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
+32 -12
View File
@@ -76,6 +76,7 @@ const PropFileScript := preload("res://scripts/editors/prop/prop_file.gd")
const PropAnchorOverlayScript := preload("res://scripts/props/prop_anchor_overlay.gd")
const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
const WholeStickmanPreviewScript := preload("res://scripts/editors/stickman/whole_stickman_preview.gd")
const EditorContextMenuScript := preload("res://scripts/shared/editor_ui/editor_context_menu.gd")
const SELECTION_CORE_COLOR := Color.WHITE
const SELECTION_HALO_COLOR := Color(0.0, 0.0, 0.0, 0.85)
@@ -122,7 +123,7 @@ var _grid_size: int = 15
var _snap_enabled: bool = false
var _has_clipboard: bool = false
var _create_point_armed: bool = false
var _context_menu: PopupMenu
var _context_menu: EditorContextMenu
var _edit_mode: EditMode = EditMode.TRANSFORM
var _anchor_overlay: RefCounted
var _origin_marker_color: Color = ORIGIN_MARKER_DEFAULT_COLOR
@@ -189,7 +190,7 @@ func _ready() -> void:
_anchor_overlay = PropAnchorOverlayScript.new()
_anchor_overlay.anchors_changed.connect(func(data: Dictionary) -> void: anchors_changed.emit(data); queue_redraw())
_anchor_overlay.anchor_selected.connect(func(anchor_id: String) -> void: anchor_selected.emit(anchor_id); queue_redraw())
_context_menu = PopupMenu.new()
_context_menu = EditorContextMenuScript.new() as EditorContextMenu
_context_menu.name = "ContextMenu"
add_child(_context_menu)
if not _context_menu.id_pressed.is_connected(_on_context_menu_id_pressed):
@@ -983,7 +984,12 @@ func set_has_clipboard(state: bool) -> void:
## Sets the font size of the right-click context menu (Round 4, theme-driven).
func set_context_menu_font_size(size: int) -> void:
if _context_menu != null:
_context_menu.add_theme_font_size_override("font_size", size)
_context_menu.set_font_size(size)
func set_context_menu_visual_theme(visual_theme: EditorVisualTheme, editor: StringName) -> void:
if _context_menu != null:
_context_menu.configure(visual_theme, editor)
func is_cursor_over_drawing(global_pos: Vector2) -> bool:
@@ -1335,8 +1341,8 @@ func _handle_points_right_click(mb: InputEventMouseButton, world_pos: Vector2) -
if context_shape >= 0 and context_vertex >= 0:
_context_menu.set_meta("context_shape", context_shape)
_context_menu.set_meta("context_vertex", context_vertex)
_context_menu.add_item("Remove Point", MENU_REMOVE_POINT)
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
_context_menu.add_destructive_item("Remove Point", MENU_REMOVE_POINT)
_context_menu.popup_at(mb.global_position, _global_point_target_rect(world_pos))
return
# Segment proximity -> Create Point (selects the shape first).
@@ -1348,7 +1354,7 @@ func _handle_points_right_click(mb: InputEventMouseButton, world_pos: Vector2) -
queue_redraw()
shape_selected.emit(seg_shape)
_context_menu.add_item("Create Point", MENU_CREATE_POINT)
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
_context_menu.popup_at(mb.global_position, _global_shape_target_rect(seg_shape))
return
# Interior body: select only, no menu.
@@ -1381,17 +1387,18 @@ func _handle_transform_right_click(mb: InputEventMouseButton, world_pos: Vector2
_context_menu.set_meta("click_position", world_pos)
queue_redraw()
shape_selected.emit(hit_shape)
_context_menu.add_item("Copy", MENU_COPY)
_context_menu.add_item("Copy Shape", MENU_COPY)
if _has_clipboard:
_context_menu.add_item("Paste", MENU_PASTE)
_context_menu.add_item("Erase", MENU_DELETE)
_context_menu.add_item("Paste Shape", MENU_PASTE)
_context_menu.add_separator()
_context_menu.add_item("Flip Left-Right", MENU_MIRROR_X)
_context_menu.add_item("Flip Up-Down", MENU_MIRROR_Y)
_context_menu.add_separator()
_context_menu.add_item("Bring Forward", MENU_BRING_FORWARD)
_context_menu.add_item("Send Back", MENU_SEND_BACK)
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
_context_menu.add_separator()
_context_menu.add_destructive_item("Erase Shape", MENU_DELETE)
_context_menu.popup_at(mb.global_position, _global_shape_target_rect(hit_shape))
return
# Empty canvas -> add primitives.
@@ -1403,8 +1410,8 @@ func _handle_transform_right_click(mb: InputEventMouseButton, world_pos: Vector2
_context_menu.add_item("Add Circle", MENU_ADD_CIRCLE)
if _has_clipboard:
_context_menu.add_separator()
_context_menu.add_item("Paste", MENU_PASTE)
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
_context_menu.add_item("Paste Shape", MENU_PASTE)
_context_menu.popup_at(mb.global_position)
func _handle_left_press(world_pos: Vector2) -> void:
@@ -1857,6 +1864,19 @@ func _screen_to_world(pos: Vector2) -> Vector2:
return (pos - _pan_offset) / _zoom
func _global_point_target_rect(world_pos: Vector2) -> Rect2:
var screen_pos := global_position + _pan_offset + world_pos * _zoom
return Rect2(screen_pos - Vector2(6.0, 6.0), Vector2(12.0, 12.0))
func _global_shape_target_rect(shape_idx: int) -> Rect2:
if shape_idx < 0 or shape_idx >= shapes.size():
return Rect2()
var world_bounds: Rect2 = _shape_bbox(shapes[shape_idx])
var screen_position := global_position + _pan_offset + world_bounds.position * _zoom
return Rect2(screen_position, world_bounds.size * _zoom).grow(4.0)
func _snap_to_grid(pos: Vector2) -> Vector2:
var gs := float(_grid_size)
if gs <= 0.0:
+276 -118
View File
@@ -13,6 +13,8 @@ extends Control
const PropFileScript := preload("res://scripts/editors/prop/prop_file.gd")
const PropThemeScript := preload("res://scripts/editors/prop/prop_theme.gd")
const EditorVisualThemeScript := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd")
const SandboxThemeScript := preload("res://scripts/stage/sandbox_theme.gd")
const StageDialogStylerScript := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
const PropCanvasScript := preload("res://scripts/editors/prop/prop_canvas.gd")
const PropThumbnailScript := preload("res://scripts/shared/thumbnails/prop_thumbnail.gd")
@@ -20,6 +22,7 @@ const ThumbnailCacheScript := preload("res://scripts/shared/thumbnails/thumbnail
const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
const PropAttachPreviewScene := preload("res://scenes/editors/prop/prop_attach_preview.tscn")
const ContentPathsScript := preload("res://scripts/shared/content_paths.gd")
const PropInspectorIconsScript := preload("res://scripts/editors/prop/prop_inspector_icons.gd")
const PRESET_LABELS: PackedStringArray = ["None", "Wood", "Rubber", "Cardboard", "Metal"]
const PRIMITIVE_IDS: PackedStringArray = ["box", "plank", "triangle", "line", "circle"]
@@ -116,6 +119,7 @@ static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool:
var _theme: PropTheme
var _editor_visual_theme: EditorVisualTheme
var _dialog_theme: SandboxTheme
var _material_preset: int = PropBlockScript.MaterialPreset.NONE
var _current_path: String = ""
var _dirty: bool = false
@@ -128,11 +132,14 @@ var _thumb_cache: ThumbnailCache
var _ui_theme: Theme
# Inspector controls
var _preset_option: OptionButton
var _material_section: VBoxContainer
var _preset_option: PropInspectorDropdown
var _material_section: PanelContainer
var _mass_label: Label
var _mass_value: Label
var _friction_label: Label
var _friction_value: Label
var _bounce_label: Label
var _bounce_value: Label
var _fill_button: ColorPickerButton
var _outline_button: ColorPickerButton
var _width_spin: SpinBox
@@ -143,14 +150,14 @@ var _vertex_label: Label
var _delete_button: Button
var _create_point_button: Button
var _create_point_hint: Label
var _shape_section: VBoxContainer
var _primitive_option: OptionButton
var _shape_section: PanelContainer
var _primitive_option: PropInspectorDropdown
var _params_box: VBoxContainer
var _param_spins: Dictionary = {}
var _add_button: Button
var _updating_inspector: bool = false
var _tag_checks: Dictionary = {}
var _tag_section: VBoxContainer
var _tag_section: PanelContainer
var _stage_authoring: bool = false
var _stage_return_pending: bool = false
var _stage_place_button: Button
@@ -177,7 +184,7 @@ var _points_button: Button
var _attach_button: Button
# Add-shape inspector section (hidden in Reshape mode, Round 3)
var _add_section: VBoxContainer
var _add_section: PanelContainer
var _anchor_section: VBoxContainer
var _anchor_list: ItemList
var _anchor_role: OptionButton
@@ -218,6 +225,8 @@ func _ready() -> void:
get_tree().auto_accept_quit = false
_editor_visual_theme = EditorVisualThemeScript.new()
_editor_visual_theme.load_from(EditorVisualTheme.DEFAULT_PATH)
_dialog_theme = SandboxThemeScript.new()
_dialog_theme.load_from(SandboxThemeScript.DEFAULT_PATH)
_theme = PropThemeScript.new()
_theme.load_from(PropThemeScript.DEFAULT_PATH)
_thumb_cache = ThumbnailCacheScript.new()
@@ -254,6 +263,7 @@ func _ready() -> void:
_canvas.set_grid_size(_theme.grid_size())
_canvas.set_snap_enabled(false)
_canvas.set_has_clipboard(false)
_canvas.set_context_menu_visual_theme(_editor_visual_theme, &"prop")
_canvas.set_context_menu_font_size(_theme.font_context_menu())
_canvas.set_anchor_data(_anchors)
var origin_marker_color: Color = _theme.text_muted_color()
@@ -503,26 +513,26 @@ func _setup_menu_bar() -> void:
var file_menu := PopupMenu.new()
file_menu.name = "FileMenu"
file_menu.add_item("🆕 New", MENU_NEW)
file_menu.add_item("💾 Save", MENU_SAVE)
file_menu.add_item("📄 Save As...", MENU_SAVE_AS)
file_menu.add_item("📂 Load...", MENU_LOAD)
file_menu.add_separator()
file_menu.add_item("📂 Load…", MENU_LOAD)
var recent_popup := PopupMenu.new()
recent_popup.name = "RecentProps"
recent_popup.id_pressed.connect(_on_recent_prop_selected)
file_menu.add_child(recent_popup)
file_menu.add_submenu_item("🕑 Recent", "RecentProps")
file_menu.add_submenu_item("🕑 Open Recent", "RecentProps")
file_menu.add_separator()
file_menu.add_item("💾 Save", MENU_SAVE)
file_menu.add_item("📄 Save As…", MENU_SAVE_AS)
file_menu.id_pressed.connect(_on_menu_id_pressed)
file_menu.about_to_popup.connect(_update_recent_menu)
_menu_bar.add_child(file_menu)
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "📁 File")
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "File")
var view_menu := PopupMenu.new()
view_menu.name = "ViewMenu"
view_menu.add_item("🔍 Reset View", MENU_RESET_VIEWS)
view_menu.add_item("🔍 Reset Views", MENU_RESET_VIEWS)
view_menu.id_pressed.connect(_on_menu_id_pressed)
_menu_bar.add_child(view_menu)
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "👁️ View")
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "View")
# ---------------------------------------------------------------------------
@@ -617,25 +627,31 @@ func _setup_dialogs() -> void:
_editor_visual_theme.style_button_role(_save_dialog.get_cancel_button(), &"secondary", &"prop")
_editor_visual_theme.style_button_role(_load_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(_load_dialog.get_cancel_button(), &"secondary", &"prop")
_editor_visual_theme.style_button_role(_error_dialog.get_ok_button(), &"secondary", &"prop")
_error_dialog.get_ok_button().text = "Got it"
StageDialogStylerScript.apply(
_error_dialog, _dialog_theme, _dialog_theme.font(),
_error_dialog.get_ok_button()
)
_build_exit_dialog()
_rename_anchor_dialog = ConfirmationDialog.new()
_rename_anchor_dialog.title = "Rename Anchor"
_rename_anchor_dialog.ok_button_text = "Rename"
_rename_anchor_dialog.theme = _ui_theme
_editor_visual_theme.style_button_role(_rename_anchor_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(_rename_anchor_dialog.get_cancel_button(), &"secondary", &"prop")
_rename_anchor_dialog.set_meta(StageDialogStylerScript.ICON_META, "📝")
_rename_anchor_edit = LineEdit.new()
_rename_anchor_edit.placeholder_text = "Anchor name"
_rename_anchor_dialog.add_child(_rename_anchor_edit)
add_child(_rename_anchor_dialog)
StageDialogStylerScript.apply(
_rename_anchor_dialog, _dialog_theme, _dialog_theme.font(),
_rename_anchor_dialog.get_ok_button()
)
_rename_anchor_edit.text_changed.connect(_update_rename_anchor_confirmation)
_rename_anchor_edit.text_submitted.connect(_on_rename_anchor_text_submitted)
_rename_anchor_dialog.confirmed.connect(_commit_anchor_rename)
_rename_anchor_dialog.canceled.connect(_clear_pending_anchor_rename)
func _on_rename_anchor_text_submitted(_text: String) -> void:
if _rename_anchor_dialog == null or not _rename_anchor_dialog.visible:
if _rename_anchor_dialog == null or not StageDialogStylerScript.is_open(_rename_anchor_dialog):
return
var ok_button := _rename_anchor_dialog.get_ok_button()
if not ok_button.disabled:
@@ -886,30 +902,31 @@ func _on_whole_prop_rotate_requested(angle_rad: float) -> void:
func _build_exit_dialog() -> void:
_exit_dialog = ConfirmationDialog.new()
_exit_dialog.title = "⚠️ Unsaved Changes"
_exit_dialog.title = "Unsaved Changes"
_exit_dialog.set_meta(StageDialogStylerScript.ICON_META, "💾")
_exit_dialog.dialog_text = "You have unsaved changes. Save them before quitting?"
_exit_dialog.ok_button_text = "💾 Save"
var discard_button: Button = _exit_dialog.add_button("🗑️ Discard", false, "discard")
_exit_dialog.get_cancel_button().text = "❌ Cancel"
_exit_dialog.theme = _ui_theme
_editor_visual_theme.style_button_role(_exit_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(discard_button, &"destructive", &"prop")
_editor_visual_theme.style_button_role(_exit_dialog.get_cancel_button(), &"secondary", &"prop")
_exit_dialog.ok_button_text = "Save"
var discard_button: Button = _exit_dialog.add_button("Discard", false, "discard")
_exit_dialog.get_cancel_button().text = "Cancel"
add_child(_exit_dialog)
StageDialogStylerScript.apply(
_exit_dialog, _dialog_theme, _dialog_theme.font(),
_exit_dialog.get_ok_button(), [discard_button]
)
_exit_dialog.confirmed.connect(_on_exit_save)
_exit_dialog.custom_action.connect(_on_exit_custom_action)
_exit_dialog.canceled.connect(_on_exit_cancel)
_off_origin_save_dialog = ConfirmationDialog.new()
_off_origin_save_dialog.title = "🎯 Center Prop?"
_off_origin_save_dialog.dialog_text = "This prop is far from (0,0). Centering it keeps its shapes and anchors together.\n\nWhat would you like to do?"
_off_origin_save_dialog.ok_button_text = "🎯 Center & Save"
var save_anyway_button: Button = _off_origin_save_dialog.add_button("💾 Save Anyway", false, "save_anyway")
_off_origin_save_dialog.get_cancel_button().text = "❌ Cancel"
_off_origin_save_dialog.theme = _ui_theme
_editor_visual_theme.style_button_role(_off_origin_save_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(save_anyway_button, &"secondary", &"prop")
_editor_visual_theme.style_button_role(_off_origin_save_dialog.get_cancel_button(), &"secondary", &"prop")
_off_origin_save_dialog.ok_button_text = "Center & Save"
var save_anyway_button: Button = _off_origin_save_dialog.add_button("Save Anyway", false, "save_anyway")
_off_origin_save_dialog.get_cancel_button().text = "Cancel"
add_child(_off_origin_save_dialog)
StageDialogStylerScript.apply(
_off_origin_save_dialog, _dialog_theme, _dialog_theme.font(),
_off_origin_save_dialog.get_ok_button(), [], [save_anyway_button]
)
_off_origin_save_dialog.confirmed.connect(func() -> void: await _execute_pending_save(true))
_off_origin_save_dialog.custom_action.connect(func(action: StringName) -> void:
if action == &"save_anyway": await _execute_pending_save(false))
@@ -931,7 +948,7 @@ func _request_close() -> void:
_pending_quit = true
_pending_new = false
_exit_dialog.dialog_text = "You have unsaved changes. Save them before quitting?"
_exit_dialog.popup_centered()
StageDialogStylerScript.popup(_exit_dialog)
func _request_new() -> void:
@@ -942,7 +959,7 @@ func _request_new() -> void:
_pending_new = true
_pending_quit = false
_exit_dialog.dialog_text = "You have unsaved changes. Save them before starting a new prop?"
_exit_dialog.popup_centered()
StageDialogStylerScript.popup(_exit_dialog)
func _request_stage_return() -> void:
@@ -958,11 +975,11 @@ func _request_stage_return() -> void:
_pending_new = false
_pending_quit = false
_exit_dialog.dialog_text = "Save your artwork before returning? Saving places it on the Stage."
_exit_dialog.popup_centered()
StageDialogStylerScript.popup(_exit_dialog)
func _on_exit_save() -> void:
_exit_dialog.hide()
StageDialogStylerScript.hide(_exit_dialog)
if _current_path.is_empty():
_save_dialog.popup_centered()
return
@@ -971,7 +988,7 @@ func _on_exit_save() -> void:
func _on_exit_custom_action(action: StringName) -> void:
if action == &"discard":
_exit_dialog.hide()
StageDialogStylerScript.hide(_exit_dialog)
if not _stage_authoring:
_clear_autosave()
if _stage_return_pending:
@@ -1046,47 +1063,60 @@ func _on_menu_id_pressed(id: int) -> void:
# ---------------------------------------------------------------------------
func _build_inspector() -> void:
var header_size: int = _theme.font_header()
var row_height: int = _theme.row_min_height()
_inspector_list.add_theme_constant_override("separation", 10)
_inspector_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
# Material preset section
_material_section = VBoxContainer.new()
_material_section.add_theme_constant_override("separation", 6)
_material_section = _make_inspector_card()
_inspector_list.add_child(_material_section)
var material_title := Label.new()
material_title.text = "Material"
material_title.add_theme_font_size_override("font_size", header_size)
_material_section.add_child(material_title)
var material_content: VBoxContainer = _inspector_card_content(_material_section)
material_content.add_child(_make_inspector_header("Material", PropInspectorIconsScript.section("material")))
_preset_option = OptionButton.new()
for label: String in PRESET_LABELS:
_preset_option.add_item(label)
_preset_option = PropInspectorDropdown.new()
for index: int in PRESET_LABELS.size():
_preset_option.add_icon_item(
PropInspectorIconsScript.material(index, _theme.material_tint(index, "fill")),
PRESET_LABELS[index]
)
_preset_option.selected = _material_preset
_preset_option.custom_minimum_size.y = float(_theme.button_min_height())
_preset_option.configure(
_ui_theme,
_theme.inspector_background_color(),
_editor_visual_theme.chrome_color(&"selected_surface", &"prop"),
_theme.border_color(),
_theme.accent_color(),
_editor_visual_theme.chrome_color(&"focus", &"prop"),
_theme.text_color(),
_theme.font_body()
)
_preset_option.item_selected.connect(_on_preset_selected)
_material_section.add_child(_preset_option)
material_content.add_child(_preset_option)
_mass_label = Label.new()
_friction_label = Label.new()
_bounce_label = Label.new()
_material_section.add_child(_mass_label)
_material_section.add_child(_friction_label)
_material_section.add_child(_bounce_label)
var stats := HBoxContainer.new()
stats.add_theme_constant_override("separation", 6)
material_content.add_child(stats)
var mass_tile: Array[Label] = _make_stat_tile(stats, "Mass")
_mass_label = mass_tile[0]
_mass_value = mass_tile[1]
var grip_tile: Array[Label] = _make_stat_tile(stats, "Surface grip")
_friction_label = grip_tile[0]
_friction_value = grip_tile[1]
var bounce_tile: Array[Label] = _make_stat_tile(stats, "Bounce")
_bounce_label = bounce_tile[0]
_bounce_value = bounce_tile[1]
_update_physics_labels()
# Selected-shape section
_shape_section = VBoxContainer.new()
_shape_section.add_theme_constant_override("separation", 6)
_shape_section = _make_inspector_card()
_inspector_list.add_child(_shape_section)
var shape_title := Label.new()
shape_title.text = "Selected Shape"
shape_title.add_theme_font_size_override("font_size", header_size)
_shape_section.add_child(shape_title)
var shape_content: VBoxContainer = _inspector_card_content(_shape_section)
shape_content.add_child(_make_inspector_header("Selected Shape", PropInspectorIconsScript.section("selected_shape")))
var fill_row := HBoxContainer.new()
fill_row.custom_minimum_size.y = float(row_height)
_shape_section.add_child(fill_row)
shape_content.add_child(fill_row)
var fill_label := Label.new()
fill_label.text = "Fill:"
fill_label.custom_minimum_size = Vector2(90.0, 0.0)
@@ -1099,7 +1129,7 @@ func _build_inspector() -> void:
var outline_row := HBoxContainer.new()
outline_row.custom_minimum_size.y = float(row_height)
_shape_section.add_child(outline_row)
shape_content.add_child(outline_row)
var outline_label := Label.new()
outline_label.text = "Outline:"
outline_label.custom_minimum_size = Vector2(90.0, 0.0)
@@ -1112,7 +1142,7 @@ func _build_inspector() -> void:
var width_row := HBoxContainer.new()
width_row.custom_minimum_size.y = float(row_height)
_shape_section.add_child(width_row)
shape_content.add_child(width_row)
var width_label := Label.new()
width_label.text = "Width:"
width_label.custom_minimum_size = Vector2(90.0, 0.0)
@@ -1128,7 +1158,7 @@ func _build_inspector() -> void:
_radius_row = HBoxContainer.new()
_radius_row.custom_minimum_size.y = float(row_height)
_shape_section.add_child(_radius_row)
shape_content.add_child(_radius_row)
var radius_label := Label.new()
radius_label.text = "Radius:"
radius_label.custom_minimum_size = Vector2(90.0, 0.0)
@@ -1145,10 +1175,10 @@ func _build_inspector() -> void:
_reshaped_note = Label.new()
_reshaped_note.text = "✏️ This shape was reshaped"
_reshaped_note.add_theme_color_override("font_color", _theme.text_muted_color())
_shape_section.add_child(_reshaped_note)
shape_content.add_child(_reshaped_note)
_vertex_label = Label.new()
_shape_section.add_child(_vertex_label)
shape_content.add_child(_vertex_label)
_create_point_button = Button.new()
_create_point_button.text = "📍 Create Point"
@@ -1156,46 +1186,55 @@ func _build_inspector() -> void:
_create_point_button.custom_minimum_size.y = float(_theme.button_min_height())
_editor_visual_theme.style_button_role(_create_point_button, &"primary", &"prop")
_create_point_button.pressed.connect(_on_create_point_pressed)
_shape_section.add_child(_create_point_button)
shape_content.add_child(_create_point_button)
_create_point_hint = Label.new()
_create_point_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_create_point_hint.add_theme_color_override("font_color", _theme.text_muted_color())
_shape_section.add_child(_create_point_hint)
shape_content.add_child(_create_point_hint)
_delete_button = Button.new()
_delete_button.text = "Erase Shape"
_delete_button.custom_minimum_size.y = float(_theme.button_min_height())
_editor_visual_theme.style_button_role(_delete_button, &"destructive", &"prop")
_delete_button.pressed.connect(_on_delete_shape_pressed)
_shape_section.add_child(_delete_button)
shape_content.add_child(_delete_button)
# Add-shape section (hidden in Reshape mode, Round 3)
_add_section = VBoxContainer.new()
_add_section.add_theme_constant_override("separation", 8)
_add_section = _make_inspector_card()
_inspector_list.add_child(_add_section)
var add_content: VBoxContainer = _inspector_card_content(_add_section)
add_content.add_child(_make_inspector_header("Add Shape", PropInspectorIconsScript.section("add_shape")))
var add_title := Label.new()
add_title.text = "Add Shape"
add_title.add_theme_font_size_override("font_size", header_size)
_add_section.add_child(add_title)
_primitive_option = OptionButton.new()
for label: String in PRIMITIVE_LABELS:
_primitive_option.add_item(label)
_primitive_option = PropInspectorDropdown.new()
for index: int in PRIMITIVE_LABELS.size():
_primitive_option.add_icon_item(
PropInspectorIconsScript.shape(PRIMITIVE_IDS[index], _primitive_icon_color(PRIMITIVE_IDS[index])),
PRIMITIVE_LABELS[index]
)
_primitive_option.custom_minimum_size.y = float(_theme.button_min_height())
_primitive_option.configure(
_ui_theme,
_theme.inspector_background_color(),
_editor_visual_theme.chrome_color(&"selected_surface", &"prop"),
_theme.border_color(),
_theme.accent_color(),
_editor_visual_theme.chrome_color(&"focus", &"prop"),
_theme.text_color(),
_theme.font_body()
)
_primitive_option.item_selected.connect(_on_primitive_selected)
_add_section.add_child(_primitive_option)
add_content.add_child(_primitive_option)
_params_box = VBoxContainer.new()
_params_box.add_theme_constant_override("separation", 4)
_add_section.add_child(_params_box)
add_content.add_child(_params_box)
_add_button = Button.new()
_add_button.text = "Add a Shape"
_add_button.custom_minimum_size.y = float(_theme.button_min_height())
_editor_visual_theme.style_button_role(_add_button, &"primary", &"prop")
_add_button.pressed.connect(_on_add_pressed)
_add_section.add_child(_add_button)
add_content.add_child(_add_button)
_apply_control_theme(_inspector_list)
_rebuild_primitive_params()
@@ -1204,6 +1243,117 @@ func _build_inspector() -> void:
_refresh_tag_controls()
func _make_inspector_card() -> PanelContainer:
var card := PanelContainer.new()
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var style := StyleBoxFlat.new()
style.bg_color = _theme.inspector_background_color().darkened(0.07)
style.border_color = _theme.border_color()
style.set_border_width_all(1)
style.set_corner_radius_all(10)
style.content_margin_left = 10.0
style.content_margin_top = 10.0
style.content_margin_right = 10.0
style.content_margin_bottom = 10.0
card.add_theme_stylebox_override("panel", style)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 8)
card.add_child(content)
return card
func _inspector_card_content(card: PanelContainer) -> VBoxContainer:
return card.get_child(0) as VBoxContainer
func _make_inspector_header(title: String, icon: Texture2D) -> HBoxContainer:
var header := HBoxContainer.new()
header.add_theme_constant_override("separation", 8)
var icon_view := TextureRect.new()
icon_view.texture = icon
icon_view.custom_minimum_size = Vector2(30.0, 30.0)
icon_view.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon_view.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon_view.mouse_filter = Control.MOUSE_FILTER_IGNORE
header.add_child(icon_view)
var label := Label.new()
label.text = title
label.add_theme_font_size_override("font_size", _theme.font_header())
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(label)
return header
func _make_stat_tile(parent: Control, caption_text: String) -> Array[Label]:
var tile := PanelContainer.new()
tile.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var style := StyleBoxFlat.new()
style.bg_color = _editor_visual_theme.chrome_color(&"background", &"prop").darkened(0.02)
style.border_color = _theme.border_color()
style.set_border_width_all(1)
style.set_corner_radius_all(8)
style.content_margin_left = 7.0
style.content_margin_top = 7.0
style.content_margin_right = 7.0
style.content_margin_bottom = 7.0
tile.add_theme_stylebox_override("panel", style)
parent.add_child(tile)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 1)
tile.add_child(content)
var caption := Label.new()
caption.text = caption_text
caption.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
caption.add_theme_font_size_override("font_size", 14)
caption.add_theme_color_override("font_color", _theme.text_muted_color())
content.add_child(caption)
var value := Label.new()
value.add_theme_font_size_override("font_size", 22)
content.add_child(value)
return [caption, value]
func _style_tag_button(button: Button) -> void:
var text_color: Color = _theme.text_color()
var surface: Color = _editor_visual_theme.chrome_color(&"background", &"prop")
var border: Color = _theme.border_color()
var accent: Color = _theme.accent_color()
button.add_theme_stylebox_override("normal", _make_tag_style(surface, border, 1))
button.add_theme_stylebox_override("hover", _make_tag_style(surface.lightened(0.12), accent, 1))
button.add_theme_stylebox_override("pressed", _make_tag_style(_editor_visual_theme.chrome_color(&"selected_surface", &"prop"), accent, 2))
button.add_theme_stylebox_override("focus", _make_tag_style(Color(0.0, 0.0, 0.0, 0.0), _editor_visual_theme.chrome_color(&"focus", &"prop"), 2))
button.add_theme_color_override("font_color", text_color)
button.add_theme_color_override("font_hover_color", text_color)
button.add_theme_color_override("font_pressed_color", text_color)
button.add_theme_color_override("icon_normal_color", Color.WHITE)
button.add_theme_color_override("icon_hover_color", Color.WHITE)
button.add_theme_color_override("icon_pressed_color", Color.WHITE)
func _make_tag_style(fill: Color, border: Color, border_width: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = fill
style.border_color = border
style.set_border_width_all(border_width)
style.set_corner_radius_all(8)
style.content_margin_left = 7.0
style.content_margin_top = 5.0
style.content_margin_right = 7.0
style.content_margin_bottom = 5.0
return style
func _primitive_icon_color(primitive_id: String) -> Color:
match primitive_id:
"box": return Color("#70C8F0")
"plank": return Color("#F3B65D")
"triangle": return Color("#66C681")
"line": return Color("#DDE7F5")
"circle": return Color("#C9A3FF")
return _theme.accent_color()
func _build_stage_authoring_controls() -> void:
var top_bar: HBoxContainer = get_node_or_null(NodePath("ChromeRoot/TopBar")) as HBoxContainer
if top_bar == null:
@@ -1244,29 +1394,30 @@ func _apply_authoring_context() -> void:
func _build_tag_inspector() -> void:
_tag_section = VBoxContainer.new()
_tag_section.add_theme_constant_override("separation", 4)
_tag_section = _make_inspector_card()
_inspector_list.add_child(_tag_section)
var title := Label.new()
title.text = "Artwork Tags"
title.add_theme_font_size_override("font_size", _theme.font_header())
_tag_section.add_child(title)
var tag_content: VBoxContainer = _inspector_card_content(_tag_section)
tag_content.add_child(_make_inspector_header("Artwork Tags", PropInspectorIconsScript.section("tags")))
var hint := Label.new()
hint.text = "Choose a few so this art is easy to find."
hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_tag_section.add_child(hint)
hint.add_theme_color_override("font_color", _theme.text_muted_color())
tag_content.add_child(hint)
var rows := FlowContainer.new()
rows.add_theme_constant_override("h_separation", 6)
rows.add_theme_constant_override("v_separation", 4)
_tag_section.add_child(rows)
rows.add_theme_constant_override("v_separation", 6)
tag_content.add_child(rows)
for index: int in ART_TAG_IDS.size():
var tag_id: String = ART_TAG_IDS[index]
var check := CheckBox.new()
check.text = ART_TAG_LABELS[index]
check.custom_minimum_size.y = float(_theme.button_min_height())
check.toggled.connect(_on_art_tag_toggled.bind(tag_id))
rows.add_child(check)
_tag_checks[tag_id] = check
var tag_button := Button.new()
tag_button.text = ART_TAG_LABELS[index]
tag_button.toggle_mode = true
tag_button.custom_minimum_size = Vector2(122.0, 50.0)
tag_button.icon = PropInspectorIconsScript.tag(tag_id)
tag_button.toggled.connect(_on_art_tag_toggled.bind(tag_id))
_style_tag_button(tag_button)
rows.add_child(tag_button)
_tag_checks[tag_id] = tag_button
func _on_art_tag_toggled(enabled: bool, tag_id: String) -> void:
@@ -1285,9 +1436,9 @@ func _refresh_tag_controls() -> void:
return
_updating_inspector = true
for tag_id: String in ART_TAG_IDS:
var check: CheckBox = _tag_checks.get(tag_id) as CheckBox
if check != null:
check.set_pressed_no_signal(_tags.has(tag_id))
var tag_button: Button = _tag_checks.get(tag_id) as Button
if tag_button != null:
tag_button.set_pressed_no_signal(_tags.has(tag_id))
_updating_inspector = false
func _build_anchor_inspector() -> void:
@@ -1397,13 +1548,17 @@ func _on_rename_anchor_pressed() -> void:
_rename_anchor_source_id = _selected_anchor_id
_rename_anchor_edit.text = _rename_anchor_source_id
_update_rename_anchor_confirmation(_rename_anchor_edit.text)
_rename_anchor_dialog.popup_centered(Vector2i(420, 150))
StageDialogStylerScript.popup(_rename_anchor_dialog)
_rename_anchor_edit.call_deferred("grab_focus")
_rename_anchor_edit.call_deferred("select_all")
func _update_rename_anchor_confirmation(value: String) -> void:
if _rename_anchor_dialog != null:
_rename_anchor_dialog.get_ok_button().disabled = value.strip_edges().is_empty()
var native_button: Button = _rename_anchor_dialog.get_ok_button()
native_button.disabled = value.strip_edges().is_empty()
var visible_button: Button = StageDialogStylerScript.visual_button(native_button)
if visible_button != native_button:
visible_button.disabled = native_button.disabled
func _commit_anchor_rename() -> void:
if _rename_anchor_source_id.is_empty() or not _anchors.has(_rename_anchor_source_id):
@@ -1463,10 +1618,10 @@ func _apply_control_theme(node: Node) -> void:
func _update_physics_labels() -> void:
var mat: PhysicsMaterial = PropBlockScript.physics_material(_material_preset)
_mass_label.text = "Mass: %.1f" % PropBlockScript.mass_for(_material_preset)
_friction_label.text = "Surface grip: %.2f" % mat.friction
_mass_value.text = "%.1f" % PropBlockScript.mass_for(_material_preset)
_friction_value.text = "%.2f" % mat.friction
_friction_label.tooltip_text = "Higher values make sliding harder."
_bounce_label.text = "Bounce: %.2f" % mat.bounce
_bounce_value.text = "%.2f" % mat.bounce
func _on_preset_selected(index: int) -> void:
@@ -1786,14 +1941,16 @@ func _check_autosave_recovery() -> void:
var dialog := ConfirmationDialog.new()
dialog.title = "Restore Prop?"
dialog.dialog_text = "An unsaved prop was recovered. Restore it?"
dialog.ok_button_text = "🔄 Restore"
var discard_button: Button = dialog.add_button("🗑️ Discard", false, "discard")
dialog.theme = _ui_theme
_editor_visual_theme.style_button_role(dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(discard_button, &"destructive", &"prop")
_editor_visual_theme.style_button_role(dialog.get_cancel_button(), &"secondary", &"prop")
dialog.set_meta(StageDialogStylerScript.ICON_META, "🔄")
dialog.dialog_text = "An unsaved prop was recovered. Restore this work?"
dialog.ok_button_text = "Restore"
var discard_button: Button = dialog.add_button("Discard", false, "discard")
dialog.get_cancel_button().text = "Cancel"
add_child(dialog)
StageDialogStylerScript.apply(
dialog, _dialog_theme, _dialog_theme.font(),
dialog.get_ok_button(), [discard_button]
)
dialog.confirmed.connect(func() -> void:
_apply_json_data(raw, true)
_current_path = source_path
@@ -1803,7 +1960,8 @@ func _check_autosave_recovery() -> void:
if action == &"discard":
_clear_autosave()
dialog.queue_free())
dialog.popup_centered()
dialog.canceled.connect(func() -> void: dialog.queue_free())
StageDialogStylerScript.popup(dialog)
# ---------------------------------------------------------------------------
@@ -1879,7 +2037,7 @@ func _begin_save_request(path: String, with_thumbnail: bool) -> void:
_pending_save_path = path
_pending_save_with_thumbnail = with_thumbnail
if should_warn_off_origin(_canvas.has_shape_geometry(), _canvas.get_shapes_bounds()):
_off_origin_save_dialog.popup_centered()
StageDialogStylerScript.popup(_off_origin_save_dialog)
return
await _execute_pending_save(false)
@@ -1944,4 +2102,4 @@ func _on_load_file_selected(path: String) -> void:
func _show_error(message: String) -> void:
_error_dialog.dialog_text = message
_error_dialog.popup_centered()
StageDialogStylerScript.popup(_error_dialog)
@@ -0,0 +1,9 @@
class_name PropInspectorDropdown
extends EditorSelectionDropdown
## Prop-specific icon adapter for the shared editor selection dropdown.
const PropInspectorIconsScript := preload("res://scripts/editors/prop/prop_inspector_icons.gd")
func add_icon_item(texture: Texture2D, label: String) -> void:
add_styled_item(label, texture, PropInspectorIconsScript.popup_item(texture))
@@ -0,0 +1 @@
uid://bnnujoeli7cwk
@@ -0,0 +1,95 @@
class_name PropInspectorIcons
extends RefCounted
## Small flat 2D glyphs for the Prop Editor inspector.
## SVG is rasterized at runtime so colors can follow the editor and material tokens.
const ICON_SIZE := 24
static func material(preset: int, tint: Color) -> Texture2D:
if preset == 0:
return _texture(_svg('<circle cx="24" cy="24" r="15" fill="#252B38" stroke="#C4D1E5" stroke-width="4"/>'))
return _texture(_svg('<circle cx="24" cy="24" r="15" fill="%s" stroke="#FFFFFF" stroke-opacity="0.35" stroke-width="3"/>' % _hex(tint)))
static func shape(shape_id: String, tint: Color = Color("#22C6FF")) -> Texture2D:
var color := _hex(tint)
var content := ""
match shape_id:
"box":
content = '<rect x="8" y="8" width="32" height="32" rx="2" fill="%s"/>' % color
"plank":
content = '<rect x="5" y="16" width="38" height="16" rx="2" fill="%s"/>' % color
"triangle":
content = '<polygon points="24,6 44,41 4,41" fill="%s"/>' % color
"line":
content = '<path d="M8 38 L40 10" fill="none" stroke="%s" stroke-width="6" stroke-linecap="round"/>' % color
"circle":
content = '<circle cx="24" cy="24" r="16" fill="%s"/>' % color
_:
content = '<rect x="8" y="8" width="32" height="32" rx="2" fill="%s"/>' % color
return _texture(_svg(content))
static func section(section_id: String) -> Texture2D:
var content := ""
match section_id:
"material":
content = '<rect x="7" y="9" width="14" height="14" rx="3" fill="#F3B65D"/><rect x="27" y="9" width="14" height="14" rx="3" fill="#E76D66"/><rect x="7" y="27" width="14" height="14" rx="3" fill="#66C681"/><rect x="27" y="27" width="14" height="14" rx="3" fill="#70BFE8"/>'
"selected_shape":
content = '<path d="M9 32 L16 11 L38 15 L35 37 Z" fill="#DDE7F5" stroke="#22C6FF" stroke-width="3" stroke-linejoin="round"/><circle cx="9" cy="32" r="3" fill="#FFFFFF"/><circle cx="16" cy="11" r="3" fill="#FFFFFF"/><circle cx="38" cy="15" r="3" fill="#FFFFFF"/><circle cx="35" cy="37" r="3" fill="#FFFFFF"/>'
"add_shape":
content = '<rect x="7" y="7" width="34" height="34" fill="none" stroke="#22C6FF" stroke-width="5"/><path d="M24 15 V33 M15 24 H33" stroke="#22C6FF" stroke-width="4" stroke-linecap="round"/>'
"tags":
content = '<path d="M7 17 L17 7 H34 L42 15 V32 L25 42 L7 24 Z" fill="#FFB84D"/><circle cx="31" cy="16" r="3.5" fill="#252B38"/>'
return _texture(_svg(content))
static func tag(tag_id: String) -> Texture2D:
var color := "#66C681"
var body := ""
match tag_id:
"nature":
color = "#66C681"
body = '<path d="M9 35 C9 18 21 8 39 8 C39 26 29 39 13 39 Z" fill="%s"/><path d="M12 38 L33 15" stroke="#E8FFF0" stroke-width="3" stroke-linecap="round"/>' % color
"buildings":
color = "#F3A653"
body = '<path d="M10 40 V10 H29 V18 H38 V40 Z" fill="%s"/><path d="M16 16 H21 M16 23 H21 M16 30 H21 M31 24 H35 M31 31 H35" stroke="#3C2A22" stroke-width="3"/>' % color
"walls":
color = "#F36B5B"
body = '<path d="M5 10 H21 V19 H5 Z M24 10 H42 V19 H24 Z M14 22 H31 V31 H14 Z M4 34 H20 V42 H4 Z M23 34 H42 V42 H23 Z" fill="%s"/><path d="M22 10 V19 M13 22 V31 M31 34 V42" stroke="#733A37" stroke-width="2"/>' % color
"roads":
color = "#9BAEC9"
body = '<path d="M16 7 H32 L42 41 H6 Z" fill="%s"/><path d="M24 12 V19 M24 25 V31 M24 36 V40" stroke="#283247" stroke-width="3" stroke-linecap="round"/>' % color
"objects":
color = "#E8A96D"
body = '<rect x="7" y="11" width="34" height="26" fill="none" stroke="%s" stroke-width="5"/>' % color
return _texture(_svg(body))
static func popup_item(texture: Texture2D, size: int = 44) -> Texture2D:
if texture == null:
return null
var source: Image = texture.get_image()
var canvas := Image.create(size, size, false, Image.FORMAT_RGBA8)
var draw_size: Vector2i = Vector2i(mini(source.get_width(), size), mini(source.get_height(), size))
var source_rect := Rect2i(Vector2i.ZERO, draw_size)
var destination := Vector2i((size - draw_size.x) / 2, (size - draw_size.y) / 2)
canvas.blit_rect(source, source_rect, destination)
return ImageTexture.create_from_image(canvas)
static func _svg(content: String) -> String:
return '<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 48 48">%s</svg>' % [ICON_SIZE, ICON_SIZE, content]
static func _texture(svg: String) -> Texture2D:
var image := Image.new()
var error: Error = image.load_svg_from_string(svg)
if error != OK:
push_warning("Prop Inspector icon could not be rasterized (%s)." % error_string(error))
return null
return ImageTexture.create_from_image(image)
static func _hex(color: Color) -> String:
return "#" + color.to_html(false)
@@ -0,0 +1 @@
uid://5ac0640i4bj7