Files
stickman/scripts/speech/speech_style_toolbar.gd
T

850 lines
36 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class_name SpeechStyleToolbar
extends Control
## Transient stage-side speech styling controls. The composer owns every edit.
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
class FramePreview extends Control:
const RENDERER := preload("res://scripts/speech/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