class_name SpeechStyleToolbar extends Control ## Transient stage-side speech styling controls. The composer owns every edit. const SPEECH_PAYLOAD := preload("res://scripts/speech_payload.gd") class FramePreview extends Control: const RENDERER := preload("res://scripts/stickman_speech_bubble.gd") var frame_id: String = "talk" var fill: Color = Color.WHITE var outline: Color = Color.BLACK func _draw() -> void: var frame: Rect2 = Rect2(5.0, 6.0, maxf(30.0, size.x - 10.0), maxf(28.0, size.y - 12.0)) RENDERER.draw_style_shape(self, frame_id, frame, fill, outline, false, 3) class FrameSample extends Button: var frame_id: String = "talk" var fill: Color = Color.WHITE var outline: Color = Color.BLACK var compact: bool = false func configure(style_id: String, fill_color: Color, outline_color: Color, use_compact_layout: bool, card_width: float) -> void: frame_id = style_id fill = fill_color outline = outline_color compact = use_compact_layout custom_minimum_size = Vector2(card_width, 92.0) if compact else Vector2(118.0, 108.0) text = "" var card_layout: VBoxContainer = VBoxContainer.new() card_layout.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) card_layout.offset_left = 6.0 card_layout.offset_top = 6.0 card_layout.offset_right = -6.0 card_layout.offset_bottom = -6.0 card_layout.add_theme_constant_override("separation", 2) card_layout.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(card_layout) var preview: FramePreview = FramePreview.new() preview.frame_id = frame_id preview.fill = fill preview.outline = outline preview.custom_minimum_size = Vector2(0.0, 56.0 if compact else 68.0) preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL preview.mouse_filter = Control.MOUSE_FILTER_IGNORE card_layout.add_child(preview) var title: Label = Label.new() title.text = style_id.capitalize() title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER title.add_theme_font_size_override("font_size", 16 if compact else 18) title.add_theme_color_override("font_color", Color("#F8FAFC")) title.custom_minimum_size.y = 24.0 if compact else 28.0 title.mouse_filter = Control.MOUSE_FILTER_IGNORE card_layout.add_child(title) signal chooser_open_requested(kind: StringName) signal choice_selected(kind: StringName, value: String) signal sequence_action_requested(action: StringName) signal done_styling_requested() signal message_edit_requested(opened: bool) var _theme: SandboxTheme var _context: Dictionary = {} var _stage_rect: Rect2 = Rect2() var _frame_rect: Rect2 = Rect2() var _badge_avoidance_rects: Array[Rect2] = [] var _bar: PanelContainer var _bar_layout: VBoxContainer var _bar_scroll: ScrollContainer var _bar_content: VBoxContainer var _speech_flow: GridContainer var _bar_title: Label var _bar_ribbon: PanelContainer var _bubble_position_label: Label var _message_button: Button var _done_styling_button: Button var _earlier_button: Button var _later_button: Button var _add_bubble_button: Button var _remove_bubble_button: Button var _undo_remove_button: Button var _sequence_hint_label: Label var _chooser: PanelContainer var _chooser_layout: VBoxContainer var _chooser_ribbon: PanelContainer var _chooser_scroll: ScrollContainer var _chooser_content: VBoxContainer var _chooser_title: Label var _category_buttons: Array[Button] = [] var _category_group: ButtonGroup var _category_kinds: Array[StringName] = [&"frame", &"letters", &"effect"] var _choice_buttons: Dictionary = {} var _active_kind: StringName = &"" var _busy: bool = false var _message_dialog_open: bool = false var _compact_choices: bool = false var _choice_width: float = 440.0 func _ready() -> void: set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) mouse_filter = Control.MOUSE_FILTER_IGNORE z_index = 80 _build_ui() visible = false func configure(theme_data: SandboxTheme) -> void: _theme = theme_data if is_inside_tree() and _bar != null: _apply_theme_styles() func set_context(context: Dictionary) -> void: var changed: bool = _context != context _context = context.duplicate(true) var choice_layout_changed: bool = _prepare_choice_layout() _sync_category_disabled() if _message_button != null: _message_button.disabled = _busy _sync_sequence_controls() if not _active_kind.is_empty(): if choice_layout_changed: _build_choices(_active_kind, false) elif changed: _refresh_choice_states() _sync_category_buttons() _layout_panels() func set_anchor_rect(frame_viewport_rect: Rect2, stage_viewport_rect: Rect2) -> void: _frame_rect = frame_viewport_rect _stage_rect = stage_viewport_rect visible = frame_viewport_rect.size.x > 0.0 and frame_viewport_rect.size.y > 0.0 var choice_layout_changed: bool = _prepare_choice_layout() if choice_layout_changed and not _active_kind.is_empty(): _build_choices(_active_kind, false) _layout_panels() func set_badge_avoidance_rects(rects: Array[Rect2]) -> void: if _badge_avoidance_rects == rects: return _badge_avoidance_rects = rects.duplicate() _layout_panels() func present_chooser(kind: StringName) -> void: if _busy or not [&"frame", &"letters", &"effect"].has(kind): return _active_kind = kind _prepare_choice_layout() _build_choices(kind) _chooser.visible = true _sync_category_buttons() _layout_panels() func close_chooser() -> void: if not is_chooser_open(): return _active_kind = &"" _chooser.hide() _sync_category_buttons() func dismiss_styling() -> void: close_chooser() hide() done_styling_requested.emit() func is_chooser_open() -> bool: return _chooser != null and _chooser.visible func contains_viewport_point(point: Vector2) -> bool: if not visible: return false if _bar != null and _bar.visible and _bar.get_global_rect().has_point(point): return true return _chooser != null and _chooser.visible and _chooser.get_global_rect().has_point(point) func chooser_contains_viewport_point(point: Vector2) -> bool: return _chooser != null and _chooser.visible and _chooser.get_global_rect().has_point(point) func toolbar_viewport_rect() -> Rect2: return _bar.get_global_rect() if _bar != null and is_instance_valid(_bar) else Rect2() func set_message_dialog_open(opened: bool) -> void: _message_dialog_open = opened if _message_button != null: _message_button.set_pressed_no_signal(opened) _apply_category_button_style(_message_button, opened) func set_busy(busy: bool) -> void: _busy = busy _sync_category_disabled() if _message_button != null: _message_button.disabled = busy if _done_styling_button != null: _done_styling_button.disabled = busy _sync_sequence_controls() if _chooser != null: _chooser.mouse_filter = Control.MOUSE_FILTER_STOP if busy else Control.MOUSE_FILTER_PASS _refresh_choice_states() func _sync_category_disabled() -> void: var has_message: bool = not String(_context.get("text", "")).strip_edges().is_empty() for index: int in range(_category_buttons.size()): var is_effect_button: bool = index < _category_kinds.size() and _category_kinds[index] == &"effect" _category_buttons[index].disabled = _busy or (is_effect_button and not has_message) func _sync_sequence_controls() -> void: if _context.is_empty(): return var count: int = int(_context.get("bubble_count", 1)) var limit: int = int(_context.get("max_bubbles", 8)) if _bubble_position_label != null: var selected_index: int = clampi(int(_context.get("bubble_index", 0)), 0, maxi(0, count - 1)) _bubble_position_label.text = "Bubble %d of %d" % [selected_index + 1, count] if _earlier_button != null: _earlier_button.disabled = _busy or not bool(_context.get("can_move_earlier", false)) if _later_button != null: _later_button.disabled = _busy or not bool(_context.get("can_move_later", false)) if _add_bubble_button != null: _add_bubble_button.disabled = _busy or count >= limit _add_bubble_button.tooltip_text = "Eight bubbles is the limit." if count >= limit else "Add a bubble after this one" if _remove_bubble_button != null: _remove_bubble_button.disabled = _busy or count <= 1 if _undo_remove_button != null: var can_undo_removed_bubble: bool = bool(_context.get("can_undo_remove", false)) _undo_remove_button.visible = can_undo_removed_bubble _undo_remove_button.disabled = _busy or not can_undo_removed_bubble if _sequence_hint_label != null: var hint: String = String(_context.get("sequence_hint", "")) if count >= limit and hint.is_empty(): hint = "You can add up to eight bubbles." _sequence_hint_label.text = hint _sequence_hint_label.visible = not hint.is_empty() func _build_ui() -> void: _bar = PanelContainer.new() _bar.name = "SpeechStyleBar" _bar.mouse_filter = Control.MOUSE_FILTER_STOP add_child(_bar) _bar_layout = VBoxContainer.new() _bar_layout.add_theme_constant_override("separation", 8) _bar.add_child(_bar_layout) _bar_ribbon = PanelContainer.new() _bar_ribbon.add_theme_stylebox_override("panel", _ribbon_style(_ribbon_color())) _bar_layout.add_child(_bar_ribbon) _bar_title = Label.new() _bar_title.text = "Speech Style" _bar_title.add_theme_font_size_override("font_size", 24) _bar_title.add_theme_color_override("font_color", Color("#FFFFFF")) _bar_title.mouse_filter = Control.MOUSE_FILTER_IGNORE _bar_ribbon.add_child(_bar_title) _bar_scroll = ScrollContainer.new() _bar_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED _bar_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO _bar_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL _bar_layout.add_child(_bar_scroll) _bar_content = VBoxContainer.new() _bar_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL _bar_content.add_theme_constant_override("separation", 8) _bar_scroll.add_child(_bar_content) var sequence_flow: HFlowContainer = _new_button_flow() _bar_content.add_child(sequence_flow) _bubble_position_label = _group_label("Bubble 1 of 1") _bubble_position_label.add_theme_font_size_override("font_size", 20) _bubble_position_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL sequence_flow.add_child(_bubble_position_label) _earlier_button = _make_button("↑ Earlier") _earlier_button.tooltip_text = "Move this bubble earlier in the speech" _earlier_button.pressed.connect(_on_sequence_action_pressed.bind(&"earlier")) sequence_flow.add_child(_earlier_button) _later_button = _make_button("↓ Later") _later_button.tooltip_text = "Move this bubble later in the speech" _later_button.pressed.connect(_on_sequence_action_pressed.bind(&"later")) sequence_flow.add_child(_later_button) _category_group = ButtonGroup.new() _category_group.allow_unpress = false _speech_flow = GridContainer.new() _speech_flow.columns = 2 _speech_flow.size_flags_horizontal = Control.SIZE_EXPAND_FILL _speech_flow.add_theme_constant_override("h_separation", 8) _speech_flow.add_theme_constant_override("v_separation", 8) _bar_content.add_child(_speech_flow) _message_button = _make_button("✏ Message") _message_button.custom_minimum_size = Vector2(0.0, maxf(56.0, _hit_height())) _message_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL _message_button.toggle_mode = true _message_button.toggled.connect(_on_message_toggled) _speech_flow.add_child(_message_button) _apply_category_button_style(_message_button, false) _add_category_button("🗨 Frame", &"frame", _speech_flow) _add_category_button("🔤 Letters", &"letters", _speech_flow) _add_category_button("✨ Effects", &"effect", _speech_flow) var bubble_flow: HFlowContainer = _new_button_flow() _bar_content.add_child(bubble_flow) _add_bubble_button = _make_button("➕ Add bubble") _add_bubble_button.pressed.connect(_on_sequence_action_pressed.bind(&"add")) bubble_flow.add_child(_add_bubble_button) _remove_bubble_button = _make_button("🗑 Remove bubble") _remove_bubble_button.pressed.connect(_on_sequence_action_pressed.bind(&"remove")) bubble_flow.add_child(_remove_bubble_button) _undo_remove_button = _make_button("↶ Undo") _undo_remove_button.visible = false _undo_remove_button.pressed.connect(_on_sequence_action_pressed.bind(&"undo_remove")) bubble_flow.add_child(_undo_remove_button) _sequence_hint_label = _group_label("") _sequence_hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _sequence_hint_label.visible = false _bar_content.add_child(_sequence_hint_label) var done_margin: MarginContainer = MarginContainer.new() done_margin.add_theme_constant_override("margin_top", 8) _bar_layout.add_child(done_margin) var footer: CenterContainer = CenterContainer.new() done_margin.add_child(footer) _done_styling_button = _make_button("✓ Done styling") _done_styling_button.custom_minimum_size = Vector2(220.0, maxf(56.0, _hit_height())) _done_styling_button.add_theme_font_size_override("font_size", 19) _done_styling_button.pressed.connect(dismiss_styling) footer.add_child(_done_styling_button) _chooser = PanelContainer.new() _chooser.name = "SpeechStyleChooser" _chooser.mouse_filter = Control.MOUSE_FILTER_STOP _chooser.hide() add_child(_chooser) _chooser_layout = VBoxContainer.new() _chooser_layout.add_theme_constant_override("separation", 8) _chooser.add_child(_chooser_layout) _chooser_ribbon = PanelContainer.new() _chooser_ribbon.add_theme_stylebox_override("panel", _ribbon_style(_ribbon_color())) _chooser_layout.add_child(_chooser_ribbon) _chooser_title = Label.new() _chooser_title.add_theme_font_size_override("font_size", 22) _chooser_title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _chooser_title.add_theme_color_override("font_color", Color("#FFFFFF")) _chooser_title.mouse_filter = Control.MOUSE_FILTER_IGNORE _chooser_ribbon.add_child(_chooser_title) _chooser_scroll = ScrollContainer.new() _chooser_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED _chooser_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO _chooser_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL _chooser_layout.add_child(_chooser_scroll) _chooser_content = VBoxContainer.new() _chooser_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL _chooser_content.add_theme_constant_override("separation", 8) _chooser_scroll.add_child(_chooser_content) _apply_theme_styles() func _new_button_flow() -> HFlowContainer: var flow: HFlowContainer = HFlowContainer.new() flow.size_flags_horizontal = Control.SIZE_EXPAND_FILL flow.add_theme_constant_override("h_separation", 8) flow.add_theme_constant_override("v_separation", 8) return flow func _group_label(caption: String) -> Label: var label: Label = Label.new() label.text = caption label.add_theme_font_size_override("font_size", 17) label.add_theme_color_override("font_color", Color("#D6E1EF")) return label func _add_category_button(caption: String, kind: StringName, parent: Control) -> void: var button: Button = _make_button(caption) button.custom_minimum_size = Vector2(0.0, maxf(56.0, _hit_height())) button.size_flags_horizontal = Control.SIZE_EXPAND_FILL button.toggle_mode = true button.button_group = _category_group button.add_theme_font_size_override("font_size", 21) button.pressed.connect(_on_category_pressed.bind(kind)) _category_buttons.append(button) parent.add_child(button) _apply_category_button_style(button, false) func _on_message_toggled(opened: bool) -> void: if _busy: _message_button.set_pressed_no_signal(_message_dialog_open) return set_message_dialog_open(opened) if opened: close_chooser() message_edit_requested.emit(opened) func _on_category_pressed(kind: StringName) -> void: if _busy: return if _message_dialog_open: set_message_dialog_open(false) message_edit_requested.emit(false) if is_chooser_open() and _active_kind == kind: _sync_category_buttons() return chooser_open_requested.emit(kind) func _on_sequence_action_pressed(action: StringName) -> void: if _busy: return sequence_action_requested.emit(action) func _sync_category_buttons() -> void: for index: int in range(_category_buttons.size()): var button: Button = _category_buttons[index] var active: bool = is_chooser_open() and index < _category_kinds.size() and _category_kinds[index] == _active_kind button.set_pressed_no_signal(active) _apply_category_button_style(button, active) func _apply_category_button_style(button: Button, active: bool) -> void: var normal: StyleBoxFlat = StyleBoxFlat.new() normal.bg_color = Color("#176B83") if active else Color("#344255") normal.border_color = _selection_color() if active else Color("#C6D1DE") normal.set_border_width_all(4 if active else 2) normal.set_corner_radius_all(9) normal.set_content_margin_all(8) var hover: StyleBoxFlat = normal.duplicate() as StyleBoxFlat if not active: hover.bg_color = Color("#40546D") button.add_theme_stylebox_override("normal", normal) button.add_theme_stylebox_override("pressed", normal) button.add_theme_stylebox_override("hover", hover) button.add_theme_color_override("font_color", Color("#FFFFFF")) func _build_choices(kind: StringName, relayout: bool = true) -> void: for child: Node in _chooser_content.get_children(): _chooser_content.remove_child(child) child.free() _chooser_scroll.custom_minimum_size.y = 0.0 _choice_buttons.clear() var target: Dictionary = _context.get("target", {}) if _context.get("target", {}) is Dictionary else {} var target_text: String = String(target.get("text", "")) _chooser_title.text = _chooser_title_for(kind, target_text) if kind == &"frame": var frames: GridContainer = GridContainer.new() frames.columns = 2 if _compact_choices else 3 frames.add_theme_constant_override("h_separation", 6) frames.add_theme_constant_override("v_separation", 8) _chooser_content.add_child(frames) for frame_id: String in SPEECH_PAYLOAD.BUBBLE_STYLES: var sample: FrameSample = FrameSample.new() var card_width: float = minf(86.0, maxf(60.0, (_choice_width - 48.0) * 0.5)) if _compact_choices else 118.0 sample.configure(frame_id, _frame_color(frame_id, "fill", Color("#FFFDF6")), _frame_color(frame_id, "outline", Color("#202020")), _compact_choices, card_width) _configure_choice(sample, &"frame", frame_id) frames.add_child(sample) elif kind == &"letters": _add_subheading("Font") var fonts: HFlowContainer = HFlowContainer.new() _chooser_content.add_child(fonts) for font_id: String in SPEECH_PAYLOAD.FONT_PRESETS: var font_choice: Button = _make_button("Aa\n" + font_id.capitalize()) font_choice.custom_minimum_size = Vector2(76.0, 86.0) if _compact_choices else Vector2(120.0, 92.0) font_choice.add_theme_font_override("font", _theme.speech_font(font_id) if _theme != null else ThemeDB.fallback_font) var font_preview_size: int = 24 if _theme != null: font_preview_size = clampi(roundi(float(_theme.speech_font_size(font_id, "medium")) * 0.7), 24, 28) font_choice.add_theme_font_size_override("font_size", font_preview_size) _configure_choice(font_choice, &"font", font_id) fonts.add_child(font_choice) _add_subheading("Size") var sizes: HFlowContainer = HFlowContainer.new() _chooser_content.add_child(sizes) for size_id: String in SPEECH_PAYLOAD.SIZE_PRESETS: var size_choice: Button = _make_button("Aa\n" + size_id.capitalize()) size_choice.custom_minimum_size = Vector2(76.0, 76.0) if _compact_choices else Vector2(120.0, 84.0) size_choice.add_theme_font_size_override("font_size", _size_font_size(size_id)) _configure_choice(size_choice, &"size", size_id) sizes.add_child(size_choice) _add_subheading("Color") var colors: GridContainer = GridContainer.new() colors.columns = clampi(floori((_choice_width - 24.0) / 72.0), 1, 4) colors.add_theme_constant_override("h_separation", 8) colors.add_theme_constant_override("v_separation", 8) _chooser_content.add_child(colors) if _theme != null: for entry: Dictionary in _theme.speech_text_colors(): var color: Color = entry.get("color", Color.BLACK) var color_id: String = "#" + color.to_html(false) var swatch: Button = _make_button("") swatch.custom_minimum_size = Vector2(64.0, 64.0) swatch.tooltip_text = String(entry.get("name", "Color")) _configure_choice(swatch, &"color", color_id) colors.add_child(swatch) else: var has_message: bool = not String(_context.get("text", "")).strip_edges().is_empty() if not has_message: var hint: Label = Label.new() hint.text = "Add a message before choosing an effect." hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _chooser_content.add_child(hint) var effects: HFlowContainer = HFlowContainer.new() _chooser_content.add_child(effects) var effect_labels: Dictionary = {"shake": "〰 Shake", "wave": "🌊 Wave", "bounce": "⬆ Bounce", "none": "○ No effect"} for effect_id: String in ["shake", "wave", "bounce", "none"]: var effect_choice: Button = _make_button(String(effect_labels[effect_id])) effect_choice.custom_minimum_size = Vector2(136.0, 60.0) effect_choice.add_theme_font_size_override("font_size", 20) effect_choice.disabled = not has_message _configure_choice(effect_choice, &"effect", effect_id) effects.add_child(effect_choice) if relayout: _layout_panels() func _configure_choice(button: Button, kind: StringName, value: String) -> void: button.focus_mode = Control.FOCUS_NONE button.custom_minimum_size.y = maxf(button.custom_minimum_size.y, _hit_height()) button.pressed.connect(_on_choice_pressed.bind(kind, value)) _choice_buttons[String(kind) + ":" + value] = {"button": button, "kind": kind, "value": value} _refresh_choice_state(button, kind, value) func _on_choice_pressed(kind: StringName, value: String) -> void: if _busy: return if kind == &"effect" and String(_context.get("text", "")).strip_edges().is_empty(): return choice_selected.emit(kind, value) func _refresh_choice_state(button: Button, kind: StringName, value: String) -> void: var selected: String = "" match kind: &"frame": selected = String(_context.get("bubble_style", "talk")) &"font": selected = String(_context.get("font_preset", "comic")) &"size": selected = String(_context.get("size_preset", "medium")) &"color": selected = String(_context.get("color", "#202020")).to_lower() &"effect": selected = String(_context.get("effect", "none")) var chosen: bool = selected.to_lower() == value.to_lower() var base_color: String = value if kind == &"color" else ("#176B83" if chosen else "#344255") var has_message: bool = not String(_context.get("text", "")).strip_edges().is_empty() button.disabled = _busy or (kind == &"effect" and not has_message) button.add_theme_stylebox_override("normal", _choice_button_style(chosen, base_color, false)) button.add_theme_stylebox_override("hover", _choice_button_style(chosen, base_color, true)) button.add_theme_stylebox_override("pressed", _choice_button_style(chosen, base_color, true)) button.add_theme_stylebox_override("focus", _choice_button_style(chosen, base_color, false)) button.add_theme_color_override("font_color", Color("#FFFFFF")) func _refresh_choice_states() -> void: var target: Dictionary = _context.get("target", {}) if _context.get("target", {}) is Dictionary else {} _chooser_title.text = _chooser_title_for(_active_kind, String(target.get("text", ""))) for entry_value: Variant in _choice_buttons.values(): if not entry_value is Dictionary: continue var entry: Dictionary = entry_value as Dictionary var button: Button = entry.get("button") as Button if button == null or not is_instance_valid(button): continue _refresh_choice_state(button, StringName(entry.get("kind", "")), String(entry.get("value", ""))) func _chooser_title_for(kind: StringName, target_text: String) -> String: match kind: &"frame": return "Choose a frame" &"letters": return "Choose how the letters look" &"effect": return "Choose an effect for %s" % target_text if not target_text.is_empty() else "Choose an effect for the whole bubble" return "Choose a style" func _choice_button_style(chosen: bool, base_color: String, hovered: bool) -> StyleBoxFlat: var style: StyleBoxFlat = StyleBoxFlat.new() style.bg_color = Color(base_color) if hovered: style.bg_color = style.bg_color.lightened(0.12) style.border_color = _selection_color() if chosen else Color("#F8FAFC") style.set_border_width_all(5 if chosen else 2) style.set_corner_radius_all(8) style.set_content_margin_all(6) return style func _add_subheading(text: String) -> void: var label: Label = Label.new() label.text = text label.add_theme_font_size_override("font_size", 20) label.add_theme_color_override("font_color", _selection_color()) _chooser_content.add_child(label) func _layout_panels() -> void: if _bar == null or _stage_rect.size.x <= 0.0: return var choice_layout_changed: bool = _prepare_choice_layout() if choice_layout_changed and _chooser != null and _chooser.visible and not _active_kind.is_empty(): _build_choices(_active_kind, false) var max_width: float = _max_width() var gap: float = _gap() var stage_width: float = _stage_rect.size.x var stage_height: float = _stage_rect.size.y var bar_width: float = minf(max_width, stage_width) var left_space: float = maxf(0.0, _frame_rect.position.x - _stage_rect.position.x - gap) var right_space: float = maxf(0.0, _stage_rect.end.x - _frame_rect.end.x - gap) var side_space: float = maxf(left_space, right_space) var toolbar_side_layout: bool = side_space >= minf(max_width, 300.0) if toolbar_side_layout: bar_width = minf(max_width, side_space) var bar_height: float = _fit_bar_height(bar_width, stage_height) var bar_position: Vector2 if toolbar_side_layout: var toolbar_on_right: bool = right_space >= left_space bar_position.x = _frame_rect.end.x + gap if toolbar_on_right else _frame_rect.position.x - bar_width - gap bar_position.y = clampf(_frame_rect.get_center().y - bar_height * 0.5, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - bar_height)) else: var above_space: float = _frame_rect.position.y - _stage_rect.position.y - gap var below_space: float = _stage_rect.end.y - _frame_rect.end.y - gap var place_above: bool = above_space >= bar_height or above_space >= below_space bar_position.x = clampf(_frame_rect.get_center().x - bar_width * 0.5, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - bar_width)) bar_position.y = _frame_rect.position.y - bar_height - gap if place_above else _frame_rect.end.y + gap bar_position.x = clampf(bar_position.x, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - bar_width)) bar_position.y = clampf(bar_position.y, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - bar_height)) bar_position = _position_avoiding_badges(bar_position, Vector2(bar_width, bar_height), _frame_rect) _bar.position = bar_position _bar.size = Vector2(bar_width, bar_height) if _chooser == null or not _chooser.visible: return var below_toolbar: float = maxf(_stage_rect.position.y, _bar.position.y + _bar.size.y + gap) var use_right: bool = right_space >= left_space var chooser_side_space: float = right_space if use_right else left_space var side_layout: bool = chooser_side_space >= 180.0 and _stage_rect.end.y - below_toolbar >= 150.0 var chooser_width: float = minf(440.0, chooser_side_space) if side_layout else minf(440.0, _stage_rect.size.x) _chooser.custom_minimum_size = Vector2(chooser_width, 0.0) var content_height: float = _chooser_content.get_combined_minimum_size().y # ScrollContainer doesn't include its child's vertical minimum in the panel's # natural size. Seed it here so short choosers fit their content and long ones # can be capped below without collapsing the choice area to zero height. _chooser_scroll.custom_minimum_size.y = content_height _chooser.reset_size() _chooser.size.x = chooser_width var natural_height: float = maxf(1.0, _chooser.get_combined_minimum_size().y) var fixed_height: float = maxf(0.0, natural_height - content_height) var max_height: float = maxf(150.0, _stage_rect.size.y * 0.82) var chooser_y: float if side_layout: chooser_y = below_toolbar max_height = minf(max_height, maxf(1.0, _stage_rect.end.y - chooser_y)) else: var below_y: float = _frame_rect.end.y + gap var below_room: float = _stage_rect.end.y - below_y var above_y: float = _stage_rect.position.y var above_room: float = _bar.position.y - gap - above_y if below_room >= above_room: chooser_y = below_y max_height = minf(max_height, maxf(1.0, below_room)) else: chooser_y = above_y max_height = minf(max_height, maxf(1.0, above_room)) var chooser_height: float = minf(natural_height, max_height) if natural_height > max_height: _chooser_scroll.custom_minimum_size.y = maxf(64.0, max_height - fixed_height) _chooser.reset_size() _chooser.custom_minimum_size = Vector2(chooser_width, chooser_height) _chooser.size = Vector2(chooser_width, chooser_height) var chooser_x: float if side_layout: chooser_x = _frame_rect.end.x + gap if use_right else _frame_rect.position.x - gap - chooser_width else: chooser_x = clampf(_frame_rect.get_center().x - chooser_width * 0.5, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - chooser_width)) chooser_y = clampf(chooser_y, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - chooser_height)) var chooser_position: Vector2 = Vector2(chooser_x, chooser_y) var toolbar_obstruction: Array[Rect2] = [Rect2(_bar.position, _bar.size)] _chooser.position = _position_avoiding_badges(chooser_position, Vector2(chooser_width, chooser_height), _frame_rect, toolbar_obstruction) func _position_avoiding_badges(preferred: Vector2, size: Vector2, anchor: Rect2, extra_obstructions: Array[Rect2] = []) -> Vector2: var candidates: Array[Vector2] = [preferred] var gap: float = _gap() var center: Vector2 = anchor.get_center() candidates.append(Vector2(anchor.position.x - size.x - gap, center.y - size.y * 0.5)) candidates.append(Vector2(anchor.end.x + gap, center.y - size.y * 0.5)) candidates.append(Vector2(center.x - size.x * 0.5, anchor.position.y - size.y - gap)) candidates.append(Vector2(center.x - size.x * 0.5, anchor.end.y + gap)) candidates.append(Vector2(_stage_rect.position.x, _stage_rect.position.y)) candidates.append(Vector2(_stage_rect.end.x - size.x, _stage_rect.position.y)) candidates.append(Vector2(_stage_rect.position.x, _stage_rect.end.y - size.y)) candidates.append(Vector2(_stage_rect.end.x - size.x, _stage_rect.end.y - size.y)) candidates.append(_stage_rect.get_center() - size * 0.5) var best: Vector2 = _clamp_panel_position(preferred, size) var best_collisions: int = _badge_collision_count(best, size, extra_obstructions) var best_distance: float = best.distance_squared_to(preferred) for candidate: Vector2 in candidates: var clamped: Vector2 = _clamp_panel_position(candidate, size) var collisions: int = _badge_collision_count(clamped, size, extra_obstructions) var distance: float = clamped.distance_squared_to(preferred) if collisions < best_collisions or (collisions == best_collisions and distance < best_distance): best = clamped best_collisions = collisions best_distance = distance if best_collisions == 0 and is_zero_approx(best_distance): return best return best func _clamp_panel_position(candidate: Vector2, size: Vector2) -> Vector2: return Vector2( clampf(candidate.x, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - size.x)), clampf(candidate.y, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - size.y)) ) func _badge_collision_count(position: Vector2, size: Vector2, extra_obstructions: Array[Rect2]) -> int: var panel_rect: Rect2 = Rect2(position, size) var count: int = 0 for badge_rect: Rect2 in _badge_avoidance_rects: if panel_rect.intersects(badge_rect): count += 1 for obstruction: Rect2 in extra_obstructions: if panel_rect.intersects(obstruction): count += 1 return count func _fit_bar_height(bar_width: float, available_height: float) -> float: _bar.custom_minimum_size = Vector2(bar_width, 0.0) _bar_scroll.custom_minimum_size.y = 0.0 _bar.reset_size() var fixed_height: float = _bar.get_combined_minimum_size().y var content_height: float = _bar_content.get_combined_minimum_size().y var max_height: float = maxf(1.0, available_height) var body_room: float = maxf(48.0, max_height - fixed_height) _bar_scroll.custom_minimum_size.y = minf(content_height, body_room) _bar.reset_size() return minf(maxf(54.0, _bar.get_combined_minimum_size().y), max_height) func _prepare_choice_layout() -> bool: var previous_compact: bool = _compact_choices var previous_width: float = _choice_width var gap: float = _gap() var left_space: float = maxf(0.0, _frame_rect.position.x - _stage_rect.position.x - gap) var right_space: float = maxf(0.0, _stage_rect.end.x - _frame_rect.end.x - gap) var side_space: float = maxf(left_space, right_space) var can_place_to_side: bool = side_space >= 180.0 _choice_width = minf(440.0, side_space) if can_place_to_side else minf(440.0, _stage_rect.size.x) _compact_choices = can_place_to_side and _choice_width < 320.0 return previous_compact != _compact_choices or not is_equal_approx(previous_width, _choice_width) func _apply_theme_styles() -> void: if _bar == null or _chooser == null: return _bar.add_theme_stylebox_override("panel", _panel_style()) _chooser.add_theme_stylebox_override("panel", _panel_style()) _bar_ribbon.add_theme_stylebox_override("panel", _ribbon_style(_ribbon_color())) _chooser_ribbon.add_theme_stylebox_override("panel", _ribbon_style(_ribbon_color())) _sync_category_buttons() var action_normal: StyleBoxFlat = _choice_button_style(false, "#344255", false) var action_hover: StyleBoxFlat = _choice_button_style(false, "#40546D", true) for action_button: Button in [_done_styling_button]: if action_button == null: continue action_button.add_theme_stylebox_override("normal", action_normal) action_button.add_theme_stylebox_override("hover", action_hover) action_button.add_theme_stylebox_override("pressed", action_normal) action_button.add_theme_stylebox_override("focus", action_normal) action_button.add_theme_color_override("font_color", Color("#FFFFFF")) _apply_category_button_style(_message_button, _message_dialog_open) if _theme != null: var gap: int = int(_theme.speech_toolbar_number("gap", 8.0, 4.0, 20.0)) _speech_flow.add_theme_constant_override("h_separation", gap) _speech_flow.add_theme_constant_override("v_separation", gap) _sync_sequence_controls() func _panel_style() -> StyleBoxFlat: var style: StyleBoxFlat = StyleBoxFlat.new() style.bg_color = _theme.color("speech.toolbar.surface", Color("#1C2431F7")) if _theme != null else Color("#1C2431F7") style.border_color = _border_color() style.set_border_width_all(2) style.set_corner_radius_all(12) style.set_content_margin_all(8) return style func _ribbon_style(color: Color) -> StyleBoxFlat: var style: StyleBoxFlat = StyleBoxFlat.new() style.bg_color = color style.set_corner_radius_all(7) style.set_content_margin(SIDE_LEFT, 10.0) style.set_content_margin(SIDE_TOP, 4.0) style.set_content_margin(SIDE_RIGHT, 10.0) style.set_content_margin(SIDE_BOTTOM, 4.0) return style func _ribbon_color() -> Color: return _theme.color("speech.toolbar.ribbon", Color("#2385A0")) if _theme != null else Color("#2385A0") func _make_button(caption: String) -> Button: var button: Button = Button.new() button.text = caption button.custom_minimum_size.y = _hit_height() button.focus_mode = Control.FOCUS_NONE button.add_theme_font_size_override("font_size", 18) return button func _hit_height() -> float: return _theme.speech_toolbar_number("min_hit_height", 56.0, 44.0, 80.0) if _theme != null else 56.0 func _max_width() -> float: return _theme.speech_toolbar_number("max_width", 560.0, 300.0, 720.0) if _theme != null else 560.0 func _gap() -> float: return _theme.speech_toolbar_number("gap", 10.0, 4.0, 24.0) if _theme != null else 10.0 func _border_color() -> Color: return _theme.color("speech.toolbar.border", Color("#9EADBF")) if _theme != null else Color("#9EADBF") func _selection_color() -> Color: return _theme.color("speech.toolbar.selection_halo", Color("#FFD84D")) if _theme != null else Color("#FFD84D") func _frame_color(frame_id: String, channel: String, fallback: Color) -> Color: return _theme.speech_color(frame_id, channel, fallback) if _theme != null else fallback func _size_font_size(size_id: String) -> int: match size_id: "small": return 20 "huge": return 40 _: return 28