feat: harmonize Prop and Stickman editor UI

This commit is contained in:
2026-10-24 01:02:11 -04:00
parent ddb298f631
commit ce46b5d97e
44 changed files with 1565 additions and 256 deletions
+209
View File
@@ -0,0 +1,209 @@
extends SceneTree
const EDITOR_VISUAL_THEME := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd")
const EDITOR_CONTEXT_MENU := preload("res://scripts/shared/editor_ui/editor_context_menu.gd")
const STAGE_DIALOG_STYLER := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
const PROP_SCENE := preload("res://scenes/editors/prop/prop_editor.tscn")
const STICKMAN_SCENE := preload("res://scenes/main.tscn")
var _checks: int = 0
var _failures: int = 0
var _last_pressed_id: int = -1
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1280, 800)
root.gui_embed_subwindows = true
var visual_theme: EditorVisualTheme = EDITOR_VISUAL_THEME.new()
check("shared editor theme loads", visual_theme.load_from(EDITOR_VISUAL_THEME.DEFAULT_PATH))
var menu: EditorContextMenu = EDITOR_CONTEXT_MENU.new()
root.add_child(menu)
menu.configure(visual_theme, &"prop")
menu.add_item("Copy Shape", 1)
menu.add_separator()
menu.add_destructive_item("Erase Shape", 2)
menu.id_pressed.connect(_on_menu_item_pressed)
var copy_button: Button = menu.get_row_button(1)
var erase_button: Button = menu.get_row_button(2)
check("normal and destructive rows are created", copy_button != null and erase_button != null)
check("rows meet the Studio List target height", copy_button != null and copy_button.custom_minimum_size.y == 48.0)
check("menu rows do not show a keyboard selector on popup", copy_button != null and copy_button.focus_mode == Control.FOCUS_NONE and not copy_button.has_focus())
check("destructive label uses semantic danger color", erase_button != null and erase_button.get_theme_color("font_color").is_equal_approx(visual_theme.chrome_color(&"destructive", &"prop")))
var panel_style: StyleBoxFlat = menu.get_theme_stylebox("panel") as StyleBoxFlat
var hover_style: StyleBoxFlat = copy_button.get_theme_stylebox("hover") as StyleBoxFlat
var focus_style: StyleBoxFlat = copy_button.get_theme_stylebox("focus") as StyleBoxFlat
check("popup shell uses shared raised surface", panel_style != null and panel_style.bg_color.is_equal_approx(visual_theme.chrome_color(&"surface", &"prop")))
check("hover row uses a cyan side cue", hover_style != null and hover_style.border_width_left == 3 and hover_style.border_color.is_equal_approx(visual_theme.chrome_color(&"edit", &"prop")))
check("keyboard focus has a distinct focus outline", focus_style != null and focus_style.border_width_left == 2 and focus_style.border_color.is_equal_approx(visual_theme.chrome_color(&"focus", &"prop")))
root.size = Vector2i(900, 600)
menu.popup_at(Vector2(root.size) - Vector2(4.0, 4.0))
await process_frame
check("popup opens at the requested edge", menu.visible)
check("popup stays inside the viewport", menu.position.x >= 0 and menu.position.y >= 0 and menu.position.x + menu.size.x <= root.size.x and menu.position.y + menu.size.y <= root.size.y)
var down_event := InputEventKey.new()
down_event.keycode = KEY_DOWN
down_event.pressed = true
menu.call("_input", down_event)
await process_frame
check("Down reveals focus on the first row", copy_button.has_focus())
var up_event := InputEventKey.new()
up_event.keycode = KEY_UP
up_event.pressed = true
menu.call("_input", up_event)
await process_frame
check("Up moves focus to the last row", erase_button.has_focus())
menu.call("_on_row_mouse_entered", copy_button)
await process_frame
check("pointer entry hides the keyboard selector", not copy_button.has_focus() and not erase_button.has_focus() and copy_button.focus_mode == Control.FOCUS_NONE)
menu.hide()
var avoid_rect := Rect2(340.0, 240.0, 220.0, 120.0)
menu.popup_at(avoid_rect.get_center(), avoid_rect)
await process_frame
check("popup keeps the clicked target unobstructed when room is available", not Rect2(Vector2(menu.position), Vector2(menu.size)).intersects(avoid_rect))
erase_button.emit_signal("pressed")
await process_frame
check("activating an item emits its id and closes the popup", _last_pressed_id == 2 and not menu.visible)
menu.queue_free()
await process_frame
root.size = Vector2i(1280, 800)
await _test_prop_context_menu()
await _test_stickman_context_menus()
print("----------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func _test_prop_context_menu() -> void:
var editor: Control = PROP_SCENE.instantiate() as Control
root.add_child(editor)
await process_frame
await process_frame
_dismiss_fixture_recovery_prompt(editor)
var canvas: PropCanvas = editor.get("_canvas") as PropCanvas
canvas.add_primitive("box", Vector2.ZERO)
var shape: Dictionary = (canvas.get("shapes") as Array)[0] as Dictionary
var bounds: Rect2 = canvas.call("_shape_bbox", shape) as Rect2
var center: Vector2 = bounds.get_center()
var zoom: float = float(canvas.get("_zoom"))
var pan: Vector2 = canvas.get("_pan_offset") as Vector2
var click := InputEventMouseButton.new()
click.global_position = canvas.global_position + center * zoom + pan
canvas.call("_handle_transform_right_click", click, center)
await process_frame
var menu: EditorContextMenu = canvas.get("_context_menu") as EditorContextMenu
check("Prop opens the shared Studio List menu", menu.visible)
check("Prop uses child-readable shape action labels", menu.get_row_button(3) != null and menu.get_row_button(3).text == "Copy Shape" and menu.get_row_button(5).text == "Erase Shape")
check("Prop destructive action uses danger color", menu.get_row_button(5) != null and menu.get_row_button(5).get_theme_color("font_color").is_equal_approx((editor.get("_editor_visual_theme") as EditorVisualTheme).chrome_color(&"destructive", &"prop")))
check("Prop menu remains inside the viewport", menu.position.x >= 0 and menu.position.y >= 0 and menu.position.x + menu.size.x <= root.size.x and menu.position.y + menu.size.y <= root.size.y)
var prop_target: Rect2 = canvas.call("_global_shape_target_rect", 0) as Rect2
check("Prop menu leaves the clicked shape visible", not Rect2(Vector2(menu.position), Vector2(menu.size)).intersects(prop_target))
check("Prop menu avoids an unnecessary scrollbar", not (menu.get_node("MenuShell/MenuScroll") as ScrollContainer).get_v_scroll_bar().visible)
await _save_capture("context_menu_prop.png")
menu.hide()
editor.queue_free()
await process_frame
func _test_stickman_context_menus() -> void:
var main_scene: Control = STICKMAN_SCENE.instantiate() as Control
root.add_child(main_scene)
await process_frame
await process_frame
var editor: Control = main_scene.find_child("StickmanEditor", true, false) as Control
var panels: Dictionary = editor.get("_body_part_panels") as Dictionary
var head: BodyPartPanel = panels.get("head") as BodyPartPanel
head.add_shape(&"rectangle", head.call("drawing_center_world"))
var shape: Dictionary = (head.get("shapes") as Array)[0] as Dictionary
var points: PackedVector2Array = shape.get("points", PackedVector2Array()) as PackedVector2Array
var center := Vector2.ZERO
for point: Vector2 in points:
center += point
if not points.is_empty():
center /= float(points.size())
var click := InputEventMouseButton.new()
var drawing_area: Control = head.get("drawing_area") as Control
var head_zoom: float = float(head.get("_zoom"))
var head_pan: Vector2 = head.get("_pan_offset") as Vector2
click.global_position = drawing_area.global_position + head_pan + center * head_zoom
head.call("_handle_right_click", click, center)
await process_frame
var part_menu: EditorContextMenu = head.get("context_menu") as EditorContextMenu
check("Stickman body-part panels open the shared Studio List menu", part_menu.visible)
check("Stickman shape menu uses the shared shape wording", part_menu.get_row_button(9) != null and part_menu.get_row_button(9).text == "Copy Shape" and part_menu.get_row_button(5).text == "Erase Shape")
check("Stickman Erase Shape is coral", part_menu.get_row_button(5) != null and part_menu.get_row_button(5).get_theme_color("font_color").is_equal_approx((editor.get("_editor_visual_theme") as EditorVisualTheme).chrome_color(&"destructive", &"stickman")))
var part_target: Rect2 = head.call("_global_shape_target_rect", shape) as Rect2
check("Stickman part menu leaves the clicked shape visible", not Rect2(Vector2(part_menu.position), Vector2(part_menu.size)).intersects(part_target))
check("Stickman part menu avoids an unnecessary scrollbar", not (part_menu.get_node("MenuShell/MenuScroll") as ScrollContainer).get_v_scroll_bar().visible)
await _save_capture("context_menu_stickman_part.png")
part_menu.hide()
var preview: WholeStickmanPreview = editor.find_child("WholeStickmanPreview", true, false) as WholeStickmanPreview
var preview_area: Control = preview.get("preview_area") as Control
await process_frame
var part_bounds: Dictionary = preview.get("_part_bounds") as Dictionary
var head_bounds: Rect2 = part_bounds.get("head", Rect2()) as Rect2
var preview_zoom: float = float(preview.get("_zoom"))
var preview_pan: Vector2 = preview.get("_pan_offset") as Vector2
var preview_click := InputEventMouseButton.new()
preview_click.position = head_bounds.get_center() * preview_zoom + preview_pan
preview_click.global_position = preview_area.global_position + preview_click.position
preview.call("_handle_right_click", preview_click)
await process_frame
var preview_menu: EditorContextMenu = preview.get("_context_menu") as EditorContextMenu
check("Whole Stickman preview opens the shared Studio List menu", preview_menu.visible)
check("Whole Stickman uses child-readable flip labels", preview_menu.get_row_button(2) != null and preview_menu.get_row_button(2).text == "Flip Left-Right" and preview_menu.get_row_button(3).text == "Flip Up-Down")
var preview_target := Rect2(preview_area.global_position + preview_pan + head_bounds.position * preview_zoom, head_bounds.size * preview_zoom).grow(4.0)
check("Whole Stickman menu leaves the clicked part visible", not Rect2(Vector2(preview_menu.position), Vector2(preview_menu.size)).intersects(preview_target))
await _save_capture("context_menu_stickman_preview.png")
preview_menu.hide()
main_scene.queue_free()
await process_frame
func _save_capture(file_name: String) -> void:
if DisplayServer.get_name() == "headless":
print("SKIP visual capture under headless renderer: %s" % file_name)
return
var viewport_texture: ViewportTexture = root.get_texture()
if viewport_texture == null:
check("visual capture texture exists: %s" % file_name, false)
return
var image: Image = viewport_texture.get_image()
if image == null or image.is_empty():
check("visual capture image is available: %s" % file_name, false)
return
var path: String = ProjectSettings.globalize_path("res://.godot/" + file_name)
var error: Error = image.save_png(path)
check("visual capture saved: %s" % file_name, error == OK and not image.is_empty())
func _dismiss_fixture_recovery_prompt(editor: Control) -> void:
# A real user autosave may be present in user://. Hide the prompt for clean
# visual captures without choosing Restore or Discard or touching that file.
for child: Node in editor.get_children():
if child is ConfirmationDialog and (child as ConfirmationDialog).title == "Restore Prop?":
STAGE_DIALOG_STYLER.hide(child as ConfirmationDialog)
func _on_menu_item_pressed(id: int) -> void:
_last_pressed_id = id
func check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
push_error("FAIL: %s" % label)
+1
View File
@@ -0,0 +1 @@
uid://crigocq28ory
+46 -2
View File
@@ -2,6 +2,7 @@ extends SceneTree
const STICKMAN_SCENE := preload("res://scenes/main.tscn")
const PROP_SCENE := preload("res://scenes/editors/prop/prop_editor.tscn")
const STAGE_DIALOG_STYLER := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
var _checks: int = 0
var _failures: int = 0
@@ -171,7 +172,7 @@ func _test_stickman_workspace_and_transactions() -> void:
check("second shape can be authored", bool(head.call("add_shape", &"line", head.call("drawing_center_world"))))
editor.call("_on_file_menu_id_pressed", 2)
var unsaved_dialog: ConfirmationDialog = editor.get("_unsaved_dialog") as ConfirmationDialog
check("Start Over shows a save/discard/cancel transaction", unsaved_dialog.visible)
check("Start Over shows a save/discard/cancel transaction", STAGE_DIALOG_STYLER.is_open(unsaved_dialog))
unsaved_dialog.get_cancel_button().emit_signal("pressed")
await process_frame
check("Cancel leaves dirty content in place", bool(editor.call("has_unsaved_changes")) and (head.get("shapes") as Array).size() == 2)
@@ -197,7 +198,50 @@ func _test_prop_reshape_and_chrome() -> void:
check("Prop action target meets 44 px minimum", (editor.get("_create_point_button") as Button).custom_minimum_size.y >= 44.0)
check("new Prop has an explicit clean save status", str(save_status.text).ends_with("Not saved yet"))
check("whole-prop action uses approved discoverable wording", str((editor.get("_whole_prop_resize_button") as Button).text) == "Move, Turn, or Resize Prop")
check("material inspector uses child-facing grip language", str((editor.get("_friction_label") as Label).text).begins_with("Surface grip:"))
check("material physics tile uses child-facing grip language", str((editor.get("_friction_label") as Label).text) == "Surface grip")
var preset_option: PropInspectorDropdown = editor.get("_preset_option") as PropInspectorDropdown
check("material choices show colored swatches in field and popup", preset_option.item_count == 5 and preset_option.get_item_icon(0) != null and preset_option.get_item_icon(4) != null and preset_option.icon != null)
var material_selected_row: StyleBoxFlat = preset_option.get_popup().get_theme_stylebox("hover") as StyleBoxFlat
var material_items_plain: bool = true
for index: int in preset_option.item_count:
material_items_plain = material_items_plain and not preset_option.get_popup().is_item_checkable(index)
check("material popup uses the full-row selection highlight without radio items", material_items_plain and material_selected_row.bg_color.is_equal_approx(Color("#103B4D")) and material_selected_row.border_color.is_equal_approx(Color("#22C6FF")))
await process_frame
preset_option.show_popup()
await process_frame
check("opening the material popup focuses its current value and spans the field", preset_option.get_popup().get_focused_item() == preset_option.selected and preset_option.get_popup().size.x >= preset_option.size.x)
check("dropdown popup icons reserve a 44 px row target", preset_option.get_popup().get_item_icon(0).get_width() >= 44)
preset_option.hide_popup()
preset_option.get_popup().emit_signal("id_pressed", 1)
check("choosing a material updates the field and editor model", preset_option.selected == 1 and int(editor.get("_material_preset")) == 1)
preset_option.get_popup().emit_signal("id_pressed", 0)
var primitive_option: PropInspectorDropdown = editor.get("_primitive_option") as PropInspectorDropdown
check("2D shape choices show flat icons in field and popup", primitive_option.item_count == 5 and primitive_option.get_item_icon(0) != null and primitive_option.get_item_icon(4) != null and primitive_option.icon != null)
var shape_items_plain: bool = true
for index: int in primitive_option.item_count:
shape_items_plain = shape_items_plain and not primitive_option.get_popup().is_item_checkable(index)
check("shape popup does not draw radio selectors", shape_items_plain)
primitive_option.show_popup()
await process_frame
check("shape popup highlights its current 2D choice", primitive_option.get_popup().get_focused_item() == primitive_option.selected)
primitive_option.hide_popup()
primitive_option.get_popup().emit_signal("id_pressed", 2)
check("choosing a 2D shape updates its parameters", primitive_option.selected == 2 and (editor.get("_param_spins") as Dictionary).has("base"))
primitive_option.get_popup().emit_signal("id_pressed", 0)
var tag_buttons: Dictionary = editor.get("_tag_checks") as Dictionary
var nature_button: Button = tag_buttons.get("nature") as Button
check("artwork tag chip has a colored icon and a wide touch target", nature_button != null and nature_button.icon != null and nature_button.custom_minimum_size.y >= 48.0)
var nature_icon: Texture2D = nature_button.icon
nature_button.set_pressed_no_signal(true)
nature_button.emit_signal("toggled", true)
var selected_tag_style := nature_button.get_theme_stylebox("pressed") as StyleBoxFlat
check("tag selection updates its model", (editor.get("_tags") as Array).has("nature"))
check("selected tag keeps its original colored icon without a badge", nature_button.icon == nature_icon)
check("selected tag style uses a cyan-tinted highlight", selected_tag_style.bg_color.is_equal_approx(Color("#103B4D")))
nature_button.set_pressed_no_signal(false)
nature_button.emit_signal("toggled", false)
check("clearing a tag removes it from the tag model", not (editor.get("_tags") as Array).has("nature"))
check("inspector sections use themed cards", editor.get("_material_section") is PanelContainer and editor.get("_add_section") is PanelContainer and editor.get("_tag_section") is PanelContainer)
check("anchor layer control uses the approved label", (editor.get("_anchor_z") as SpinBox).get_parent().get_child(0) is Label and str(((editor.get("_anchor_z") as SpinBox).get_parent().get_child(0) as Label).text) == "Held prop layer:")
canvas.call("add_primitive", "box", Vector2(150.0, 150.0))
+3
View File
@@ -15,6 +15,9 @@ func _run() -> void:
check("committed shared theme loads", defaults.load_from(EDITOR_VISUAL_THEME.DEFAULT_PATH))
check("default body font is 18", defaults.chrome_px(&"body", &"prop") == 18)
check("default touch target is 44 px", defaults.chrome_px(&"button_min_height", &"stickman") == 44)
check("selected surface has a shared semantic token", defaults.chrome_color(&"selected_surface", &"prop").is_equal_approx(Color("#103B4D")))
check("Studio List rows are 48 px", defaults.chrome_px(&"context_menu_row_height", &"stickman") == 48)
check("Studio List minimum width is 208 px", defaults.chrome_px(&"context_menu_min_width", &"prop") == 208)
check("preview role preserves alpha", is_equal_approx(defaults.chrome_color(&"preview").a, 0.4))
var custom_path: String = "user://editor_visual_theme_test.json"