class_name RulePanel extends PopupPanel ## RulePanel - Rule list editor popup (Phase 3c.4). ## ## Shows a list of rules (already filtered by the stage: by source stickman or ## by waypoint), with edit / delete / drag-reorder controls and Add Rule / Clear ## All. Mutations delegate to the stage via signals; reordering emits the new ## order of the *displayed* rules' ids, which the stage maps back onto its ## full `_event_rules` array (preserving un-filtered rules' positions). const ACTION_REGISTRY := preload("res://scripts/action_registry.gd") const TRIGGER_REGISTRY := preload("res://scripts/trigger_registry.gd") signal edit_requested(rule_id: int) signal delete_requested(rule_id: int) signal add_requested() signal clear_requested() signal reorder_requested(ordered_ids: Array[int]) var _title_label: Label = null var _list: VBoxContainer = null var _empty_label: Label = null var _rows: Array[PanelContainer] = [] var _rules: Array[Dictionary] = [] var _drag_index: int = -1 var _drag_target: int = -1 ## Theme font overrides (set via apply_font from sandbox_theme.json). Size 0 = no ## override (engine default); Font null = no override. var _ui_font: Font = null var _emoji_font: Font = null var _base_font_size: int = 0 var _row_font_size: int = 0 var _title_font_size: int = 0 var _title_font: Font = null func _ready() -> void: exclusive = true popup_window = true _build_ui() func _unhandled_input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and not event.echo: if (event as InputEventKey).keycode == KEY_ESCAPE: hide() ## Updates the displayed rule list + title without popping up. func set_rules(rules: Array[Dictionary], title_hint: String) -> void: _rules = rules.duplicate(true) _title_label.text = title_hint refresh() ## Attaches a filtered rule list + a source hint, rebuilds the rows and pops up. func show_rules(rules: Array[Dictionary], title_hint: String) -> void: set_rules(rules, title_hint) popup_centered() func refresh() -> void: if _list == null: return for child: Node in _list.get_children(): if child != _empty_label: child.queue_free() _rows.clear() _drag_index = -1 _drag_target = -1 _empty_label.visible = _rules.is_empty() for i: int in _rules.size(): _rows.append(_make_row(i, _rules[i])) func _build_ui() -> void: title = "Rules" var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 12) margin.add_theme_constant_override("margin_right", 12) margin.add_theme_constant_override("margin_top", 12) margin.add_theme_constant_override("margin_bottom", 12) add_child(margin) var vbox := VBoxContainer.new() vbox.add_theme_constant_override("separation", 8) margin.add_child(vbox) var title_bar := HBoxContainer.new() vbox.add_child(title_bar) _title_label = Label.new() _title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _title_label.text = "Source: ?" title_bar.add_child(_title_label) var close_btn := Button.new() close_btn.text = "× Close" close_btn.pressed.connect(hide) title_bar.add_child(close_btn) var scroll := ScrollContainer.new() scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL scroll.custom_minimum_size = Vector2(0.0, 260.0) vbox.add_child(scroll) _list = VBoxContainer.new() _list.size_flags_horizontal = Control.SIZE_EXPAND_FILL _list.add_theme_constant_override("separation", 4) scroll.add_child(_list) _empty_label = Label.new() _empty_label.text = "No rules." _empty_label.modulate = Color(1.0, 1.0, 1.0, 0.5) _list.add_child(_empty_label) var footer := HBoxContainer.new() footer.add_theme_constant_override("separation", 8) vbox.add_child(footer) var add_btn := Button.new() add_btn.text = "➕ Add Rule" add_btn.pressed.connect(func() -> void: add_requested.emit()) footer.add_child(add_btn) var clear_btn := Button.new() clear_btn.text = "🗑 Clear All" clear_btn.pressed.connect(func() -> void: clear_requested.emit()) footer.add_child(clear_btn) min_size = Vector2i(520, 360) func _make_row(index: int, rule: Dictionary) -> PanelContainer: var panel := PanelContainer.new() panel.add_theme_stylebox_override("panel", _row_style(Color(0.0, 0.0, 0.0, 0.0))) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 6) panel.add_child(row) var number := Label.new() number.text = str(index + 1) row.add_child(number) var body := VBoxContainer.new() body.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(body) body.add_child(_make_label(_trigger_summary(rule))) var actions := _action_summaries(rule) if actions.is_empty(): body.add_child(_make_label("→ (no actions)", true)) else: for text: String in actions: body.add_child(_make_label(text, true)) var edit := Button.new() edit.text = "✎" edit.tooltip_text = "Edit" edit.pressed.connect(func() -> void: edit_requested.emit(int(rule.get("id", -1)))) row.add_child(edit) var remove := Button.new() remove.text = "✕" remove.tooltip_text = "Delete" remove.pressed.connect(func() -> void: delete_requested.emit(int(rule.get("id", -1)))) row.add_child(remove) var drag := Button.new() drag.text = "≡" drag.tooltip_text = "Drag to reorder" drag.mouse_default_cursor_shape = Control.CURSOR_MOVE drag.gui_input.connect(_on_drag_handle_gui_input.bind(index)) row.add_child(drag) # Parent the row into the list and apply the theme font/size overrides. Without # add_child the row never renders and drag-reorder has no geometry to work with. _list.add_child(panel) _apply_font_recursive(panel, _row_font_size) return panel ## Applies theme font/size overrides (mirrors AssetSelector.apply_font). Called by ## the stage after add_child so the panel's UI is already built. func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void: _ui_font = ui_font _emoji_font = emoji_font _base_font_size = int(sizes.get("rule_panel", 18)) _row_font_size = int(sizes.get("panel_row", 16)) _title_font_size = int(sizes.get("panel_title", 18)) var title_bold := bool(sizes.get("panel_title_bold", true)) var bold_font: Font = sizes.get("bold_font", null) _title_font = bold_font if title_bold and bold_font != null else ui_font _apply_font_recursive(self, _base_font_size) if _title_label != null: _apply_font_to(_title_label, _title_font_size, _title_font) if _empty_label != null: _apply_font_to(_empty_label, _row_font_size) func _apply_font_recursive(node: Node, size: int) -> void: for child: Node in node.get_children(): if child is Control: _apply_font_to(child as Control, size) _apply_font_recursive(child, size) func _apply_font_to(c: Control, size: int, font: Font = null) -> void: if c == null: return if font != null: c.add_theme_font_override("font", font) elif _ui_font != null: c.add_theme_font_override("font", _ui_font) elif _emoji_font != null: c.add_theme_font_override("font", _emoji_font) if size > 0: c.add_theme_font_size_override("font_size", size) func _make_label(text: String, dim: bool = false) -> Label: var lbl := Label.new() lbl.text = text if dim: lbl.modulate = Color(1.0, 1.0, 1.0, 0.7) return lbl func _trigger_summary(rule: Dictionary) -> String: var trigger: Dictionary = rule.get("trigger", {}) var type := String(trigger.get("type", "")) return "%s %s %s" % [ TRIGGER_REGISTRY.icon(type), _actor_name(int(trigger.get("source", -1))), TRIGGER_REGISTRY.label(type), ] func _action_summaries(rule: Dictionary) -> Array[String]: var out: Array[String] = [] for a: Dictionary in rule.get("actions", []): out.append("→ %s %s %s" % [ ACTION_REGISTRY.icon(String(a.get("type", ""))), _actor_name(int(a.get("target", -1))), ACTION_REGISTRY.summarize(a), ]) return out func _row_style(bg: Color) -> StyleBoxFlat: var sb := StyleBoxFlat.new() sb.bg_color = bg sb.set_corner_radius_all(4) sb.content_margin_left = 6.0 sb.content_margin_right = 6.0 sb.content_margin_top = 2.0 sb.content_margin_bottom = 2.0 return sb func _on_drag_handle_gui_input(event: InputEvent, index: int) -> void: if event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT: if (event as InputEventMouseButton).pressed: _drag_index = index _drag_target = index _refresh_drag_highlight() else: _commit_drag() elif event is InputEventMouseMotion and _drag_index >= 0: _update_drag_target() func _update_drag_target() -> void: var mouse_y := _list.get_local_mouse_position().y var best := _drag_target var best_d := INF for i: int in _rows.size(): var row := _rows[i] var d := absf((row.position.y + row.size.y * 0.5) - mouse_y) if d < best_d: best_d = d best = i if best != _drag_target: _drag_target = best _refresh_drag_highlight() func _refresh_drag_highlight() -> void: for i: int in _rows.size(): var bg := Color(0.0, 0.0, 0.0, 0.0) if _drag_index >= 0 and i == _drag_target: bg = Color(0.15, 0.4, 0.9, 0.4) (_rows[i] as PanelContainer).add_theme_stylebox_override("panel", _row_style(bg)) func _commit_drag() -> void: var from := _drag_index var to := _drag_target _drag_index = -1 _drag_target = -1 _refresh_drag_highlight() if from < 0 or to < 0 or from == to or from >= _rules.size() or to >= _rules.size(): return var moved: Dictionary = _rules[from] _rules.remove_at(from) _rules.insert(to, moved) var ordered: Array[int] = [] for r: Dictionary in _rules: ordered.append(int(r.get("id", -1))) refresh() reorder_requested.emit(ordered) func _actor_name(id: int) -> String: if id <= 0: # -1 is the "no source / no actor" sentinel; instance_from_id(-1) would # spam an engine error, so resolve the display name only for real ids. return "?" var node := instance_from_id(id) if node is Node and is_instance_valid(node): return String((node as Node).name) return "?"