Add reusable UI components for Phase 7 UI Polish

- Introduced `EmojiQuickGrid`, `FontPresetPicker`, and `PresetSizeToggle` classes for emoji selection, font presets, and size toggles respectively.
- Implemented configuration methods for each component to handle dynamic content and theming.
- Created tests for the new components to ensure functionality and integration with the existing UI.
- Added a comprehensive architecture specification for Phase 7 UI Polish detailing scope, implementation, and validation strategies.
- Updated existing theme handling to support new UI elements and ensure consistent styling across the application.
This commit is contained in:
2026-09-18 09:42:48 -04:00
parent baa19a9341
commit 1a2a43c805
35 changed files with 1791 additions and 205 deletions
+9 -8
View File
@@ -67,6 +67,10 @@ func _initialize() -> void:
func _run() -> void:
# SandboxStage opens a recovery dialog when a prior interactive run left an
# autosave. That native child window survives briefly across this suite's
# short-lived stage fixtures and contaminates camera/navigation assertions.
DirAccess.remove_absolute("user://sandbox_autosave.scene")
print("")
print("========================================================")
print(" PHASE 4b.1 FIX REGRESSION TEST (headless)")
@@ -439,18 +443,15 @@ func _test_bug5_ghost_lifecycle() -> void:
_check(ghost.get_parent() == holder, "ghost is parented to the ghost holder, not World")
_check(world.get_children().has(ghost) == false, "ghost is not a selectable World child")
_check(is_equal_approx(ghost.modulate.a, 0.5), "ghost is translucent (alpha 0.5)")
# Block-unit snapped ghost positioning (strides, not the 16-px grid/cursor).
# Single-placement preview follows the configured snap grid; footprint stride
# remains exclusively for drag painting.
stage._snap_enabled = true
var raw_pos: Vector2 = stage._camera.get_global_mouse_position() \
+ stage._spawner.get_spawn_offset("ground")
var expected_cell: Vector2i = stage._world_to_terrain_cell(raw_pos)
var expected_pos: Vector2 = stage._terrain_cell_center(expected_cell)
var expected_pos: Vector2 = stage._snap_to_grid(raw_pos)
stage._update_ghost_position()
_check(ghost.position.distance_to(expected_pos) <= 0.01,
"ghost positioned at the block-unit cell center (got %s, expected %s)" % [ghost.position, expected_pos])
_check(is_equal_approx(fmod(absf(ghost.position.x), GROUND_STRIDE_X), 0.0),
"ghost x is a multiple of the %dpx stride (got %.1f)" % [GROUND_STRIDE_X, ghost.position.x])
_check(is_equal_approx(fmod(absf(ghost.position.y), GROUND_STRIDE_Y), 0.0),
"ghost y is a multiple of the 32px stride (got %.1f)" % ghost.position.y)
"ghost positioned at the configured snap-grid point (got %s, expected %s)" % [ghost.position, expected_pos])
# Drag begin frees the single cursor ghost and builds per-cell ghosts.
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(30, 0)))
+17
View File
@@ -0,0 +1,17 @@
extends SceneTree
const SPAWNER := preload("res://scripts/stage_spawner.gd")
const CARD := preload("res://scripts/ui/asset_card.gd")
var _failures: int = 0
func _init() -> void:
var world := Node2D.new()
var spawner = SPAWNER.new(world)
var entries: Array[Dictionary] = spawner.get_palette_entries()
_check(entries.size() == 6, "six registry cards")
_check(String(entries[0].get("placement_id", "")) == "ground", "registry id retained")
var card = CARD.new(); card.configure(entries[0]); _check(card.payload().get("kind", "") == "terrain", "card copies payload")
print("PASS" if _failures == 0 else "FAIL: %d" % _failures); quit(0 if _failures == 0 else 1)
func _check(condition: bool, message: String) -> void:
if not condition: _failures += 1; push_error("Phase 7 palette: " + message)
+1
View File
@@ -0,0 +1 @@
uid://dt5c3h6p0msb2
+22
View File
@@ -0,0 +1,22 @@
extends SceneTree
const THEME_SCRIPT := preload("res://scripts/sandbox_theme.gd")
var _failures: int = 0
func _init() -> void:
var theme = THEME_SCRIPT.new()
_check(theme.load_from(), "committed v2 theme loads")
_check(theme.integer("fonts.sizes.tooltip", 0) == 18, "v2 font sizes resolve")
_check(theme.color("mode_colors.edit", Color.BLACK) == Color("#22c6ff"), "mode color resolves")
var first: StyleBoxFlat = theme.stylebox("palette")
first.bg_color = Color.RED
var second: StyleBoxFlat = theme.stylebox("palette")
_check(second.bg_color != Color.RED, "stylebox callers receive isolated copies")
_check(not theme.swatches().is_empty() and not theme.size_presets().is_empty(), "widget defaults available")
print("PASS" if _failures == 0 else "FAIL: %d" % _failures)
quit(0 if _failures == 0 else 1)
func _check(condition: bool, message: String) -> void:
if not condition:
_failures += 1
push_error("Phase 7 theme: " + message)
+1
View File
@@ -0,0 +1 @@
uid://dho8gbkh2u0k4
+60
View File
@@ -0,0 +1,60 @@
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
DirAccess.remove_absolute("user://sandbox_autosave.scene")
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
stage.get_window().size = Vector2i(1280, 720)
await process_frame
_check(stage._mode_badge.position.x >= stage._palette_dock.size.x, "badge is outside palette dock")
_check(stage._mode_frame.offset_left >= stage._palette_dock.size.x, "mode frame is inset beyond palette")
_check(stage._mode_buttons.size() == 3 and (stage._mode_buttons[0] as Button).custom_minimum_size.y >= 44.0, "mode buttons are kid-sized")
_check(stage._file_menu.get_theme_font_size("font_size") == 22, "File menu uses themed font size")
_check((stage._mode_buttons[0] as Button).get_theme_font_size("font_size") == 24, "mode switcher uses themed font size")
var mode_switcher: Control = (stage._mode_buttons[0] as Button).get_parent() as Control
_check(absf(mode_switcher.get_global_rect().get_center().x - 640.0) <= 1.0, "mode switcher remains horizontally centered at a known viewport width")
_check(mode_switcher.get_parent().mouse_filter == Control.MOUSE_FILTER_IGNORE, "full-width mode centering wrapper is mouse-transparent")
_check(stage._file_menu.get_popup().get_theme_font_size("font_size") == 26, "File popup uses independently themed font size")
_check(stage._unsaved_changes_dialog.get_theme_font_size("font_size") == 22, "dialog font-size theme metric is applied")
_check(stage._unsaved_changes_dialog.get_ok_button().get_theme_font_size("font_size") == 22, "dialog button inherits themed font size")
_check(stage._unsaved_changes_dialog.get_label().horizontal_alignment == HORIZONTAL_ALIGNMENT_CENTER and stage._unsaved_changes_dialog.get_label().get_theme_font_size("font_size") == 26, "dialog message is centered and larger")
var card: Button = stage._palette_buttons["area"]
_check(card.clip_contents and card.custom_minimum_size.y >= 100.0, "palette cards clip and reserve vertical space")
stage.set_mode(stage.StageMode.DIRECT)
_check(is_zero_approx(stage._mode_frame.offset_left), "Direct frame reclaims hidden palette width")
stage.set_mode(stage.StageMode.EDIT)
_check(stage._mode_frame.offset_left >= stage._palette_dock.size.x, "Edit frame restores palette inset")
_check(not stage.is_point_in_stage_frame(Vector2(1, 80), Rect2(100, 50, 600, 400)), "frame helper rejects a palette-side position")
_check(stage.is_point_in_stage_frame(Vector2(200, 200), Rect2(100, 50, 600, 400)), "stage frame helper accepts an inner viewport position")
stage._grid_size = 15.0
stage._snap_enabled = true
stage.set_placement_mode("step")
stage._place_at(Vector2(22.0, 23.0))
var placed: Node2D = stage._world.get_child(stage._world.get_child_count() - 1) as Node2D
_check(placed.position == Vector2(15.0, 30.0), "single terrain uses configured grid snap")
stage._open_selector("prop")
await process_frame
var selector_card: Variant = stage._selector._grid.get_child(0)
_check(selector_card.custom_minimum_size.x >= 190.0 and selector_card.custom_minimum_size.y >= 220.0, "selector uses enlarged themed card metrics")
_check(String(selector_card._label.text).length() > 0 and selector_card.thumbnail_rect().custom_minimum_size.x >= 156.0, "selector card shows a name beneath an enlarged thumbnail")
stage._on_selector_cancelled()
stage.set_mode(stage.StageMode.DIRECT)
stage._pending_walk_target = true
var right := InputEventMouseButton.new(); right.button_index = MOUSE_BUTTON_RIGHT; right.pressed = true
stage._handle_world_click(right)
_check(not stage._pending_walk_target, "RMB cancels an active direct target")
stage.queue_free()
await process_frame
print("PASS" if _failures == 0 else "FAIL: %d" % _failures)
quit(0 if _failures == 0 else 1)
func _check(condition: bool, message: String) -> void:
if not condition:
_failures += 1
push_error("Phase 7 UI follow-up: " + message)
+1
View File
@@ -0,0 +1 @@
uid://cnnlc36tnlqil
+17
View File
@@ -0,0 +1,17 @@
extends SceneTree
const COLOR_SWATCH := preload("res://scripts/ui/color_swatch_selector.gd")
const SIZE_TOGGLE := preload("res://scripts/ui/preset_size_toggle.gd")
const FONT_PICKER := preload("res://scripts/ui/font_preset_picker.gd")
const EMOJI_GRID := preload("res://scripts/ui/emoji_quick_grid.gd")
var _failures: int = 0
func _init() -> void:
var swatch = COLOR_SWATCH.new(); swatch.configure([Color.RED], Color.RED); _check(swatch.get_selected() == Color.RED, "swatch selection")
var sizes = SIZE_TOGGLE.new(); sizes.configure([{"id":"small","label":"Small","value":20}], "small"); _check(sizes.get_selected_value() == 20, "size value")
var fonts = FONT_PICKER.new(); fonts.configure([{"id":"ui","label":"UI"}], "ui"); _check(fonts.get_selected_id() == "ui", "font id")
var emoji = EMOJI_GRID.new(); emoji.configure(["😀"], 1); _check(emoji.get_child_count() == 1, "emoji controls")
print("PASS" if _failures == 0 else "FAIL: %d" % _failures); quit(0 if _failures == 0 else 1)
func _check(condition: bool, message: String) -> void:
if not condition: _failures += 1; push_error("Phase 7 widgets: " + message)
+1
View File
@@ -0,0 +1 @@
uid://btjgad0h7xge3