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
@@ -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