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
+8 -6
View File
@@ -426,17 +426,19 @@ func _build_file_menu(parent: HBoxContainer) -> void:
var file_popup: PopupMenu = _file_menu.get_popup()
var popup_font_size: int = _theme_integer("fonts.sizes.file_menu_popup", 26, 12, 64)
file_popup.add_theme_font_size_override("font_size", popup_font_size)
file_popup.add_item("New", 0)
file_popup.add_item("Open…", 1)
file_popup.add_item("🆕 New", 0)
file_popup.add_item("📂 Open…", 1)
var recent_popup := PopupMenu.new()
recent_popup.name = "OpenRecent"
recent_popup.add_theme_font_size_override("font_size", popup_font_size)
recent_popup.id_pressed.connect(_on_recent_scene_requested)
file_popup.add_child(recent_popup)
file_popup.add_submenu_item("Open Recent", "OpenRecent")
file_popup.add_item("Save", 2)
file_popup.add_item("Save As…", 3)
file_popup.add_item("Rename Scene…", 4)
file_popup.add_submenu_item("🕑 Open Recent", "OpenRecent")
file_popup.add_separator()
file_popup.add_item("💾 Save", 2)
file_popup.add_item("📄 Save As…", 3)
file_popup.add_separator()
file_popup.add_item("✏️ Rename Scene…", 4)
file_popup.id_pressed.connect(_on_file_menu_requested)
file_popup.about_to_popup.connect(_on_recent_menu_requested)
parent.add_child(_file_menu)
+39 -17
View File
@@ -64,7 +64,7 @@ const ZOOM_STEP: float = 1.10
@onready var title_label: Label = %TitleLabel
@onready var drawing_area: Control = %DrawingArea
@onready var context_menu: PopupMenu = %ContextMenu
@onready var context_menu: EditorContextMenu = %ContextMenu
@onready var color_picker_popup: PopupPanel = %ColorPickerPopup
@onready var color_picker: ColorPicker = %ColorPicker
@onready var color_picker_button_row: HBoxContainer = %ButtonRow
@@ -127,6 +127,8 @@ func _ready() -> void:
cancel_button.pressed.connect(_on_color_picker_cancel)
if not context_menu.popup_hide.is_connected(_on_presentation_popup_hidden):
context_menu.popup_hide.connect(_on_presentation_popup_hidden)
if _editor_visual_theme != null:
context_menu.configure(_editor_visual_theme, &"stickman")
if not color_picker_popup.popup_hide.is_connected(_on_presentation_popup_hidden):
color_picker_popup.popup_hide.connect(_on_presentation_popup_hidden)
cancel_button.text = "Cancel"
@@ -298,6 +300,8 @@ func set_canvas_tokens(grid_color: Color, selection_core: Color, selection_halo:
_hint_color = hint_color
_button_target_height = button_target_height
_editor_visual_theme = editor_visual_theme
if context_menu != null and _editor_visual_theme != null:
context_menu.configure(_editor_visual_theme, &"stickman")
if drawing_area != null:
drawing_area.queue_redraw()
@@ -421,6 +425,24 @@ func _screen_to_world(pos: Vector2) -> Vector2:
return (pos - _pan_offset) / _zoom
func _global_point_target_rect(world_pos: Vector2) -> Rect2:
var screen_pos := drawing_area.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(sd: Dictionary) -> Rect2:
var points: PackedVector2Array = sd.get("points", PackedVector2Array())
if points.is_empty():
return Rect2()
var min_point := points[0]
var max_point := points[0]
for point: Vector2 in points:
min_point = min_point.min(point)
max_point = max_point.max(point)
var screen_position := drawing_area.global_position + _pan_offset + min_point * _zoom
return Rect2(screen_position, (max_point - min_point) * _zoom).grow(4.0)
func _snap_to_grid(pos: Vector2) -> Vector2:
var gs := float(_grid_size)
if gs <= 0.0:
@@ -638,9 +660,9 @@ func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
if hit_idx >= 0:
_context_shape_idx = _selected_shape_idx
context_menu.clear()
context_menu.add_item("Remove Point", 6)
context_menu.add_destructive_item("Remove Point", 6)
context_menu.set_meta("vertex_index", hit_idx)
context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
context_menu.popup_at(mb.global_position, _global_point_target_rect(world_pos))
return
for i: int in range(shapes.size() - 1, -1, -1):
@@ -651,9 +673,9 @@ func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
if hit_idx >= 0:
_context_shape_idx = i
context_menu.clear()
context_menu.add_item("Remove Point", 6)
context_menu.add_destructive_item("Remove Point", 6)
context_menu.set_meta("vertex_index", hit_idx)
context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
context_menu.popup_at(mb.global_position, _global_point_target_rect(world_pos))
return
# Step 2: Check if right-click is over any shape (reverse order = front first).
@@ -677,22 +699,22 @@ func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
context_menu.add_item("Create Point", 3)
context_menu.add_separator()
context_menu.add_item("Color...", 4)
context_menu.add_item("Change Color…", 4)
context_menu.add_separator()
context_menu.add_item("Send Back", 7)
context_menu.add_item("Bring Forward", 8)
context_menu.add_separator()
context_menu.add_item("Copy", 9)
context_menu.add_item("Copy Shape", 9)
if _has_clipboard:
context_menu.add_item("Paste", 10)
context_menu.add_item("Paste Shape", 10)
context_menu.add_separator()
context_menu.add_item("Mirror X", 11)
context_menu.add_item("Mirror Y", 12)
context_menu.add_item("Flip Left-Right", 11)
context_menu.add_item("Flip Up-Down", 12)
context_menu.add_separator()
context_menu.add_item("Delete", 5)
context_menu.add_item("Bring Forward", 8)
context_menu.add_item("Send Back", 7)
context_menu.add_separator()
context_menu.add_destructive_item("Erase Shape", 5)
context_menu.set_meta("click_position", world_pos)
context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
context_menu.popup_at(mb.global_position, _global_shape_target_rect(found_sd))
return
# Step 3: No shape under mouse -> shape creation menu.
@@ -702,9 +724,9 @@ func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
context_menu.add_item("Circle", 2)
if _has_clipboard:
context_menu.add_separator()
context_menu.add_item("Paste", 10)
context_menu.add_item("Paste Shape", 10)
context_menu.set_meta("click_position", world_pos)
context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
context_menu.popup_at(mb.global_position)
func _handle_left_press(world_pos: Vector2) -> void:
+60 -33
View File
@@ -17,6 +17,8 @@ extends Control
const ContentPathsScript := preload("res://scripts/shared/content_paths.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")
enum WorkspaceView { OVERVIEW, FOCUS }
enum PendingDocumentAction { NONE, LOAD, CLEAR, QUIT }
@@ -110,11 +112,10 @@ const WORKSPACE_GAP: float = 8.0
@onready var _overview_scroll: ScrollContainer = %OverviewScroll
@onready var _focus_host: VBoxContainer = %FocusHost
@onready var _focus_panel_host: Control = %FocusPanelHost
@onready var _focus_part_label: Label = %FocusPartLabel
@onready var _workspace_row: HSplitContainer = %WorkspaceRow
@onready var _overview_button: Button = %OverviewButton
@onready var _focus_button: Button = %FocusButton
@onready var _part_picker: OptionButton = %PartPicker
@onready var _part_picker: EditorSelectionDropdown = %PartPicker
@onready var _add_shape_button: Button = %AddShapeButton
@onready var _color_button: Button = %ColorButton
@onready var _whole_stickman_button: Button = %WholeStickmanButton
@@ -132,6 +133,7 @@ const WORKSPACE_GAP: float = 8.0
@onready var _unsaved_dialog: ConfirmationDialog = %UnsavedChangesDialog
var _editor_visual_theme: EditorVisualTheme
var _dialog_theme: SandboxTheme
var _body_part_origins: Dictionary = {}
var _workspace_view: WorkspaceView = WorkspaceView.OVERVIEW
var _view_choice_was_manual: bool = false
@@ -173,6 +175,8 @@ var _view_menu: PopupMenu
func _ready() -> void:
_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 = _editor_visual_theme.control_theme(&"stickman")
_setup_menu_bar()
_setup_dialogs()
@@ -188,6 +192,7 @@ func _ready() -> void:
_editor_visual_theme.stickman_canvas_color(&"selection_halo"),
_editor_visual_theme.chrome_color(&"preview", &"stickman")
)
_whole_preview.set_context_menu_visual_theme(_editor_visual_theme, &"stickman")
_stickman_name_edit.text_changed.connect(_on_stickman_name_changed)
_load_settings()
@@ -233,17 +238,18 @@ func _process(_delta: float) -> void:
func _setup_menu_bar() -> void:
var file_menu: PopupMenu = PopupMenu.new()
file_menu.name = "FileMenu"
file_menu.add_item("Save", 0)
file_menu.add_item("Load", 1)
file_menu.add_item("📂 Load…", 1)
file_menu.add_separator()
file_menu.add_item("Start Over", 2)
file_menu.add_item("💾 Save", 0)
file_menu.add_separator()
file_menu.add_item("🆕 Start Over", 2)
file_menu.id_pressed.connect(_on_file_menu_id_pressed)
_menu_bar.add_child(file_menu)
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "File")
_edit_menu = PopupMenu.new()
_edit_menu.name = "EditMenu"
_edit_menu.add_item("Configure Grid...", 0)
_edit_menu.add_item("⚙️ Configure Grid…", 0)
_edit_menu.add_separator()
_edit_menu.add_item(_snap_menu_label(), 1)
_edit_menu.id_pressed.connect(_on_edit_menu_id_pressed)
@@ -253,7 +259,7 @@ func _setup_menu_bar() -> void:
var view_menu: PopupMenu = PopupMenu.new()
view_menu.name = "ViewMenu"
view_menu.add_item("Reset Views", 0)
view_menu.add_item("🔍 Reset Views", 0)
view_menu.add_separator()
view_menu.add_item(_guide_menu_label(), 1)
view_menu.id_pressed.connect(_on_view_menu_id_pressed)
@@ -266,10 +272,6 @@ func _setup_menu_bar() -> void:
func _setup_dialogs() -> void:
_save_dialog.theme = theme
_load_dialog.theme = theme
_clear_confirm.theme = theme
_unsaved_dialog.theme = theme
_error_dialog.theme = theme
_grid_config_dialog.theme = theme
var content_dir: String = ProjectSettings.globalize_path(ContentPathsScript.STICKMEN_DIR)
_save_dialog.current_dir = content_dir
_load_dialog.current_dir = content_dir
@@ -280,17 +282,34 @@ func _setup_dialogs() -> void:
_editor_visual_theme.style_button_role(_load_dialog.get_ok_button(), &"primary", &"stickman")
_editor_visual_theme.style_button_role(_load_dialog.get_cancel_button(), &"secondary", &"stickman")
_clear_confirm.confirmed.connect(_on_clear_confirmed)
_clear_confirm.title = "Start Over?"
_clear_confirm.dialog_text = "This will clear your current stickman. Start over?"
_clear_confirm.ok_button_text = "Start Over"
_clear_confirm.set_meta(StageDialogStylerScript.ICON_META, "🧹")
_clear_confirm.get_cancel_button().text = "Cancel"
_editor_visual_theme.style_button_role(_clear_confirm.get_ok_button(), &"destructive", &"stickman")
_editor_visual_theme.style_button_role(_clear_confirm.get_cancel_button(), &"secondary", &"stickman")
var discard_button: Button = _unsaved_dialog.add_button("Discard Changes", false, "discard")
StageDialogStylerScript.apply(
_clear_confirm, _dialog_theme, _dialog_theme.font(),
null, [_clear_confirm.get_ok_button()], [], false
)
_unsaved_dialog.title = "Unsaved Changes"
_unsaved_dialog.set_meta(StageDialogStylerScript.ICON_META, "💾")
var discard_button: Button = _unsaved_dialog.add_button("Discard", false, "discard")
_unsaved_dialog.get_cancel_button().text = "Cancel"
_editor_visual_theme.style_button_role(_unsaved_dialog.get_ok_button(), &"primary", &"stickman")
_editor_visual_theme.style_button_role(discard_button, &"destructive", &"stickman")
_editor_visual_theme.style_button_role(_unsaved_dialog.get_cancel_button(), &"secondary", &"stickman")
_editor_visual_theme.style_button_role(_error_dialog.get_ok_button(), &"secondary", &"stickman")
_editor_visual_theme.style_button_role(_grid_config_dialog.get_ok_button(), &"primary", &"stickman")
_editor_visual_theme.style_button_role(_grid_config_dialog.get_cancel_button(), &"secondary", &"stickman")
_unsaved_dialog.ok_button_text = "Save"
StageDialogStylerScript.apply(
_unsaved_dialog, _dialog_theme, _dialog_theme.font(),
_unsaved_dialog.get_ok_button(), [discard_button]
)
_error_dialog.get_ok_button().text = "Got it"
StageDialogStylerScript.apply(
_error_dialog, _dialog_theme, _dialog_theme.font(),
_error_dialog.get_ok_button()
)
_grid_config_dialog.set_meta(StageDialogStylerScript.ICON_META, "📏")
StageDialogStylerScript.apply(
_grid_config_dialog, _dialog_theme, _dialog_theme.font(),
_grid_config_dialog.get_ok_button()
)
_unsaved_dialog.confirmed.connect(_on_unsaved_save_pressed)
_unsaved_dialog.custom_action.connect(_on_unsaved_custom_action)
_unsaved_dialog.canceled.connect(_cancel_pending_document_action)
@@ -303,14 +322,24 @@ func _setup_workspace_controls() -> void:
_focus_button.button_group = _view_button_group
_overview_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
_focus_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"mode_button_min_height", &"stickman"))
_part_picker.custom_minimum_size = Vector2(160.0, float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman")))
_part_picker.custom_minimum_size = Vector2(220.0, float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman")))
_part_picker.configure(
theme,
_editor_visual_theme.chrome_color(&"surface", &"stickman"),
_editor_visual_theme.chrome_color(&"selected_surface", &"stickman"),
_editor_visual_theme.chrome_color(&"border", &"stickman"),
_editor_visual_theme.chrome_color(&"edit", &"stickman"),
_editor_visual_theme.chrome_color(&"focus", &"stickman"),
_editor_visual_theme.chrome_color(&"text", &"stickman"),
_editor_visual_theme.chrome_px(&"menu_popup", &"stickman"),
12
)
_add_shape_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
_editor_visual_theme.style_button_role(_add_shape_button, &"primary", &"stickman")
_color_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
_editor_visual_theme.style_button_role(_color_button, &"secondary", &"stickman")
_whole_stickman_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
_back_to_part_button.custom_minimum_size.y = float(_editor_visual_theme.chrome_px(&"button_min_height", &"stickman"))
_focus_part_label.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"section", &"stickman"))
_status_cursor_coords.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"status", &"stickman"))
_status_save_status.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"status", &"stickman"))
_status_snap_status.add_theme_font_size_override("font_size", _editor_visual_theme.chrome_px(&"status", &"stickman"))
@@ -324,9 +353,9 @@ func _setup_workspace_controls() -> void:
_whole_stickman_button.pressed.connect(_on_whole_stickman_button_pressed)
_back_to_part_button.pressed.connect(_on_back_to_part_button_pressed)
_add_shape_menu = PopupMenu.new()
_add_shape_menu.add_item("Line", 0)
_add_shape_menu.add_item("Rectangle", 1)
_add_shape_menu.add_item("Circle", 2)
_add_shape_menu.add_item("〰️ Line", 0)
_add_shape_menu.add_item("▭ Rectangle", 1)
_add_shape_menu.add_item("⚪ Circle", 2)
_add_shape_menu.id_pressed.connect(_on_add_shape_menu_id_pressed)
_add_shape_menu.theme = theme
add_child(_add_shape_menu)
@@ -526,8 +555,6 @@ func _update_active_part_presentation() -> void:
var panel: BodyPartPanel = _body_part_panels.get(part_name) as BodyPartPanel
if panel != null:
panel.set_active_presentation(part_name == _active_part_name, surface, border, accent, section_size)
if is_instance_valid(_focus_part_label):
_focus_part_label.text = BODY_PART_DISPLAY.get(_active_part_name, _active_part_name)
func _refresh_workspace_controls() -> void:
@@ -686,7 +713,7 @@ func _request_document_action(action: PendingDocumentAction) -> void:
_unsaved_dialog.dialog_text = "Save changes before starting over with a blank stickman?"
PendingDocumentAction.QUIT:
_unsaved_dialog.dialog_text = "Save changes before closing Stickman Studio?"
_unsaved_dialog.popup_centered()
StageDialogStylerScript.popup(_unsaved_dialog)
return
_continue_document_action(action)
@@ -696,7 +723,7 @@ func _continue_document_action(action: PendingDocumentAction) -> void:
PendingDocumentAction.LOAD:
_load_dialog.popup_centered_ratio(0.6)
PendingDocumentAction.CLEAR:
_clear_confirm.popup_centered()
StageDialogStylerScript.popup(_clear_confirm)
PendingDocumentAction.QUIT:
get_tree().quit()
_pending_document_action = PendingDocumentAction.NONE
@@ -763,7 +790,7 @@ func _on_edit_menu_id_pressed(id: int) -> void:
match id:
0: # Configure Grid...
_grid_size_spin_box.value = float(_grid_size)
_grid_config_dialog.popup_centered()
StageDialogStylerScript.popup(_grid_config_dialog)
1: # Snap to Grid (checkable)
_snap_enabled = not _snap_enabled
if _edit_menu:
@@ -808,7 +835,7 @@ func _update_guide_menu_item() -> void:
func _guide_menu_label() -> String:
return "Hide Pose Guide" if _show_guide else "Show Pose Guide"
return "👁️ Hide Pose Guide" if _show_guide else "👁️ Show Pose Guide"
func _on_grid_config_confirmed() -> void:
@@ -862,7 +889,7 @@ func _on_clear_confirmed() -> void:
func _show_error(message: String) -> void:
_error_dialog.dialog_text = message
_error_dialog.popup_centered()
StageDialogStylerScript.popup(_error_dialog)
# ---------------------------------------------------------------------------
# Signal handlers - body-part panels
@@ -1198,7 +1225,7 @@ func _broadcast_settings() -> void:
func _snap_menu_label() -> String:
return "[√] Snap to Grid" if _snap_enabled else "Snap to Grid"
return "🧲 [√] Snap to Grid" if _snap_enabled else "🧲 Snap to Grid"
func _update_snap_status_label() -> void:
@@ -26,6 +26,7 @@ signal part_order_changed(part_name: String, new_order: Array[String])
# ---------------------------------------------------------------------------
const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.15)
const EditorContextMenuScript := preload("res://scripts/shared/editor_ui/editor_context_menu.gd")
const MIN_ZOOM: float = 0.3
const MAX_ZOOM: float = 3.0
const ZOOM_STEP: float = 1.10
@@ -142,7 +143,7 @@ var _pan_start: Vector2 = Vector2.ZERO
# Phase 5: z-ordering
var _part_order: Array[String] = []
var _context_menu: PopupMenu
var _context_menu: EditorContextMenu
# Phase 6: selection gizmos rendered on top
var _selected_gizmo_bounds: Rect2 = Rect2()
@@ -289,6 +290,11 @@ func set_canvas_tokens(grid_color: Color, selection_core: Color, selection_halo:
preview_area.queue_redraw()
func set_context_menu_visual_theme(visual_theme: EditorVisualTheme, editor: StringName) -> void:
if _context_menu != null:
_context_menu.configure(visual_theme, editor)
## Returns the preview-space position of a guide joint (a master-space
## GUIDE_JOINTS entry mapped through _guide_to_preview). Guarded against
## unknown joint names.
@@ -334,7 +340,7 @@ func _ready() -> void:
if not preview_area.gui_input.is_connected(_on_preview_gui_input):
preview_area.gui_input.connect(_on_preview_gui_input)
_context_menu = PopupMenu.new()
_context_menu = EditorContextMenuScript.new() as EditorContextMenu
_context_menu.name = "PreviewContextMenu"
add_child(_context_menu)
_context_menu.id_pressed.connect(_on_context_menu_id_pressed)
@@ -1024,12 +1030,14 @@ func _handle_right_click(mb: InputEventMouseButton) -> void:
if rect.has_point(world_pos):
_selected_part = part_name
_context_menu.clear()
_context_menu.add_item("Send Back", 0)
_context_menu.add_item("Bring Forward", 1)
_context_menu.add_item("Send Back", 0)
_context_menu.add_separator()
_context_menu.add_item("Mirror X", 2)
_context_menu.add_item("Mirror Y", 3)
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
_context_menu.add_item("Flip Left-Right", 2)
_context_menu.add_item("Flip Up-Down", 3)
var screen_position := preview_area.global_position + _pan_offset + rect.position * _zoom
var target_rect := Rect2(screen_position, rect.size * _zoom).grow(4.0)
_context_menu.popup_at(mb.global_position, target_rect)
preview_area.queue_redraw()
return
@@ -0,0 +1,385 @@
class_name EditorContextMenu
extends PopupPanel
## Shared Studio List popup used by right-click menus in both standalone editors.
signal id_pressed(id: int)
enum ItemRole { NORMAL, DESTRUCTIVE }
const DEFAULT_EDITOR := &"prop"
const DEFAULT_MIN_WIDTH := 208
const DEFAULT_ROW_HEIGHT := 48
const DEFAULT_GROUP_GAP := 8
const SHELL_INSET := 8
const ROW_RADIUS := 6
const TARGET_GAP := 12.0
var _visual_theme: EditorVisualTheme
var _editor: StringName = DEFAULT_EDITOR
var _font_size_override: int = -1
var _shell: MarginContainer
var _scroll: ScrollContainer
var _items: VBoxContainer
var _row_buttons: Array[Button] = []
var _row_ids: Dictionary = {}
var _keyboard_navigation_active: bool = false
var _focused_row_index: int = -1
func _ready() -> void:
_build_contents()
if not about_to_popup.is_connected(_reset_keyboard_navigation):
about_to_popup.connect(_reset_keyboard_navigation)
if not popup_hide.is_connected(_reset_keyboard_navigation):
popup_hide.connect(_reset_keyboard_navigation)
if _visual_theme != null:
_apply_visual_style()
func configure(visual_theme: EditorVisualTheme, editor: StringName) -> void:
_visual_theme = visual_theme
_editor = editor
if is_node_ready():
_apply_visual_style()
func set_font_size(size: int) -> void:
_font_size_override = maxi(1, size)
if is_node_ready():
_apply_visual_style()
func clear() -> void:
_reset_keyboard_navigation()
_row_buttons.clear()
_row_ids.clear()
if _items == null:
return
for child: Node in _items.get_children():
_items.remove_child(child)
child.free()
func add_item(label: String, id: int, role: ItemRole = ItemRole.NORMAL) -> void:
if _items == null:
_build_contents()
var button := Button.new()
button.text = label
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.custom_minimum_size = Vector2(_menu_min_width(), _menu_row_height())
button.focus_mode = Control.FOCUS_NONE
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
button.add_theme_font_size_override("font_size", _font_size())
button.add_theme_color_override("font_color", _text_color(role))
button.add_theme_color_override("font_hover_color", _text_color(role))
button.add_theme_color_override("font_pressed_color", _text_color(role))
button.add_theme_color_override("font_focus_color", _text_color(role))
button.add_theme_stylebox_override("normal", _make_row_box(Color(0.0, 0.0, 0.0, 0.0), Color(0, 0, 0, 0), 0))
button.add_theme_stylebox_override("hover", _make_hover_box())
button.add_theme_stylebox_override("pressed", _make_hover_box(true))
button.add_theme_stylebox_override("focus", _make_focus_box())
button.add_theme_stylebox_override("disabled", _make_row_box(Color(0.0, 0.0, 0.0, 0.0), Color(0, 0, 0, 0), 0))
button.set_meta("context_menu_id", id)
button.set_meta("context_menu_role", role)
button.pressed.connect(_on_row_pressed.bind(button))
button.mouse_entered.connect(_on_row_mouse_entered.bind(button))
_items.add_child(button)
_row_buttons.append(button)
_row_ids[id] = button
func add_destructive_item(label: String, id: int) -> void:
add_item(label, id, ItemRole.DESTRUCTIVE)
func add_separator() -> void:
if _items == null:
_build_contents()
var spacer := MarginContainer.new()
spacer.custom_minimum_size.y = float(DEFAULT_GROUP_GAP + 1)
spacer.add_theme_constant_override("margin_top", int(DEFAULT_GROUP_GAP / 2))
spacer.add_theme_constant_override("margin_bottom", int(DEFAULT_GROUP_GAP / 2))
var line := ColorRect.new()
line.color = _theme_color(&"border", Color(1, 1, 1, 0.2))
line.custom_minimum_size.y = 1.0
line.size_flags_horizontal = Control.SIZE_EXPAND_FILL
spacer.add_child(line)
_items.add_child(spacer)
func popup_at(global_position: Vector2, avoid_rect: Rect2 = Rect2()) -> void:
if _items == null:
_build_contents()
var parent_viewport: Viewport = get_parent().get_viewport() if get_parent() != null else get_viewport()
var safe_rect: Rect2 = parent_viewport.get_visible_rect()
var margin := 8.0
var popup_size := _items.get_combined_minimum_size() + Vector2(SHELL_INSET * 2, SHELL_INSET * 2)
var border_width := float(_theme_px(&"normal_border", 1))
popup_size += Vector2(border_width * 2.0, border_width * 2.0)
popup_size.x = maxf(popup_size.x, float(_menu_min_width() + SHELL_INSET * 2))
var max_popup_size := Vector2(maxf(1.0, safe_rect.size.x - margin * 2.0), maxf(1.0, safe_rect.size.y - margin * 2.0))
popup_size = popup_size.min(max_popup_size)
if _scroll != null:
var content_height: float = _items.get_combined_minimum_size().y
var available_content_height: float = max_popup_size.y - float(SHELL_INSET * 2) - border_width * 2.0
_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO if content_height > available_content_height else ScrollContainer.SCROLL_MODE_DISABLED
size = Vector2i(popup_size.ceil())
if _scroll != null:
_scroll.scroll_vertical = 0
var max_x := maxf(safe_rect.position.x + margin, safe_rect.end.x - popup_size.x - margin)
var max_y := maxf(safe_rect.position.y + margin, safe_rect.end.y - popup_size.y - margin)
var popup_origin := _choose_popup_origin(
global_position,
avoid_rect,
popup_size,
safe_rect,
margin,
max_x,
max_y
)
var popup_position := Vector2i(popup_origin.round())
popup_on_parent(Rect2i(popup_position, Vector2i.ONE))
# PopupPanel sizes itself from the child layout on popup. Reapply the measured
# target size after opening so the scroll region can honor the 48 px rows.
size = Vector2i(popup_size.ceil())
var actual_size := Vector2(size)
if popup_position.x + actual_size.x > safe_rect.end.x - margin:
popup_origin.x = global_position.x - actual_size.x - margin
if popup_position.y + actual_size.y > safe_rect.end.y - margin:
popup_origin.y = global_position.y - actual_size.y - margin
var actual_max_x := maxf(safe_rect.position.x + margin, safe_rect.end.x - actual_size.x - margin)
var actual_max_y := maxf(safe_rect.position.y + margin, safe_rect.end.y - actual_size.y - margin)
popup_origin.x = clampf(popup_origin.x, safe_rect.position.x + margin, actual_max_x)
popup_origin.y = clampf(popup_origin.y, safe_rect.position.y + margin, actual_max_y)
position = Vector2i(popup_origin.round())
func _choose_popup_origin(
global_position: Vector2,
avoid_rect: Rect2,
popup_size: Vector2,
safe_rect: Rect2,
margin: float,
max_x: float,
max_y: float
) -> Vector2:
var low_x: float = safe_rect.position.x + margin
var low_y: float = safe_rect.position.y + margin
var cursor_fallback := Vector2(
clampf(global_position.x + margin, low_x, max_x),
clampf(global_position.y + margin, low_y, max_y)
)
if not avoid_rect.has_area():
return cursor_fallback
var aligned_y := clampf(global_position.y - 20.0, low_y, max_y)
var aligned_x := clampf(global_position.x + margin, low_x, max_x)
var candidates: Array[Vector2] = [
Vector2(avoid_rect.end.x + TARGET_GAP, aligned_y),
Vector2(avoid_rect.position.x - popup_size.x - TARGET_GAP, aligned_y),
Vector2(aligned_x, avoid_rect.end.y + TARGET_GAP),
Vector2(aligned_x, avoid_rect.position.y - popup_size.y - TARGET_GAP),
cursor_fallback,
]
for candidate: Vector2 in candidates:
if candidate.x < low_x or candidate.y < low_y or candidate.x > max_x or candidate.y > max_y:
continue
var candidate_rect := Rect2(candidate, popup_size)
if not candidate_rect.intersects(avoid_rect):
return candidate
return cursor_fallback
func get_row_button(id: int) -> Button:
return _row_ids.get(id) as Button
func _build_contents() -> void:
if _items != null:
return
_shell = MarginContainer.new()
_shell.name = "MenuShell"
_shell.add_theme_constant_override("margin_left", SHELL_INSET)
_shell.add_theme_constant_override("margin_top", SHELL_INSET)
_shell.add_theme_constant_override("margin_right", SHELL_INSET)
_shell.add_theme_constant_override("margin_bottom", SHELL_INSET)
_shell.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_shell.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(_shell)
_scroll = ScrollContainer.new()
_scroll.name = "MenuScroll"
_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_shell.add_child(_scroll)
_items = VBoxContainer.new()
_items.name = "MenuItems"
_items.add_theme_constant_override("separation", 0)
_items.custom_minimum_size.x = float(_menu_min_width())
_scroll.add_child(_items)
_apply_visual_style()
func _apply_visual_style() -> void:
if _visual_theme == null:
return
var surface := _theme_color(&"surface", Color("#252B38"))
var border := _theme_color(&"border", Color("#FFFFFF33"))
var radius := _theme_px(&"panel", 10)
var panel := StyleBoxFlat.new()
panel.bg_color = surface
panel.border_color = border
panel.set_border_width_all(_theme_px(&"normal_border", 1))
panel.set_corner_radius_all(radius)
panel.shadow_color = Color(0.0, 0.0, 0.0, 0.28)
panel.shadow_size = 8
panel.shadow_offset = Vector2(0.0, 3.0)
add_theme_stylebox_override("panel", panel)
if _shell != null:
_shell.add_theme_constant_override("margin_left", SHELL_INSET)
_shell.add_theme_constant_override("margin_top", SHELL_INSET)
_shell.add_theme_constant_override("margin_right", SHELL_INSET)
_shell.add_theme_constant_override("margin_bottom", SHELL_INSET)
if _items != null:
_items.custom_minimum_size.x = float(_menu_min_width())
for button: Button in _row_buttons:
if not is_instance_valid(button):
continue
button.custom_minimum_size = Vector2(_menu_min_width(), _menu_row_height())
button.add_theme_font_size_override("font_size", _font_size())
var role := int(button.get_meta("context_menu_role", ItemRole.NORMAL))
var item_role: ItemRole = ItemRole.DESTRUCTIVE if role == ItemRole.DESTRUCTIVE else ItemRole.NORMAL
button.add_theme_color_override("font_color", _text_color(item_role))
button.add_theme_color_override("font_hover_color", _text_color(item_role))
button.add_theme_color_override("font_pressed_color", _text_color(item_role))
button.add_theme_color_override("font_focus_color", _text_color(item_role))
button.add_theme_stylebox_override("normal", _make_row_box(Color(0.0, 0.0, 0.0, 0.0), Color(0, 0, 0, 0), 0))
button.add_theme_stylebox_override("hover", _make_hover_box())
button.add_theme_stylebox_override("pressed", _make_hover_box(true))
button.add_theme_stylebox_override("focus", _make_focus_box())
func _make_row_box(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(ROW_RADIUS)
style.content_margin_left = 12
style.content_margin_right = 12
style.content_margin_top = 4
style.content_margin_bottom = 4
return style
func _make_hover_box(pressed: bool = false) -> StyleBoxFlat:
var hover_color := _theme_color(&"surface_hover", Color("#30394A"))
if pressed:
hover_color = hover_color.darkened(0.1)
var style := _make_row_box(hover_color, _theme_color(&"edit", Color("#22C6FF")), 0)
style.border_width_left = 3
style.content_margin_left = 9
return style
func _make_focus_box() -> StyleBoxFlat:
var style := _make_row_box(Color(0.0, 0.0, 0.0, 0.0), _theme_color(&"focus", Color("#8BD9FF")), 2)
return style
func _on_row_pressed(button: Button) -> void:
if not is_instance_valid(button):
return
var id: int = int(button.get_meta("context_menu_id", -1))
id_pressed.emit(id)
hide()
func _input(event: InputEvent) -> void:
if not visible or not event is InputEventKey:
return
var key_event := event as InputEventKey
if not key_event.pressed:
return
if key_event.keycode == KEY_DOWN:
_move_keyboard_focus(1)
get_viewport().set_input_as_handled()
elif key_event.keycode == KEY_UP:
_move_keyboard_focus(-1)
get_viewport().set_input_as_handled()
func _move_keyboard_focus(direction: int) -> void:
if _row_buttons.is_empty():
return
var candidate_index: int
if not _keyboard_navigation_active:
candidate_index = 0 if direction > 0 else _row_buttons.size() - 1
else:
candidate_index = _focused_row_index + direction
for _attempt: int in range(_row_buttons.size()):
candidate_index = posmod(candidate_index, _row_buttons.size())
var button: Button = _row_buttons[candidate_index]
if is_instance_valid(button) and button.visible and not button.disabled:
_enable_keyboard_navigation()
_focused_row_index = candidate_index
button.grab_focus()
return
candidate_index += direction
func _enable_keyboard_navigation() -> void:
_keyboard_navigation_active = true
for button: Button in _row_buttons:
if is_instance_valid(button):
button.focus_mode = Control.FOCUS_ALL
func _reset_keyboard_navigation() -> void:
_keyboard_navigation_active = false
_focused_row_index = -1
if not is_node_ready():
return
var focus_owner: Control = get_viewport().gui_get_focus_owner()
if focus_owner != null and _row_buttons.has(focus_owner):
focus_owner.release_focus()
for button: Button in _row_buttons:
if is_instance_valid(button):
button.focus_mode = Control.FOCUS_NONE
func _on_row_mouse_entered(_button: Button) -> void:
_reset_keyboard_navigation()
func _theme_color(role: StringName, fallback: Color) -> Color:
if _visual_theme == null:
return fallback
return _visual_theme.chrome_color(role, _editor)
func _theme_px(role: StringName, fallback: int) -> int:
if _visual_theme == null:
return fallback
return _visual_theme.chrome_px(role, _editor)
func _font_size() -> int:
if _font_size_override > 0:
return _font_size_override
return _theme_px(&"context_menu", 20)
func _menu_row_height() -> int:
return maxi(_theme_px(&"context_menu_row_height", DEFAULT_ROW_HEIGHT), DEFAULT_ROW_HEIGHT)
func _menu_min_width() -> int:
return maxi(_theme_px(&"context_menu_min_width", DEFAULT_MIN_WIDTH), DEFAULT_MIN_WIDTH)
func _text_color(role: ItemRole) -> Color:
if role == ItemRole.DESTRUCTIVE:
return _theme_color(&"destructive", Color("#E66B75"))
return _theme_color(&"text", Color("#F5F7FA"))
@@ -0,0 +1 @@
uid://bjidsdsxomilt
@@ -0,0 +1,231 @@
class_name EditorSelectionDropdown
extends MenuButton
## Single-choice editor picker with a Studio-style selected row and no radio lane.
signal item_selected(index: int)
var selected: int = -1:
set(value):
_set_selected(value)
get:
return _selected_index
var _selected_index: int = -1
var _popup_menu: PopupMenu
var _field_icons: Array[Texture2D] = []
var _control_theme: Theme
var _surface_color: Color = Color("#252B38")
var _selected_surface_color: Color = Color("#103B4D")
var _border_color: Color = Color("#FFFFFF33")
var _accent_color: Color = Color("#22C6FF")
var _focus_color: Color = Color("#8BD9FF")
var _text_color: Color = Color("#F5F7FA")
var _font_size: int = 18
var _row_separation: int = 6
func _ready() -> void:
flat = false
focus_mode = Control.FOCUS_ALL
alignment = HORIZONTAL_ALIGNMENT_LEFT
_popup_menu = get_popup()
_popup_menu.shrink_width = false
_popup_menu.transparent = false
_popup_menu.transparent_bg = false
_popup_menu.id_pressed.connect(_on_popup_item_pressed)
about_to_popup.connect(_on_about_to_popup)
_apply_popup_theme()
_apply_field_theme()
_sync_selected_item()
queue_redraw()
func configure(
control_theme: Theme,
surface_color: Color,
selected_surface_color: Color,
border_color: Color,
accent_color: Color,
focus_color: Color,
text_color: Color,
font_size: int,
row_separation: int = 6
) -> void:
_control_theme = control_theme
_surface_color = surface_color
_selected_surface_color = selected_surface_color
_border_color = border_color
_accent_color = accent_color
_focus_color = focus_color
_text_color = text_color
_font_size = font_size
_row_separation = maxi(0, row_separation)
if is_node_ready():
_apply_popup_theme()
func add_item(label: String, id: int = -1) -> void:
var index: int = get_popup().item_count
var item_id: int = index if id < 0 else id
add_styled_item(label, null, null, item_id)
func add_styled_item(
label: String,
field_icon: Texture2D = null,
popup_icon: Texture2D = null,
id: int = -1
) -> void:
var popup: PopupMenu = get_popup()
var index: int = popup.item_count
var item_id: int = index if id < 0 else id
_field_icons.append(field_icon)
if popup_icon != null:
popup.add_icon_item(popup_icon, label, item_id)
popup.set_item_icon_max_width(index, 44)
else:
popup.add_item(label, item_id)
popup.set_item_as_radio_checkable(index, false)
if _selected_index < 0:
_set_selected(index)
func get_item_text(index: int) -> String:
return get_popup().get_item_text(index)
func get_item_icon(index: int) -> Texture2D:
return _field_icons[index] if index >= 0 and index < _field_icons.size() else null
func select(index: int) -> void:
_set_selected(index)
func clear() -> void:
get_popup().clear()
_field_icons.clear()
_selected_index = -1
_sync_selected_item()
func hide_popup() -> void:
get_popup().hide()
func _set_selected(index: int) -> void:
var count: int = get_popup().item_count
if index < 0 or count == 0:
_selected_index = -1
else:
_selected_index = clampi(index, 0, count - 1)
_sync_selected_item()
func _sync_selected_item() -> void:
if not is_instance_valid(get_popup()):
return
if _selected_index < 0 or _selected_index >= get_popup().item_count:
text = ""
icon = null
return
text = get_popup().get_item_text(_selected_index)
icon = _field_icons[_selected_index] if _selected_index < _field_icons.size() else null
func _apply_popup_theme() -> void:
if _popup_menu == null:
return
if _control_theme != null:
_popup_menu.theme = _control_theme
var panel := StyleBoxFlat.new()
var opaque_surface: Color = _surface_color
opaque_surface.a = 1.0
panel.bg_color = opaque_surface
panel.border_color = _border_color
panel.set_border_width_all(1)
panel.set_corner_radius_all(8)
panel.content_margin_left = 6.0
panel.content_margin_top = 6.0
panel.content_margin_right = 6.0
panel.content_margin_bottom = 6.0
_popup_menu.add_theme_stylebox_override("panel", panel)
var selected_row := StyleBoxFlat.new()
selected_row.bg_color = _selected_surface_color
selected_row.border_color = _accent_color
selected_row.set_border_width_all(2)
selected_row.set_corner_radius_all(7)
selected_row.content_margin_left = 5.0
selected_row.content_margin_top = 12.0
selected_row.content_margin_right = 5.0
selected_row.content_margin_bottom = 12.0
_popup_menu.add_theme_stylebox_override("hover", selected_row)
_popup_menu.add_theme_color_override("font_hover_color", _text_color)
_popup_menu.add_theme_color_override("font_color", _text_color)
_popup_menu.add_theme_font_size_override("font_size", _font_size)
_popup_menu.add_theme_constant_override("icon_max_width", 44)
_popup_menu.add_theme_constant_override("h_separation", 10)
_popup_menu.add_theme_constant_override("v_separation", _row_separation)
_popup_menu.add_theme_constant_override("item_start_padding", 4)
_popup_menu.add_theme_constant_override("item_end_padding", 4)
add_theme_stylebox_override("focus", _make_focus_style())
queue_redraw()
func _apply_field_theme() -> void:
for state: String in ["normal", "hover", "pressed", "disabled"]:
var original: StyleBox = get_theme_stylebox(state, "Button")
if original == null:
continue
var field_style: StyleBox = original.duplicate()
field_style.content_margin_right = maxf(field_style.content_margin_right, 28.0)
add_theme_stylebox_override(state, field_style)
func _make_focus_style() -> StyleBoxFlat:
var focus_box := StyleBoxFlat.new()
focus_box.bg_color = Color(0.0, 0.0, 0.0, 0.0)
focus_box.border_color = _focus_color
focus_box.set_border_width_all(2)
focus_box.set_corner_radius_all(8)
focus_box.content_margin_right = 28.0
return focus_box
func _draw() -> void:
var center := Vector2(size.x - 16.0, size.y * 0.5)
var points := PackedVector2Array([
center + Vector2(-4.0, -2.0),
center + Vector2(0.0, 2.0),
center + Vector2(4.0, -2.0),
])
draw_polyline(points, _text_color.darkened(0.28), 2.0, true)
func _on_about_to_popup() -> void:
var target_width: int = maxi(roundi(size.x), _popup_menu.min_size.x)
_popup_menu.min_size = Vector2i(target_width, _popup_menu.min_size.y)
_popup_menu.size = Vector2i(maxi(_popup_menu.size.x, target_width), _popup_menu.size.y)
call_deferred("_sync_popup_size")
if _selected_index >= 0:
_popup_menu.set_focused_item(_selected_index)
else:
_popup_menu.set_focused_item(-1)
func _sync_popup_size() -> void:
if _popup_menu == null:
return
var target_width: int = maxi(roundi(size.x), _popup_menu.min_size.x)
_popup_menu.min_size = Vector2i(target_width, _popup_menu.min_size.y)
_popup_menu.size = Vector2i(maxi(_popup_menu.size.x, target_width), _popup_menu.size.y)
if _selected_index >= 0:
_popup_menu.set_focused_item(_selected_index)
func _on_popup_item_pressed(id: int) -> void:
var index: int = get_popup().get_item_index(id)
if index < 0 or index == _selected_index:
return
_set_selected(index)
item_selected.emit(index)
@@ -0,0 +1 @@
uid://qlg14pj6jfj7
@@ -13,6 +13,7 @@ const DEFAULT_CONFIG: Dictionary = {
"background": "#171B24F2",
"surface": "#252B38FA",
"surface_hover": "#30394A",
"selected_surface": "#103B4D",
"text": "#F5F7FA",
"text_muted": "#AAB2C0",
"edit": "#22C6FF",
@@ -32,6 +33,8 @@ const DEFAULT_CONFIG: Dictionary = {
"section": 22,
"title": 26,
"menu": 20,
"menu_bar": 22,
"menu_popup": 26,
"status": 16,
"mode": 24,
"prop_name": 20,
@@ -40,7 +43,13 @@ const DEFAULT_CONFIG: Dictionary = {
"spacing": {"small": 8, "medium": 12, "large": 16},
"radii": {"control": 8, "panel": 10},
"borders": {"normal": 1, "focus": 2},
"targets": {"button_min_height": 44, "row_min_height": 44, "mode_button_min_height": 56},
"targets": {
"button_min_height": 44,
"row_min_height": 44,
"mode_button_min_height": 56,
"context_menu_row_height": 48,
"context_menu_min_width": 208,
},
},
"editors": {"prop": {}, "stickman": {}},
"stickman_canvas": {
@@ -54,6 +63,7 @@ const COLOR_ROLE_SECTIONS: Dictionary = {
"background": "colors",
"surface": "colors",
"surface_hover": "colors",
"selected_surface": "colors",
"text": "colors",
"text_muted": "colors",
"edit": "colors",
@@ -73,6 +83,8 @@ const SIZE_ROLE_SECTIONS: Dictionary = {
"section": "fonts",
"title": "fonts",
"menu": "fonts",
"menu_bar": "fonts",
"menu_popup": "fonts",
"status": "fonts",
"mode": "fonts",
"prop_name": "fonts",
@@ -87,6 +99,8 @@ const SIZE_ROLE_SECTIONS: Dictionary = {
"button_min_height": "targets",
"row_min_height": "targets",
"mode_button_min_height": "targets",
"context_menu_row_height": "targets",
"context_menu_min_width": "targets",
}
const CONFIG_SIZE_KEYS: Dictionary = {
"normal_border": "normal",
@@ -208,13 +222,12 @@ func control_theme(editor: StringName) -> Theme:
theme.set_font_size("font_size", type_name, chrome_px(&"body", editor))
var panel_box: StyleBoxFlat = _make_box(surface, border, chrome_px(&"panel", editor), normal_width)
theme.set_stylebox("panel", "PanelContainer", panel_box)
theme.set_stylebox("normal", "PopupMenu", panel_box.duplicate())
theme.set_stylebox("panel", "MenuBar", panel_box.duplicate())
theme.set_font_size("font_size", "Label", chrome_px(&"body", editor))
theme.set_color("font_color", "Label", text)
theme.set_color("font_hover_color", "Label", text)
theme.set_font_size("font_size", "PopupMenu", chrome_px(&"menu", editor))
theme.set_font_size("font_size", "MenuBar", chrome_px(&"menu", editor))
theme.set_font_size("font_size", "PopupMenu", chrome_px(&"menu_popup", editor))
theme.set_font_size("font_size", "MenuBar", chrome_px(&"menu_bar", editor))
theme.set_color("font_color", "MenuBar", text)
return theme
+38 -13
View File
@@ -1,7 +1,7 @@
class_name StageDialogStyler
extends RefCounted
## Renders child-facing dialogs as a shared in-stage card while preserving the
## native AcceptDialog/ConfirmationDialog signals used by the stage workflows.
## Renders child-facing stage and editor dialogs as a shared card while preserving
## the native AcceptDialog/ConfirmationDialog signals used by their workflows.
const THEME_DEFAULTS := preload("res://scripts/stage/sandbox_theme.gd")
const OVERLAY_META := &"stage_dialog_overlay"
@@ -20,7 +20,9 @@ const ICON_META := &"stage_dialog_icon"
const ICON_LABEL_META := &"stage_dialog_icon_label"
const ICON_BADGE_META := &"stage_dialog_icon_badge"
const PRIMARY_META := &"stage_dialog_primary_button"
const SECONDARY_META := &"stage_dialog_secondary_buttons"
const DESTRUCTIVE_META := &"stage_dialog_destructive_buttons"
const DEFAULT_PRIMARY_META := &"stage_dialog_default_primary"
const OPEN_META := &"stage_dialog_open"
@@ -29,7 +31,9 @@ static func apply(
stage_theme: SandboxTheme,
ui_font: Font,
primary_button: Button = null,
destructive_buttons: Array = []
destructive_buttons: Array = [],
secondary_buttons: Array = [],
default_ok_is_primary: bool = true
) -> void:
if dialog == null:
return
@@ -37,10 +41,14 @@ static func apply(
if not dialog.has_meta(OVERLAY_META):
_install_dialog_surface(dialog, active_theme, ui_font)
var ok_button: Button = (dialog as AcceptDialog).get_ok_button() if dialog is AcceptDialog else null
var primary: Button = primary_button if primary_button != null else ok_button
var primary: Button = primary_button
if primary == null and default_ok_is_primary and not _contains_button(destructive_buttons, ok_button):
primary = ok_button
dialog.set_meta(PRIMARY_META, primary)
dialog.set_meta(SECONDARY_META, secondary_buttons.duplicate())
dialog.set_meta(DESTRUCTIVE_META, destructive_buttons.duplicate())
_refresh_dialog_surface(dialog, active_theme, ui_font, primary, destructive_buttons)
dialog.set_meta(DEFAULT_PRIMARY_META, default_ok_is_primary)
_refresh_dialog_surface(dialog, active_theme, ui_font, primary, destructive_buttons, secondary_buttons, default_ok_is_primary)
static func popup(dialog: Window) -> void:
@@ -48,9 +56,11 @@ static func popup(dialog: Window) -> void:
return
var active_theme: SandboxTheme = _theme_for(dialog)
var font: Font = dialog.get_meta(&"stage_dialog_font") as Font if dialog.has_meta(&"stage_dialog_font") else null
var primary: Button = dialog.get_meta(PRIMARY_META, null) as Button
var primary: Button = dialog.get_meta(PRIMARY_META) as Button if dialog.has_meta(PRIMARY_META) else null
var secondary_buttons: Array = dialog.get_meta(SECONDARY_META, []) as Array
var destructive_buttons: Array = dialog.get_meta(DESTRUCTIVE_META, []) as Array
_refresh_dialog_surface(dialog, active_theme, font, primary, destructive_buttons)
var default_ok_is_primary: bool = bool(dialog.get_meta(DEFAULT_PRIMARY_META, true))
_refresh_dialog_surface(dialog, active_theme, font, primary, destructive_buttons, secondary_buttons, default_ok_is_primary)
var overlay: Control = dialog.get_meta(OVERLAY_META) as Control
if overlay == null:
return
@@ -71,8 +81,10 @@ static func popup(dialog: Window) -> void:
if is_instance_valid(tree):
tree.process_frame.connect(fit_after_layout, Object.CONNECT_ONE_SHOT)
tree.process_frame.connect(defer_fit_one_frame, Object.CONNECT_ONE_SHOT)
var focus_primary: Button = dialog.get_meta(PRIMARY_META, null) as Button
var focus_primary: Button = dialog.get_meta(PRIMARY_META) as Button if dialog.has_meta(PRIMARY_META) else null
var focus_target: Button = visual_button(focus_primary)
if focus_target == null and dialog is ConfirmationDialog:
focus_target = visual_button((dialog as ConfirmationDialog).get_cancel_button())
if focus_target != null and focus_target.visible:
focus_target.call_deferred("grab_focus")
@@ -232,6 +244,7 @@ static func _install_dialog_surface(dialog: Window, stage_theme: SandboxTheme, u
var overlay := Control.new()
overlay.name = "StageDialogOverlay"
overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
overlay.theme = stage_theme.control_theme()
overlay.mouse_filter = Control.MOUSE_FILTER_STOP
overlay.z_index = 100
overlay.z_as_relative = false
@@ -385,7 +398,9 @@ static func _refresh_dialog_surface(
stage_theme: SandboxTheme,
ui_font: Font,
primary_button: Button = null,
destructive_buttons: Array = []
destructive_buttons: Array = [],
secondary_buttons: Array = [],
default_ok_is_primary: bool = true
) -> void:
if not dialog.has_meta(OVERLAY_META):
return
@@ -458,16 +473,24 @@ static func _refresh_dialog_surface(
for old_button: Node in action_row.get_children():
action_row.remove_child(old_button)
old_button.queue_free()
var primary: Button = primary_button if primary_button != null else ok_button
var primary: Button = primary_button
if primary == null and default_ok_is_primary and not _contains_button(destructive_buttons, ok_button):
primary = ok_button
if ok_button != null:
ok_button.visible = false
if cancel_button != null:
cancel_button.visible = false
for raw_secondary: Variant in secondary_buttons:
if raw_secondary is Button:
(raw_secondary as Button).visible = false
for raw_button: Variant in destructive_buttons:
if raw_button is Button:
(raw_button as Button).visible = false
var ordered: Array[Button] = []
_append_unique_button(ordered, primary)
for raw_secondary: Variant in secondary_buttons:
if raw_secondary is Button:
_append_unique_button(ordered, raw_secondary as Button)
for raw_destructive: Variant in destructive_buttons:
if raw_destructive is Button:
_append_unique_button(ordered, raw_destructive as Button)
@@ -476,6 +499,8 @@ static func _refresh_dialog_surface(
for original: Button in ordered:
var visual: Button = Button.new()
visual.text = original.text
visual.disabled = original.disabled
visual.tooltip_text = original.tooltip_text
visual.name = "Action_%s" % String(original.name)
visual.size_flags_horizontal = Control.SIZE_SHRINK_END
var role: StringName = &"secondary"
@@ -535,18 +560,18 @@ static func _find_overlay_host(dialog: Window) -> Node:
return ancestor
ancestor = ancestor.get_parent()
var tree: SceneTree = dialog.get_tree()
var scene: Node = tree.current_scene if tree != null else null
var scene: Node = tree.current_scene if tree != null and tree.current_scene != null else (tree.root if tree != null else null)
if scene != null:
var ui_layer: Node = scene.get_node_or_null("UI")
if ui_layer is CanvasLayer:
return ui_layer
var existing: Node = scene.get_node_or_null("StageDialogLayer")
var existing: Node = scene.find_child("StageDialogLayer", true, false)
if existing is CanvasLayer:
return existing
var layer := CanvasLayer.new()
layer.name = "StageDialogLayer"
layer.layer = 100
scene.add_child(layer)
scene.add_child.call_deferred(layer)
return layer
return null