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:
@@ -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
|
||||
Reference in New Issue
Block a user