Implement speech style toolbar live-edit refinements and whole speech show controls

- Added a new specification for the speech style toolbar live-edit, detailing immediate style application, scoped effect demonstration, and hover feedback.
- Introduced refinements to the speech style toolbar, including improved chooser sizing, exclusive stage editing during speech composition, and removal of redundant controls.
- Documented new stage controls for managing how the whole speech shows, including options for bubble arrangement, word appearance, and bubble motion.
- Created a new log file for speech padding syntax to track engine versioning.
This commit is contained in:
2026-10-03 14:49:22 -04:00
parent 6a1359f244
commit 6666173c4f
42 changed files with 9163 additions and 165 deletions
+849
View File
@@ -0,0 +1,849 @@
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