feat: harmonize Prop and Stickman editor UI
This commit is contained in:
@@ -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"))
|
||||
Reference in New Issue
Block a user