class_name SpeechComposer extends Control ## Stage-side comic speech editor. The stage owns the attached preview. const SPEECH_PAYLOAD := preload("res://scripts/speech_payload.gd") const SANDBOX_THEME := preload("res://scripts/sandbox_theme.gd") const SPEECH_BUBBLE := preload("res://scripts/stickman_speech_bubble.gd") const FIVE_BUBBLE_HINT: String = "Long story? One at a time can be easier to follow." const MAX_BUBBLE_HINT: String = "You can add up to eight bubbles." enum PreviewState { STATIC, PLAYING, BLANK_HOLD } const BLANK_HOLD_SECONDS: float = 1.0 signal draft_changed(action: Dictionary) signal placement_requested() signal placement_reset_requested() signal replay_requested() signal preview_state_changed(state: String, elapsed: float, visible: bool) signal committed(action: Dictionary) signal cancelled() signal validation_failed(message: String, bubble_index: int) signal bubble_placement_requested(index: int) signal bubble_placement_reset_requested(index: int) signal style_context_changed(context: Dictionary) signal message_dialog_closed() signal stage_focus_changed(bubble_index: int) var _actor: StickmanRig = null var _draft: Dictionary = {} var _reactive_options: bool = false var _selected_bubble: int = 0 var _stage_focus_bubble: int = -1 var _target: Dictionary = {} var _style_category: StringName = &"" var _effect_demo: Dictionary = {} var _effect_demo_elapsed: float = 0.0 var _effect_demo_remaining: float = 0.0 var _caret_by_bubble: Array[Vector2i] = [] var _scroll_by_bubble: Array[float] = [] var _last_text_by_bubble: Array[String] = [] var _undo_removed_bubble: Dictionary = {} var _initial_action: Dictionary = {} var _cancel_requires_confirmation: bool = false var _cancel_confirm_dialog: ConfirmationDialog var _sequence_hint: String = "" var _five_bubble_hint_shown: bool = false var _active_editor: TextEdit var _emoji_grid: GridContainer var _emoji_picker_scroll: ScrollContainer var _emoji_picker_button: Button var _message_dialog: PanelContainer var _message_done_button: Button var _message_frame_rect: Rect2 = Rect2() var _message_stage_rect: Rect2 = Rect2() var _message_toolbar_rect: Rect2 = Rect2() var _overflow_label: Label var _loading_editor: bool = false var _replay_clock: float = 0.0 var _replay_running: bool = false var _preview_state: PreviewState = PreviewState.STATIC var _blank_hold_clock: float = 0.0 var _theme: SandboxTheme var _placement_target_bubble: int = -1 func _ready() -> void: set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) mouse_filter = Control.MOUSE_FILTER_IGNORE _theme = SANDBOX_THEME.new() _theme.load_from() theme = _theme.control_theme() visible = false _build_ui() resized.connect(_layout_message_dialog) resized.connect(_update_message_responsive_layout) func _process(delta: float) -> void: if _effect_demo_remaining > 0.0: _effect_demo_elapsed += delta _effect_demo_remaining = maxf(0.0, _effect_demo_remaining - delta) if _effect_demo_remaining <= 0.0: _stop_editor_effect_demo() if _preview_state == PreviewState.PLAYING: _replay_clock += delta if _replay_clock >= float(_draft.get("duration", 0.0)): _replay_clock = float(_draft.get("duration", _replay_clock)) _preview_state = PreviewState.BLANK_HOLD _blank_hold_clock = 0.0 _replay_running = false _emit_preview_state() elif _preview_state == PreviewState.BLANK_HOLD: _blank_hold_clock += delta if _blank_hold_clock >= BLANK_HOLD_SECONDS: _preview_state = PreviewState.STATIC _replay_clock = float(_draft.get("duration", 0.0)) _emit_preview_state() func open_for(actor: StickmanRig, initial: Dictionary, reactive_options: bool) -> void: _stop_editor_effect_demo() _style_category = &"" _actor = actor _reactive_options = reactive_options var initial_action: Dictionary = initial.duplicate(true) if not initial_action.has("keep_acting"): initial_action["keep_acting"] = reactive_options and not bool(initial_action.get("stop_current", false)) _draft = SPEECH_PAYLOAD.normalize(initial_action) _draft.erase("legacy_fixed") if not _draft.has("bubbles") or not _draft["bubbles"] is Array: _draft["bubbles"] = [] if (_draft["bubbles"] as Array).is_empty(): (_draft["bubbles"] as Array).append(_new_bubble()) _selected_bubble = 0 _undo_removed_bubble.clear() _initial_action.clear() _cancel_requires_confirmation = false _sequence_hint = "" _five_bubble_hint_shown = false _update_bubble_count_hint() _set_stage_focus(-1) _target.clear() _prepare_editor_state() # Populate the editor before emitting preview_state_changed. The stage's # preview handler reads get_draft() synchronously, which saves editor text. _sync_editor() _sync_controls() visible = true _stop_replay_to_static() _emit_draft(false) _initial_action = get_draft() func set_draft(action: Dictionary) -> void: _stop_editor_effect_demo() _style_category = &"" _target.clear() _draft = SPEECH_PAYLOAD.normalize(action) _draft.erase("legacy_fixed") var bubbles: Array = _draft.get("bubbles", []) _selected_bubble = clampi(_selected_bubble, 0, maxi(0, bubbles.size() - 1)) _update_bubble_count_hint() _undo_removed_bubble.clear() _cancel_requires_confirmation = true _set_stage_focus(-1) _target.clear() _prepare_editor_state() _sync_editor() _sync_controls() _emit_draft() _initial_action = get_draft() func get_draft() -> Dictionary: _save_active_editor_state() var result: Dictionary = _draft.duplicate(true) result.erase("legacy_fixed") result["type"] = "speak" var bubbles: Array = result.get("bubbles", []) result["text"] = String((bubbles[0] as Dictionary).get("text", "")) if not bubbles.is_empty() and bubbles[0] is Dictionary else "" result["duration"] = SPEECH_PAYLOAD.duration_for(result) return result func style_context() -> Dictionary: var bubbles: Array = _draft.get("bubbles", []) var bubble: Dictionary = _selected_bubble_data() var text: String = String(bubble.get("text", "")) var visual: Dictionary = bubble.get("visual", {}) var target: Dictionary = _target.duplicate(true) if not target.is_empty() and int(target.get("bubble_index", -1)) != _selected_bubble: target.clear() var token_style: Dictionary = _style_for_visual_target(visual, target) if not target.is_empty() else {} return { "bubble_index": _selected_bubble, "bubble_count": bubbles.size(), "can_move_earlier": _selected_bubble > 0, "can_move_later": _selected_bubble >= 0 and _selected_bubble < bubbles.size() - 1, "max_bubbles": SPEECH_PAYLOAD.MAX_BUBBLES, "can_undo_remove": not _undo_removed_bubble.is_empty(), "sequence_hint": _sequence_hint, "target": target, "bubble_style": String(visual.get("bubble_style", "talk")), "font_preset": String(token_style.get("font_preset", visual.get("font_preset", SPEECH_PAYLOAD.BASE_FONT_PRESET))), "size_preset": String(token_style.get("size_preset", visual.get("size_preset", "medium"))), "color": String(token_style.get("color", visual.get("color", SPEECH_PAYLOAD.DEFAULT_TEXT_COLOR))), "effect": String(token_style.get("effect", visual.get("effect", "none"))), "duration_mode": String(_draft.get("duration_mode", "just_enough")), "text": text, "category": String(_style_category), } func set_style_category(kind: StringName) -> void: if not [&"frame", &"letters", &"effect"].has(kind): return _style_category = kind _emit_style_context() func apply_style_choice(kind: StringName, value: String) -> void: if not _style_category_accepts(kind) or not _style_choice_valid(kind, value): return if kind == &"effect" and String(style_context().get("text", "")).strip_edges().is_empty(): return _save_active_editor_state() if kind == &"timing": _draft["duration_mode"] = value _emit_draft() _sync_controls() _emit_style_context() return var bubble: Dictionary = _selected_bubble_data() var visual: Dictionary = bubble.get("visual", {}).duplicate(true) _apply_visual_choice(visual, kind, value, _target) bubble["visual"] = visual _store_selected_bubble(bubble) if kind == &"effect": if value == "none": _stop_editor_effect_demo() else: _start_editor_effect_demo(value, _target) elif kind in [&"font", &"size"] and not _effect_demo.is_empty(): _stop_editor_effect_demo() _emit_draft() _sync_controls() _emit_style_context() func get_preview_action() -> Dictionary: return get_draft() func editor_effect_demo_context() -> Dictionary: if _effect_demo.is_empty() or _effect_demo_remaining <= 0.0: return {} var result: Dictionary = _effect_demo.duplicate(true) result["elapsed"] = _effect_demo_elapsed return result func select_bubble_token_from_stage(index: int, start: int, end: int) -> void: var bubbles: Array = _draft.get("bubbles", []) if index < 0 or index >= bubbles.size() or not bubbles[index] is Dictionary: return var text: String = String((bubbles[index] as Dictionary).get("text", "")) if SPEECH_PAYLOAD.token_span_at(text, start) != Vector2i(start, end): return var next_target: Dictionary = {"bubble_index": index, "start": start, "end": end, "text": text.substr(start, end - start)} if _target != next_target: _stop_editor_effect_demo() if index != _selected_bubble: select_bubble_from_stage(index) else: _set_stage_focus(index) _target = next_target _sync_controls() _emit_style_context() func select_bubble_word_from_stage(index: int, start: int, end: int) -> void: select_bubble_token_from_stage(index, start, end) func _emit_style_context() -> void: style_context_changed.emit(style_context()) func _style_category_accepts(kind: StringName) -> bool: var category: String = String(_style_category) if category == "frame": return kind == &"frame" if category == "effect": return kind == &"effect" return category == "letters" and kind in [&"font", &"size", &"color"] func _style_choice_valid(kind: StringName, value: String) -> bool: match kind: &"frame": return SPEECH_PAYLOAD.BUBBLE_STYLES.has(value) &"font": return SPEECH_PAYLOAD.FONT_PRESETS.has(value) &"size": return SPEECH_PAYLOAD.SIZE_PRESETS.has(value) &"color": return SPEECH_PAYLOAD._valid_color(value) &"effect": return value == "none" or SPEECH_PAYLOAD.EFFECTS.has(value) return false func _start_editor_effect_demo(effect: String, target: Dictionary) -> void: _stop_editor_effect_demo() if not [&"shake", &"wave", &"bounce"].has(effect): return _effect_demo = { "bubble_index": int(_selected_bubble), "effect": effect, } if target.is_empty(): _effect_demo["all_tokens"] = true else: _effect_demo["start"] = int(target.get("start", -1)) _effect_demo["end"] = int(target.get("end", -1)) _effect_demo_elapsed = 0.0 _effect_demo_remaining = 1.0 func _stop_editor_effect_demo() -> void: _effect_demo.clear() _effect_demo_elapsed = 0.0 _effect_demo_remaining = 0.0 func _apply_visual_choice(visual: Dictionary, kind: StringName, value: String, target: Dictionary) -> void: if kind == &"frame": visual["bubble_style"] = value return if kind == &"effect" and target.is_empty(): visual["effect"] = value _clear_word_style_property(visual, "effect") return var property: String = String(kind) + ("_preset" if kind in [&"font", &"size"] else "") if target.is_empty(): match kind: &"font": visual["font_preset"] = value &"size": visual["size_preset"] = value &"color": visual["color"] = value _clear_word_style_property(visual, property) return var text_style_start: int = int(target.get("start", -1)) var styles: Array = visual.get("word_styles", []) var style_index: int = -1 for index: int in styles.size(): if styles[index] is Dictionary and int((styles[index] as Dictionary).get("start", -2)) == text_style_start: style_index = index break var style: Dictionary = (styles[style_index] as Dictionary).duplicate(true) if style_index >= 0 else {"start": text_style_start, "end": int(target.get("end", -1))} if kind == &"effect" and value == "none" and String(visual.get("effect", "none")) == "none": style.erase("effect") else: style[property] = value if style.size() <= 2: if style_index >= 0: styles.remove_at(style_index) else: if style_index >= 0: styles[style_index] = style else: styles.append(style) visual["word_styles"] = styles func _clear_word_style_property(visual: Dictionary, property: String) -> void: var retained: Array[Dictionary] = [] for raw_style: Variant in visual.get("word_styles", []): if not raw_style is Dictionary: continue var style: Dictionary = (raw_style as Dictionary).duplicate(true) style.erase(property) if style.size() > 2: retained.append(style) visual["word_styles"] = retained func _style_for_visual_target(visual: Dictionary, target: Dictionary) -> Dictionary: var target_start: int = int(target.get("start", -1)) for raw: Variant in visual.get("word_styles", []): if raw is Dictionary and int((raw as Dictionary).get("start", -2)) == target_start: return raw as Dictionary return {} func selected_bubble_index() -> int: return _selected_bubble func reactive_options_enabled() -> bool: return _reactive_options func stage_focus_bubble_index() -> int: return _stage_focus_bubble func clear_stage_focus() -> void: var focus_changed: bool = _stage_focus_bubble >= 0 if not focus_changed and _target.is_empty(): return _stage_focus_bubble = -1 _style_category = &"" _target.clear() _sync_controls() _emit_style_context() if focus_changed: stage_focus_changed.emit(-1) func _set_stage_focus(index: int) -> void: if _stage_focus_bubble == index: return _stage_focus_bubble = index stage_focus_changed.emit(index) func choose_speech_show(key: StringName, value: Variant) -> void: _save_active_editor_state() var normalized: Dictionary = SPEECH_PAYLOAD.normalize(_draft) var valid: bool = false match key: &"arrangement": valid = value is String and SPEECH_PAYLOAD.ARRANGEMENTS.has(String(value)) &"connect_bubbles": valid = value is bool and (_draft.get("bubbles", []) as Array).size() >= 2 &"reveal": valid = value is String and SPEECH_PAYLOAD.REVEAL_MODES.has(String(value)) &"pop_in": valid = value is bool &"duration_mode": valid = value is String and SPEECH_PAYLOAD.DURATION_MODES.has(String(value)) &"keep_acting": valid = value is bool if not valid: return normalized[String(key)] = value if key == &"keep_acting" and _reactive_options: normalized["stop_current"] = not bool(value) var bubbles: Array = normalized.get("bubbles", []) if key in [&"reveal", &"pop_in"]: for index: int in bubbles.size(): if not bubbles[index] is Dictionary: continue var bubble: Dictionary = bubbles[index] as Dictionary var visual: Dictionary = bubble.get("visual", {}).duplicate(true) visual[String(key)] = value bubble["visual"] = visual bubbles[index] = bubble _draft = normalized _draft["bubbles"] = bubbles else: _draft = normalized _emit_draft() _sync_controls() func choose_comic_chain(centers: Array) -> void: # Compatibility entry point for callers from the earlier Comic chain UI. apply_bubble_layout(centers, "stack") choose_speech_show(&"arrangement", "take_turns") choose_speech_show(&"connect_bubbles", true) func set_selected_bubble_index(index: int) -> void: var bubbles: Array = _draft.get("bubbles", []) if bubbles.is_empty(): return var next_index: int = clampi(index, 0, bubbles.size() - 1) if next_index == _selected_bubble: return _stop_editor_effect_demo() _save_active_editor_state() _selected_bubble = next_index _set_stage_focus(next_index) _target.clear() _sync_editor() _sync_controls() _emit_style_context() func select_bubble_from_stage(index: int) -> void: var bubbles: Array = _draft.get("bubbles", []) if index < 0 or index >= bubbles.size(): return _stop_editor_effect_demo() if index != _selected_bubble: _save_active_editor_state() _selected_bubble = index _target.clear() _sync_editor() else: _target.clear() _set_stage_focus(index) _sync_controls() _emit_style_context() func preview_state_name() -> String: match _preview_state: PreviewState.PLAYING: return "playing" PreviewState.BLANK_HOLD: return "blank_hold" _: return "static" func preview_visible() -> bool: return _preview_state != PreviewState.BLANK_HOLD func set_preview_overflow(has_overflow: bool, _arrangement: String, bubble_layout: String = "stack") -> void: if _overflow_label == null: return if not has_overflow: _overflow_label.text = "" elif bubble_layout == "stack": var bubbles: Array = _draft.get("bubbles", []) _overflow_label.text = "This tall stack is hard to fit here. Try One at a time or move the bubbles." if bubbles.size() >= 4 else "This stack is tight in the preview. Try One at a time or move it." else: _overflow_label.text = "This frame is hard to fit here. Try a smaller size or move the bubble." func preview_elapsed() -> float: if _preview_state == PreviewState.PLAYING: return _replay_clock if _preview_state == PreviewState.BLANK_HOLD: return float(_draft.get("duration", 0.0)) return float(_draft.get("duration", 0.0)) func contains_viewport_point(point: Vector2) -> bool: if _cancel_confirm_dialog != null and _cancel_confirm_dialog.visible: return true if is_message_dialog_open(): return true return false func panel_viewport_rect() -> Rect2: return Rect2() func clear_token_target() -> void: _stop_editor_effect_demo() _clear_word_target() func set_placement_preview(local_center: Vector2) -> void: if not local_center.is_finite(): return _stop_editor_effect_demo() _draft["placement"] = {"mode": "manual", "center": local_center} _emit_draft() func set_bubble_placement_preview(index: int, local_center: Vector2) -> void: if not local_center.is_finite(): return var bubbles: Array = _draft.get("bubbles", []) if index < 0 or index >= bubbles.size() or not bubbles[index] is Dictionary: return _stop_editor_effect_demo() var bubble: Dictionary = bubbles[index] bubble["placement"] = {"mode": "manual", "center": local_center} bubbles[index] = bubble _draft["bubbles"] = bubbles _emit_draft() _sync_controls() func apply_bubble_layout(centers: Array, preset: String = "") -> void: _save_active_editor_state() var bubbles: Array = _draft.get("bubbles", []) if centers.size() != bubbles.size(): return if not preset.is_empty() and not SPEECH_PAYLOAD.BUBBLE_LAYOUT_MODES.has(preset): return for center_value: Variant in centers: if not center_value is Vector2 or not (center_value as Vector2).is_finite(): return for index: int in bubbles.size(): if not bubbles[index] is Dictionary: return var bubble: Dictionary = bubbles[index] bubble["placement"] = {"mode": "manual", "center": centers[index]} bubbles[index] = bubble if not preset.is_empty(): _draft["bubble_layout"] = preset if preset == "same_spot": _draft["connect_bubbles"] = false _draft["bubbles"] = bubbles _emit_draft() _sync_controls() func reset_placement() -> void: _stop_editor_effect_demo() _draft["placement"] = {"mode": "auto"} set_placement_pending(false) placement_reset_requested.emit() _emit_draft() func reset_bubble_placement(index: int) -> void: var bubbles: Array = _draft.get("bubbles", []) if index < 0 or index >= bubbles.size() or not bubbles[index] is Dictionary: return _stop_editor_effect_demo() var bubble: Dictionary = bubbles[index] bubble.erase("placement") bubbles[index] = bubble _draft["bubbles"] = bubbles set_placement_pending(false) bubble_placement_reset_requested.emit(index) _emit_draft() _sync_controls() func set_placement_pending(pending: bool, bubble_index: int = -1) -> void: _placement_target_bubble = bubble_index if pending else -1 func close_without_commit() -> void: _stop_editor_effect_demo() _style_category = &"" _stage_focus_bubble = -1 if _message_dialog != null: _message_dialog.hide() if _cancel_confirm_dialog != null: _cancel_confirm_dialog.hide() visible = false _replay_running = false _actor = null _undo_removed_bubble.clear() _clear_word_target(false) func set_cancel_requires_confirmation(required: bool) -> void: _cancel_requires_confirmation = required func request_cancel_speech() -> void: var changed: bool = _cancel_requires_confirmation or get_draft() != _initial_action if changed and _cancel_confirm_dialog != null: _cancel_confirm_dialog.popup_centered() return cancelled.emit() func request_done_speech() -> void: var action: Dictionary = get_draft() var error: String = SPEECH_PAYLOAD.validate(action) if not error.is_empty(): var invalid_index: int = _first_invalid_bubble_index(action) if invalid_index >= 0: _select_bubble_for_message(invalid_index) validation_failed.emit(error, invalid_index) return committed.emit(action) func _first_invalid_bubble_index(action: Dictionary) -> int: var bubbles: Array = action.get("bubbles", []) for index: int in bubbles.size(): if not bubbles[index] is Dictionary: return index var text: String = String((bubbles[index] as Dictionary).get("text", "")) if text.strip_edges().is_empty() or text.length() > SPEECH_PAYLOAD.MAX_VISIBLE_CHARACTERS: return index return _selected_bubble if not bubbles.is_empty() else -1 func _select_bubble_for_message(index: int) -> void: var bubbles: Array = _draft.get("bubbles", []) if index < 0 or index >= bubbles.size(): return _save_active_editor_state() _selected_bubble = index _target.clear() _set_stage_focus(index) _sync_editor() _sync_controls() _emit_style_context() func open_message_dialog() -> void: if _draft.is_empty() or _message_dialog == null: return _sync_editor() _emoji_picker_scroll.hide() _emoji_picker_button.set_pressed_no_signal(false) _emoji_picker_button.text = "😀 Emoji" _layout_message_dialog() _message_dialog.show() var last_line: int = maxi(0, _active_editor.get_line_count() - 1) _active_editor.set_caret_line(last_line, true) _active_editor.set_caret_column(_active_editor.get_line(last_line).length(), true) _active_editor.grab_focus() func is_message_dialog_open() -> bool: return _message_dialog != null and _message_dialog.visible func close_message_dialog() -> void: if not is_message_dialog_open(): return _save_active_editor_state() _active_editor.release_focus() _message_dialog.hide() _emoji_picker_scroll.hide() message_dialog_closed.emit() func set_message_dialog_anchor(frame_rect: Rect2, stage_rect: Rect2, toolbar_rect: Rect2) -> void: _message_frame_rect = frame_rect _message_stage_rect = stage_rect _message_toolbar_rect = toolbar_rect _layout_message_dialog() func _build_ui() -> void: _active_editor = TextEdit.new() _active_editor.name = "ActiveBubbleText" _active_editor.custom_minimum_size = Vector2(0.0, 132.0) _active_editor.size_flags_horizontal = Control.SIZE_EXPAND_FILL _active_editor.placeholder_text = "Write what the character says…" _active_editor.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY _active_editor.text_changed.connect(_on_active_text_changed) _active_editor.caret_changed.connect(_remember_active_editor_state) _cancel_confirm_dialog = ConfirmationDialog.new() _cancel_confirm_dialog.title = "Discard speech changes?" _cancel_confirm_dialog.dialog_text = "Your speech edits will be lost. Do you want to discard them?" _cancel_confirm_dialog.get_ok_button().text = "Discard changes" _cancel_confirm_dialog.get_cancel_button().text = "Keep editing" _cancel_confirm_dialog.confirmed.connect(func() -> void: cancelled.emit()) _cancel_confirm_dialog.dialog_hide_on_ok = true add_child(_cancel_confirm_dialog) _build_message_dialog() func _build_message_dialog() -> void: _message_dialog = PanelContainer.new() _message_dialog.name = "MessageDialog" _message_dialog.mouse_filter = Control.MOUSE_FILTER_STOP _message_dialog.z_index = 100 _message_dialog.hide() _message_dialog.add_theme_stylebox_override("panel", _panel_style()) add_child(_message_dialog) var margin: MarginContainer = MarginContainer.new() margin.add_theme_constant_override("margin_left", 14) margin.add_theme_constant_override("margin_right", 14) margin.add_theme_constant_override("margin_top", 12) margin.add_theme_constant_override("margin_bottom", 12) _message_dialog.add_child(margin) var content: VBoxContainer = VBoxContainer.new() content.add_theme_constant_override("separation", 10) margin.add_child(content) var ribbon: PanelContainer = PanelContainer.new() ribbon.add_theme_stylebox_override("panel", _section_ribbon_style()) var title: Label = _section_label("Message") title.add_theme_font_size_override("font_size", 23) title.add_theme_color_override("font_color", Color.WHITE) ribbon.add_child(title) content.add_child(ribbon) _active_editor.custom_minimum_size.y = 150.0 _active_editor.size_flags_vertical = Control.SIZE_FILL content.add_child(_active_editor) _overflow_label = _section_label("") _overflow_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART content.add_child(_overflow_label) var emoji_row: HBoxContainer = HBoxContainer.new() emoji_row.alignment = BoxContainer.ALIGNMENT_CENTER _emoji_picker_button = _button("😀 Emoji") _emoji_picker_button.toggle_mode = true _emoji_picker_button.custom_minimum_size = Vector2(160.0, 56.0) _emoji_picker_button.toggled.connect(_on_emoji_picker_toggled) emoji_row.add_child(_emoji_picker_button) content.add_child(emoji_row) _emoji_picker_scroll = ScrollContainer.new() _emoji_picker_scroll.name = "MessageEmojiPickerScroll" _emoji_picker_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED _emoji_picker_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO _emoji_picker_scroll.custom_minimum_size = Vector2(0.0, 156.0) _emoji_picker_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL _emoji_picker_scroll.hide() content.add_child(_emoji_picker_scroll) _emoji_grid = GridContainer.new() _emoji_grid.name = "MessageEmojiGrid" _emoji_grid.columns = 4 _emoji_grid.add_theme_constant_override("h_separation", 7) _emoji_grid.add_theme_constant_override("v_separation", 7) _emoji_picker_scroll.add_child(_emoji_grid) for emoji: String in _theme.emoji_items(): var emoji_button: Button = _button(emoji) emoji_button.custom_minimum_size = Vector2(56.0, 52.0) emoji_button.add_theme_font_size_override("font_size", 26) emoji_button.pressed.connect(_insert_emoji.bind(emoji)) _emoji_grid.add_child(emoji_button) var done_editing_row: HBoxContainer = HBoxContainer.new() done_editing_row.alignment = BoxContainer.ALIGNMENT_CENTER _message_done_button = _button("✓ Done editing") _message_done_button.custom_minimum_size = Vector2(196.0, 56.0) _message_done_button.add_theme_font_size_override("font_size", 18) _message_done_button.pressed.connect(close_message_dialog) done_editing_row.add_child(_message_done_button) content.add_child(done_editing_row) _layout_message_dialog() func _on_emoji_picker_toggled(opened: bool) -> void: _emoji_picker_scroll.visible = opened _emoji_picker_button.text = "✕ Hide emoji" if opened else "😀 Emoji" _layout_message_dialog() func _layout_message_dialog() -> void: if _message_dialog == null or not is_instance_valid(_message_dialog): return var viewport_size: Vector2 = get_viewport_rect().size var stage_rect: Rect2 = _message_stage_rect if _message_stage_rect.size.x > 0.0 and _message_stage_rect.size.y > 0.0 else Rect2(Vector2(12.0, 12.0), viewport_size - Vector2(24.0, 24.0)) var frame_rect: Rect2 = _message_frame_rect var gap: float = 10.0 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 below_toolbar: float = maxf(stage_rect.position.y, _message_toolbar_rect.end.y + gap) var side_layout: bool = side_space >= 180.0 and stage_rect.end.y - below_toolbar >= 150.0 var dialog_width: float = minf(440.0, side_space) if side_layout else minf(440.0, stage_rect.size.x) var dialog_x: float var dialog_y: float if side_layout: dialog_x = frame_rect.end.x + gap if right_space >= left_space else frame_rect.position.x - gap - dialog_width dialog_y = below_toolbar else: dialog_x = clampf(frame_rect.get_center().x - dialog_width * 0.5, stage_rect.position.x, maxf(stage_rect.position.x, stage_rect.end.x - dialog_width)) if frame_rect.size.x > 0.0 else stage_rect.get_center().x - dialog_width * 0.5 var below_frame: float = frame_rect.end.y + gap dialog_y = below_frame if stage_rect.end.y - below_frame >= 230.0 else stage_rect.position.y var reserved_height: float = minf(550.0, stage_rect.size.y) var max_anchor_y: float = maxf(stage_rect.position.y, stage_rect.end.y - reserved_height) var stable_dialog_y: float = clampf(dialog_y, stage_rect.position.y, max_anchor_y) var available_height: float = maxf(1.0, stage_rect.end.y - stable_dialog_y) var desired_height: float = 550.0 if _emoji_picker_scroll.visible else 410.0 var dialog_height: float = minf(desired_height, available_height) if _emoji_picker_scroll.visible: _emoji_picker_scroll.custom_minimum_size.y = clampf(dialog_height - 410.0, 32.0, 156.0) _message_dialog.custom_minimum_size = Vector2(dialog_width, dialog_height) _message_dialog.size = Vector2(dialog_width, dialog_height) _message_dialog.position = Vector2(clampf(dialog_x, stage_rect.position.x, maxf(stage_rect.position.x, stage_rect.end.x - dialog_width)), stable_dialog_y) func _update_message_responsive_layout() -> void: if _emoji_grid != null and _message_dialog != null: var emoji_size: float = minf(56.0, maxf(40.0, (_message_dialog.size.x - 72.0) / 4.0)) for child: Node in _emoji_grid.get_children(): if child is Button: (child as Button).custom_minimum_size = Vector2(emoji_size, emoji_size) (child as Button).add_theme_font_size_override("font_size", int(minf(26.0, emoji_size * 0.48))) _layout_message_dialog() func _section_label(text: String) -> Label: var label: Label = Label.new() label.text = text label.add_theme_font_override("font", _theme.font()) label.add_theme_font_size_override("font_size", 18) label.size_flags_horizontal = Control.SIZE_EXPAND_FILL return label func _section_ribbon_style() -> StyleBoxFlat: var style: StyleBoxFlat = StyleBoxFlat.new() style.bg_color = Color("#176B83") style.set_corner_radius_all(7) style.set_content_margin(SIDE_LEFT, 9.0) style.set_content_margin(SIDE_TOP, 3.0) style.set_content_margin(SIDE_RIGHT, 9.0) style.set_content_margin(SIDE_BOTTOM, 3.0) return style func _panel_style() -> StyleBoxFlat: var style: StyleBoxFlat = StyleBoxFlat.new() style.bg_color = _theme.color("global.bg_color", Color("#171B24F5")) style.border_color = _theme.color("global.border_color", Color("#FFFFFF55")) style.set_border_width_all(2) style.set_corner_radius_all(12) return style func _button(text: String) -> Button: var button: Button = Button.new() button.text = text button.custom_minimum_size.y = _theme.speech_number("ui.button_min_height", 44.0, 40.0, 72.0) button.focus_mode = Control.FOCUS_NONE return button func _sample_button(sample: String, caption: String) -> Button: var button: Button = Button.new() button.text = sample + "\n" + caption button.custom_minimum_size = Vector2(82.0, 64.0) button.size_flags_horizontal = Control.SIZE_EXPAND_FILL button.focus_mode = Control.FOCUS_NONE return button func _new_bubble(visual_defaults: Dictionary = {}) -> Dictionary: var visual: Dictionary = { "bubble_style": "talk", "font_preset": SPEECH_PAYLOAD.BASE_FONT_PRESET, "size_preset": "medium", "color": SPEECH_PAYLOAD.DEFAULT_TEXT_COLOR, "effect": "none", "reveal": String(_draft.get("reveal", "instant")), "pop_in": bool(_draft.get("pop_in", false)), "word_styles": [], } for key: String in ["bubble_style", "font_preset", "size_preset", "color", "effect"]: if visual_defaults.has(key): visual[key] = visual_defaults[key] return {"text": "", "visual": visual} func _prepare_editor_state() -> void: var bubbles: Array = _draft.get("bubbles", []) while _caret_by_bubble.size() < bubbles.size(): _caret_by_bubble.append(Vector2i.ZERO) _scroll_by_bubble.append(0.0) _last_text_by_bubble.append(String((bubbles[_last_text_by_bubble.size()] as Dictionary).get("text", "")) if bubbles[_last_text_by_bubble.size()] is Dictionary else "") while _caret_by_bubble.size() > bubbles.size(): _caret_by_bubble.pop_back() _scroll_by_bubble.pop_back() _last_text_by_bubble.pop_back() for index: int in bubbles.size(): if bubbles[index] is Dictionary: _last_text_by_bubble[index] = String((bubbles[index] as Dictionary).get("text", "")) func _sync_editor() -> void: if _active_editor == null: return _prepare_editor_state() var bubbles: Array = _draft.get("bubbles", []) if bubbles.is_empty(): return var selected_text: String = String((bubbles[_selected_bubble] as Dictionary).get("text", "")) _loading_editor = true _active_editor.text = selected_text var caret: Vector2i = _caret_by_bubble[_selected_bubble] var line: int = clampi(caret.x, 0, maxi(0, _active_editor.get_line_count() - 1)) _active_editor.set_caret_line(line, false) _active_editor.set_caret_column(clampi(caret.y, 0, _active_editor.get_line(line).length()), false) _active_editor.scroll_vertical = _scroll_by_bubble[_selected_bubble] _loading_editor = false func _save_active_editor_state() -> void: if _active_editor == null or not is_instance_valid(_active_editor) or _loading_editor: return var bubbles: Array = _draft.get("bubbles", []) if _selected_bubble < 0 or _selected_bubble >= bubbles.size(): return var bubble: Dictionary = bubbles[_selected_bubble] bubble["text"] = _active_editor.text bubbles[_selected_bubble] = bubble _draft["bubbles"] = bubbles _last_text_by_bubble[_selected_bubble] = _active_editor.text _caret_by_bubble[_selected_bubble] = Vector2i(_active_editor.get_caret_line(), _active_editor.get_caret_column()) _scroll_by_bubble[_selected_bubble] = _active_editor.scroll_vertical func _remember_active_editor_state() -> void: if _loading_editor: return _save_active_editor_state() func _update_bubble_count_hint() -> void: var bubbles: Array = _draft.get("bubbles", []) if bubbles.size() >= SPEECH_PAYLOAD.MAX_BUBBLES: _sequence_hint = MAX_BUBBLE_HINT elif bubbles.size() >= 5: if not _five_bubble_hint_shown: _five_bubble_hint_shown = true _sequence_hint = FIVE_BUBBLE_HINT elif bubbles.size() < SPEECH_PAYLOAD.MAX_BUBBLES and _sequence_hint == MAX_BUBBLE_HINT: _sequence_hint = "" elif _sequence_hint == FIVE_BUBBLE_HINT or _sequence_hint == MAX_BUBBLE_HINT: _sequence_hint = "" func add_bubble_after_selected() -> void: _save_active_editor_state() var bubbles: Array = _draft.get("bubbles", []) if bubbles.size() >= SPEECH_PAYLOAD.MAX_BUBBLES: _sequence_hint = MAX_BUBBLE_HINT _emit_style_context() return _undo_removed_bubble.clear() var selected: Dictionary = _selected_bubble_data() var selected_visual: Dictionary = _new_bubble_visual_defaults(selected) var insertion_index: int = clampi(_selected_bubble + 1, 0, bubbles.size()) bubbles.insert(insertion_index, _new_bubble(selected_visual)) _draft["bubbles"] = bubbles _caret_by_bubble.insert(insertion_index, Vector2i.ZERO) _scroll_by_bubble.insert(insertion_index, 0.0) _last_text_by_bubble.insert(insertion_index, "") _prepare_editor_state() _selected_bubble = insertion_index _target.clear() _style_category = &"" _update_bubble_count_hint() _set_stage_focus(_selected_bubble) _sync_editor() _sync_controls() _emit_style_context() _emit_draft() call_deferred("open_message_dialog") func _new_bubble_visual_defaults(source_bubble: Dictionary) -> Dictionary: var visual: Dictionary = source_bubble.get("visual", {}) if source_bubble.get("visual", {}) is Dictionary else {} var defaults: Dictionary = visual.duplicate(true) var tokens: Array[Vector2i] = SPEECH_PAYLOAD.token_spans(String(source_bubble.get("text", ""))) if tokens.is_empty(): return defaults var last_token: Vector2i = tokens.back() var last_word: Vector2i = Vector2i(-1, -1) var word_regex := RegEx.new() if word_regex.compile(SPEECH_PAYLOAD.WORD_REGEX) == OK: for match_result: RegExMatch in word_regex.search_all(String(source_bubble.get("text", ""))): last_word = Vector2i(match_result.get_start(), match_result.get_end()) if last_word.x >= 0: last_token = last_word for raw_style: Variant in visual.get("word_styles", []): if not raw_style is Dictionary: continue var word_style: Dictionary = raw_style as Dictionary if int(word_style.get("start", -1)) != last_token.x or int(word_style.get("end", -1)) != last_token.y: continue for property: String in ["font_preset", "size_preset", "color", "effect"]: if word_style.has(property): defaults[property] = word_style[property] break return defaults func remove_selected_bubble() -> void: _save_active_editor_state() var bubbles: Array = _draft.get("bubbles", []) if bubbles.size() <= 1: return _undo_removed_bubble = { "index": _selected_bubble, "bubble": (bubbles[_selected_bubble] as Dictionary).duplicate(true), "caret": _caret_by_bubble[_selected_bubble], "scroll": _scroll_by_bubble[_selected_bubble], "text": _last_text_by_bubble[_selected_bubble], } bubbles.remove_at(_selected_bubble) _caret_by_bubble.remove_at(_selected_bubble) _scroll_by_bubble.remove_at(_selected_bubble) _last_text_by_bubble.remove_at(_selected_bubble) _draft["bubbles"] = bubbles _update_bubble_count_hint() _selected_bubble = mini(_selected_bubble, bubbles.size() - 1) _set_stage_focus(_selected_bubble) _target.clear() _sync_editor() _sync_controls() _emit_style_context() _emit_draft() func undo_remove_bubble() -> void: if _undo_removed_bubble.is_empty(): return var bubbles: Array = _draft.get("bubbles", []) var index: int = clampi(int(_undo_removed_bubble.get("index", bubbles.size())), 0, bubbles.size()) bubbles.insert(index, (_undo_removed_bubble.get("bubble", {}) as Dictionary).duplicate(true)) _caret_by_bubble.insert(index, _undo_removed_bubble.get("caret", Vector2i.ZERO)) _scroll_by_bubble.insert(index, float(_undo_removed_bubble.get("scroll", 0.0))) _last_text_by_bubble.insert(index, String(_undo_removed_bubble.get("text", ""))) _draft["bubbles"] = bubbles _update_bubble_count_hint() _selected_bubble = index _undo_removed_bubble.clear() _target.clear() _set_stage_focus(index) _sync_editor() _sync_controls() _emit_style_context() _emit_draft() func move_selected_bubble(offset: int) -> void: _save_active_editor_state() var bubbles: Array = _draft.get("bubbles", []) var destination: int = _selected_bubble + offset if destination < 0 or destination >= bubbles.size(): return _undo_removed_bubble.clear() var bubble: Variant = bubbles.pop_at(_selected_bubble) bubbles.insert(destination, bubble) var caret: Vector2i = _caret_by_bubble.pop_at(_selected_bubble) _caret_by_bubble.insert(destination, caret) var scroll: float = _scroll_by_bubble.pop_at(_selected_bubble) _scroll_by_bubble.insert(destination, scroll) var text: String = _last_text_by_bubble.pop_at(_selected_bubble) _last_text_by_bubble.insert(destination, text) _selected_bubble = destination _set_stage_focus(destination) _draft["bubbles"] = bubbles _update_bubble_count_hint() _target.clear() _sync_editor() _sync_controls() _emit_style_context() _emit_draft() func _on_active_text_changed() -> void: if _loading_editor: return _stop_editor_effect_demo() var bubbles: Array = _draft.get("bubbles", []) if _selected_bubble < 0 or _selected_bubble >= bubbles.size(): return var bubble: Dictionary = bubbles[_selected_bubble] var old_text: String = _last_text_by_bubble[_selected_bubble] var new_text: String = _active_editor.text _adjust_word_styles(bubble, old_text, new_text) bubble["text"] = new_text bubbles[_selected_bubble] = bubble _draft["bubbles"] = bubbles _last_text_by_bubble[_selected_bubble] = new_text _caret_by_bubble[_selected_bubble] = Vector2i(_active_editor.get_caret_line(), _active_editor.get_caret_column()) _scroll_by_bubble[_selected_bubble] = _active_editor.scroll_vertical if not _target.is_empty() and int(_target.get("bubble_index", -1)) == _selected_bubble: _clear_word_target(false) _emit_style_context() _emit_draft() func _adjust_word_styles(bubble: Dictionary, old_text: String, new_text: String) -> void: var visual: Dictionary = bubble.get("visual", {}) var styles: Array = visual.get("word_styles", []) var prefix: int = 0 while prefix < old_text.length() and prefix < new_text.length() and old_text[prefix] == new_text[prefix]: prefix += 1 var old_suffix: int = old_text.length() var new_suffix: int = new_text.length() while old_suffix > prefix and new_suffix > prefix and old_text[old_suffix - 1] == new_text[new_suffix - 1]: old_suffix -= 1 new_suffix -= 1 var shift: int = new_suffix - old_suffix var source_styles: Array[Dictionary] = [] for raw: Variant in styles: if raw is Dictionary: source_styles.append((raw as Dictionary).duplicate(true)) source_styles.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a.get("start", -1)) < int(b.get("start", -1))) var updated: Array[Dictionary] = [] var new_tokens: Array[Vector2i] = SPEECH_PAYLOAD.token_spans(new_text) for style: Dictionary in source_styles: var start: int = int(style.get("start", -1)) var end: int = int(style.get("end", -1)) var mapped_start: int = start var mapped_end: int = end if end <= prefix: pass elif start >= old_suffix: mapped_start += shift mapped_end += shift else: mapped_start = start if start < prefix else prefix mapped_end = end + shift if end > old_suffix else new_suffix if mapped_end <= mapped_start: continue for token: Vector2i in new_tokens: if token.x >= mapped_end or token.y <= mapped_start: continue _append_style_for_token(updated, token, style) if old_suffix == prefix and new_suffix > prefix: var inherited_style: Dictionary = _style_for_inserted_text(old_text, styles, prefix) if not inherited_style.is_empty(): for token: Vector2i in new_tokens: if token.x >= new_suffix or token.y <= prefix or _has_style_for_token(updated, token): continue _append_style_for_token(updated, token, inherited_style) visual["word_styles"] = updated bubble["visual"] = visual func _append_style_for_token(styles: Array[Dictionary], token: Vector2i, source: Dictionary) -> void: if _has_style_for_token(styles, token): return var copied: Dictionary = {"start": token.x, "end": token.y} for property: String in ["font_preset", "size_preset", "color", "effect"]: if source.has(property): copied[property] = source[property] if copied.size() > 2: styles.append(copied) func _has_style_for_token(styles: Array[Dictionary], token: Vector2i) -> bool: for style: Dictionary in styles: if int(style.get("start", -1)) == token.x and int(style.get("end", -1)) == token.y: return true return false func _style_for_inserted_text(old_text: String, styles: Array, insertion_offset: int) -> Dictionary: var tokens: Array[Vector2i] = SPEECH_PAYLOAD.token_spans(old_text) var previous: Vector2i = Vector2i(-1, -1) var following: Vector2i = Vector2i(-1, -1) for span: Vector2i in tokens: if span.x < insertion_offset and insertion_offset < span.y: previous = span break if span.y <= insertion_offset: previous = span elif span.x >= insertion_offset and following.x < 0: following = span var source_span: Vector2i = previous if previous.x >= 0 else following if source_span.x < 0: return {} for raw: Variant in styles: if raw is Dictionary and int((raw as Dictionary).get("start", -1)) == source_span.x and int((raw as Dictionary).get("end", -1)) == source_span.y: return (raw as Dictionary).duplicate(true) return {} func _insert_emoji(emoji: String) -> void: if _active_editor == null: return _active_editor.grab_focus() _active_editor.insert_text_at_caret(emoji) func _on_replay_pressed() -> void: replay_preview_once() func replay_preview_once() -> void: _stop_editor_effect_demo() _replay_clock = 0.0 _blank_hold_clock = 0.0 _replay_running = true _preview_state = PreviewState.PLAYING _emit_preview_state() replay_requested.emit() func _sync_controls() -> void: if _draft.is_empty() or not is_instance_valid(_active_editor): return func _selected_bubble_data() -> Dictionary: var bubbles: Array = _draft.get("bubbles", []) return bubbles[_selected_bubble] if _selected_bubble >= 0 and _selected_bubble < bubbles.size() and bubbles[_selected_bubble] is Dictionary else _new_bubble() func _store_selected_bubble(bubble: Dictionary) -> void: var bubbles: Array = _draft.get("bubbles", []) if _selected_bubble >= 0 and _selected_bubble < bubbles.size(): bubbles[_selected_bubble] = bubble _draft["bubbles"] = bubbles func _clear_word_target(update_controls: bool = true) -> void: _target.clear() if update_controls and _active_editor != null and is_instance_valid(_active_editor): _sync_controls() _emit_style_context() func _stop_replay_to_static() -> void: _replay_running = false _preview_state = PreviewState.STATIC _blank_hold_clock = 0.0 _replay_clock = SPEECH_PAYLOAD.duration_for(_draft) _emit_preview_state() func _emit_preview_state() -> void: preview_state_changed.emit(preview_state_name(), preview_elapsed(), preview_visible()) func _emit_draft(stop_replay: bool = true) -> void: if _draft.is_empty(): return _save_active_editor_state() var bubbles: Array = _draft.get("bubbles", []) _draft["text"] = String((bubbles[0] as Dictionary).get("text", "")) if not bubbles.is_empty() and bubbles[0] is Dictionary else "" _draft["duration"] = SPEECH_PAYLOAD.duration_for(_draft) if stop_replay: _stop_replay_to_static() draft_changed.emit(_draft.duplicate(true))