Files
stickman/docs/specs/speech_style_toolbar.md

18 KiB
Raw Permalink Blame History

Speech styling toolbar — stage-first composer follow-up

Status: Implemented; pending user verification (2026-10-02). This extends the implemented Phase 13 comic speech composer in docs/specs/phase_13_speech_bubbles.md. The user approved a nearby line-token picker for cramped text, transactional chooser previews with Done and Cancel, and deferring Undo.

Current main-toolbar layout: see the 2026-10-02 amendment at the end of this document. It supersedes older Speech Style footer and responsive-layout descriptions below.

1. Interaction and scope

When the speech composer is open, the selected bubble has a compact toolbar near its frame on the stage. The toolbar groups Message and Timing under Speech, then Frame, Letters, and Effects under Style, with one chooser surface at a time. Frame changes the selected bubble's frame. Letters offers the existing font, size, and 16 text-color choices. Without a text target these change the whole bubble; with one selected token they change only that token. Effects offers Shake, Wave, Bounce, and No effect. With no token selected, Effects applies to the whole non-empty bubble; with a token selected, it applies only to that token.

Clicking a bubble selects it. Message editing is in a modal opened from the stage toolbar; words are selected on the stage, never by selecting text in the Message dialog. Clicking rendered text selects one whole token with a clear stage highlight. There is no drag or Shift selection. Repeated identical punctuation such as ..., !!!, and ??? forms one target. A Unicode ellipsis … forms one target. Mixed punctuation such as ?! forms two targets. A word with an internal apostrophe remains one target; emoji keep their existing grapheme behavior. For crowded text, a tap in the rendered text line opens a temporary picker of that line's tokens. Each picker choice has a minimum 44 × 44 px hit area.

The inspector retains bubble add/remove/reorder, Show, Placement, Replay, Save speech, and Cancel. Message, its emoji picker, and Timing live in the stage toolbar flow and are removed from the inspector. The selected bubble's existing bright outline stays visible. Existing placement, drag threshold, overlap bubble picker, recovery draft, and queue/rule commit flows remain intact.

2. Godot hierarchy and ownership

SandboxStage (Node2D; input, positioning, popup coordination)
├── World/StickmanRig
│   └── SpeechPreview: SpeechBubble (Node2D; frame, RichTextLabel, hit data)
└── UI: CanvasLayer
    ├── SpeechComposer: SpeechComposer (Control; authoritative draft + Message dialog)
    └── SpeechStyleToolbar: SpeechStyleToolbar (Control; transient view)
        └── StyleChooser: PopupPanel (one reusable chooser)
            └── MarginContainer/ScrollContainer/VBoxContainer
                ├── Frame choices OR Letters choices OR Effects choices
                └── Cancel / Done

Temporary line-token picker: PopupPanel under UI, owned by SandboxStage.

SpeechStyleToolbar lives in scripts/speech/speech_style_toolbar.gd (class_name SpeechStyleToolbar, extends Control) with a small scenes/speech_style_toolbar.tscn or code-built children matching the existing composer convention. It owns visual selection and chooser widget state only. SpeechComposer remains the single writer for speech dictionaries. SandboxStage positions the toolbar from the renderer, routes signals, and keeps popup input away from camera pan/zoom and bubble dragging. SpeechBubble remains the shared renderer for stage preview and playback; token interactions are enabled only for a static editor preview, never for PLAY or runtime speech.

Use a local SandboxTheme.control_theme() and add validated speech.toolbar tokens in sandbox_theme.json / scripts/stage/sandbox_theme.gd for surface, border, selection halo, gap, maximum width, and minimum hit height. Do not alter the global theme or serialize toolbar settings into .scene.

3. Static contracts and signal flow

Class Added or revised public contract
class_name SpeechPayload extends RefCounted static func token_spans(text: String) -> Array[Vector2i] and static func token_span_at(text: String, index: int) -> Vector2i. Return half-open plain-text offsets; invalid hit returns Vector2i(-1, -1). Keep word_span_at as a compatibility wrapper if other callers still use it, but move style validation/normalization and run segmentation to the token rule.
class_name SpeechBubble extends Node2D signal stage_token_selected(bubble_index: int, start: int, end: int); func selected_frame_viewport_rect(index: int) -> Rect2; func text_line_tokens_at_viewport(index: int, viewport_position: Vector2) -> Array[Vector2i]; func set_selected_token(index: int, start: int, end: int) -> void. Existing hit_test_bubbles(world_position) and drag APIs remain.
class_name SpeechComposer extends Control func style_context() -> Dictionary; func begin_style_preview(kind: StringName) -> void; func preview_style_choice(kind: StringName, value: String) -> void; func finish_style_preview(keep: bool) -> void; func get_preview_action() -> Dictionary. get_draft() continues to return committed draft only. select_bubble_token_from_stage(index,start,end) replaces the word-only entry point and validates against token_span_at. Existing text-click targeting uses the same token helper.
class_name SpeechStyleToolbar extends Control signal chooser_open_requested(kind: StringName); signal choice_preview_requested(kind: StringName, value: String); signal chooser_finished(keep: bool); signal clear_target_requested(); signal replay_requested(). func set_context(context: Dictionary) -> void; func set_anchor_rect(frame_viewport_rect: Rect2, stage_viewport_rect: Rect2) -> void; func close_chooser() -> void. UI strings remain in toolbar; model values are stable IDs.
class_name SandboxStage extends Node2D Instantiates toolbar under UI, connects it to composer/preview, calls get_preview_action() for editor rendering and effect replay, repositions toolbar after layout changes, and frees/hides it on composer close. It keeps popup exclusion in _speech_composer_owns_event or a renamed speech-UI hit guard.

style_context() is transient UI data, not a saved action. Its shape is:

{
    "bubble_index": 1,
    "bubble_count": 3,
    "target": {"start": 5, "end": 8, "text": "!!!"}, # {} for whole bubble
    "bubble_style": "shout",
    "font_preset": "comic", # effective value for the target, else bubble default
    "size_preset": "medium",
    "color": "#202020",
    "effect": "wave" # "none" when no target or override
}

Use StringName categories &"frame", &"font", &"size", &"color", and &"effect" at the signal boundary; values use the existing SpeechPayload allowlists. The toolbar must not mutate this context. Composer selection changes cause one context refresh to toolbar and renderer. Opening another bubble or token cancels an open chooser before switching, so pending changes cannot apply to a stale offset.

flowchart LR
    Click[Stage bubble or token tap] --> Hit[SpeechBubble hit or token metadata]
    Hit --> Stage[SandboxStage routing]
    Stage --> Composer[SpeechComposer selection and style context]
    Composer --> Bar[SpeechStyleToolbar]
    Bar -->|preview choice| Composer
    Composer -->|get_preview_action| Stage
    Stage -->|render + highlight| Hit
    Bar -->|Done| Draft[Committed composer draft]
    Bar -->|Cancel| Discard[Discard preview visual]
    Draft -->|Save speech| Queue[Existing queue/rule commit]

4. Token model and rendering

Token offsets remain integers over the literal bubble text. The existing visual.word_styles array is retained; start and end may now name a whole punctuation token as well as a word or emoji. The visual schema adds a backward-compatible bubble-level effect default; word-level effects override it, including an explicit none override. No .scene version change is needed. JSON numeric offset coercion stays in the existing validation path. Old word-style records remain valid because word boundaries are unchanged.

The tokenizer scans left to right in this order: regex word (SpeechPayload.WORD_REGEX, including an internal apostrophe); emoji/symbol grapheme; ASCII repeated run of the same ., !, or ?; one Unicode ellipsis …; any other single punctuation grapheme; whitespace yields no token. It must never merge different punctuation (?!) or absorb punctuation into the preceding word (Hello!). token_span_at and token_spans share the same scanner so validator, editor click target, stage click target, save/load, and renderer agree. Keep word_count() semantics unchanged for reading time.

Update SpeechPayload.validate, _normalize_visual, rich_text_runs, and SpeechComposer._adjust_word_styles to accept only exact token spans. rich_text_runs must add every token boundary before applying overrides; currently unstyled punctuation can be part of a larger run with spaces, so it cannot be wrapped in its own metadata without this split. Preserve all existing style values: font, size, color, and effect. Example:

"I am a GHOST!!!"
# I       [0, 1)    word
# am      [2, 4)    word
# a       [5, 6)    word
# GHOST   [7, 12)   word
# !!!     [12, 15)  one punctuation token
{"start": 12, "end": 15, "effect": "shake"}

SpeechBubble._render_label_text wraps each token run with RichTextLabel.push_meta({"bubble_index": i, "start": s, "end": e}, META_UNDERLINE_NEVER), including punctuation. A RichTextLabel using MOUSE_FILTER_IGNORE cannot receive meta_clicked; set label mouse_filter = MOUSE_FILTER_PASS only while static editor preview is active, and IGNORE in playback. The stage's existing deferred release handling should let meta_clicked arrive before it resolves the bubble click. The implementer must verify that press-time selection/re-render does not swallow the metadata click; if it does, defer selection until release or route all token hits through the line picker. SpeechBubble paints the selected token with an input-transparent highlight inside the label, while SpeechWordMarker keeps its rounded TextEdit highlight. Store renderer selection as transient (bubble_index,start,end) and clear it on text edits, bubble reorder/removal, or leaving composition.

RichTextLabel gives wrapped line ranges and line offsets but no dependable per-glyph rectangle API for the mixed-font content used here. Do not approximate every token rectangle from Font.get_string_size() because wrapping, glyph shaping, and per-token sizes can disagree. Exact metadata taps select directly. A tap in a rendered text line that misses metadata, or a tap on a cramped token, opens a local line-token picker. Its buttons show the complete token and have a minimum 44 × 44 px hit box; the picker includes visible words and punctuation in that line, never whitespace. Use the label's line range/offset after drawing to limit choices. If a line range is unavailable during the first frame, postpone until the next draw or show tokens for that bubble as a safe fallback. No always-visible token list occupies the inspector.

5. Toolbar and chooser layout

Anchor the compact toolbar to the selected bubble's displayed frame, including runtime fit offset, camera transform, and rig mirroring. selected_frame_viewport_rect() transforms all four corners of _layout_frame + _display_offset with get_global_transform_with_canvas() and returns their axis-aligned viewport rect. Do not use saved placement.center to position UI; it lacks viewport fitting. Recompute anchor after preview render, viewport resize, camera pan/zoom, bubble drag, bubble text change, and font/size preview. Hide the toolbar during an active bubble drag and while replay/blank hold runs; restore it beside the static preview afterward.

Preferred position is centered above the frame with an 8–12 px gap. Try below when there is no room, then clamp within the usable stage rectangle bounded by top chrome, bottom status bar, and the actual composer panel edge. Group Message/Timing on a Speech row and Frame/Letters/Effects on a Style row; reflow within available width without horizontal scrolling. The Message dialog blocks stage input and contains the text editor plus an Emoji button and picker. Choosers use a ScrollContainer with a maximum viewport-clamped height. The 16 color swatches remain equal-sized and visibly outlined when selected. Font and size samples keep their selected state and show their appearance using the existing theme/font presets. Frame samples use SpeechBubble.draw_style_shape so the choice looks like its actual frame. With no target and non-empty text, the Effects chooser applies to the whole bubble; with no text it is disabled with a short prompt. Use one chooser instance and one active category.

The toolbar itself does not save speech. The inspector's Save speech stays the only queue/rule commit action. Frame always edits the selected bubble even when a token is targeted. Letters applies bubble defaults if target == {} and token overrides otherwise. Effects sets the bubble default and clears token effect overrides if target == {}; otherwise it edits only the selected token. Timing updates the action-wide duration_mode. Switching tokens keeps prior token styles intact. Selected values refresh after every choice.

6. Transactional preview and replay

Opening a chooser captures {bubble_index, target range, category, opening visual} and creates a provisional copy of that bubble's visual style. Each choice updates only this provisional copy and emits a preview refresh. SpeechComposer.get_preview_action() overlays the provisional visual on a duplicate of get_draft(); it never writes to _draft or the recovery file. Done validates the still-current bubble/target, applies the provisional visual to _draft once, emits draft_changed, and closes. Cancel, Escape, outside click, bubble/token switch, or composer close discards the provisional visual. If text edits or bubble reorder invalidate a token while a chooser is open, discard it before applying those edits. Selecting Save speech while a chooser is open first completes the chooser as Done, then runs the existing save validation/commit flow; it cannot silently save an uncommitted preview.

For Effects, a provisional Shake/Wave/Bounce choice starts one existing composer replay cycle using get_preview_action() so the chosen motion can be seen. The Replay control restarts one cycle on demand. Preserve the existing one-second blank hold and static return; do not run effects continuously in static editing. SpeechWordEffect remains clock-driven by the speech group and unchanged in saved data. During replay, the toolbar can remain visible but disabled, or hide until static return; whichever is implemented must prevent chooser clicks from mutating an in-flight replay.

Undo is deferred. No Undo button or partial undo stack is added. The chooser's Cancel restores its opening style, and the existing Save speech point still controls queue/rule commits. A future history system should cover text, bubble order/placement, and styles together; an isolated style stack would give a misleading partial Undo.

7. Integration order and acceptance

  1. Add pure token span helpers and migrate validation, normalization, rich-text segmentation, and editor target maintenance. Keep old word-style scenes valid.
  2. Enable static-preview token metadata/highlight in SpeechBubble and stage selection routing. Confirm punctuation, emoji, mixed marks, and the 44 px line picker.
  3. Add composer provisional style APIs and switch editor preview rendering to get_preview_action() while preserving get_draft() for recovery and Save speech.
  4. Add themed toolbar/chooser and responsive frame anchoring. Connect Frame/Letters/Effects, selected values, Cancel/Done, and one-cycle effect Replay.
  5. Remove duplicate inspector styling controls only after the toolbar covers all current choices. Preserve Message, placement, timing, replay, and Save speech.

Acceptance in Godot: clicking a bubble updates Message and toolbar; clicking I, styling it, then clicking GHOST styles only that next token; !!! styles as one token and ?! as two; font/size/color still work for a whole bubble after clearing target. Tiny punctuation can be chosen through a nearby picker with 44 px choices. Cancel returns the opening appearance and leaves get_draft()/recovery unchanged; Done survives Save speech, scene save, reload, and queue edit. Effect preview plays once and Replay repeats it. Toolbar and picker remain reachable during zoom, bubble drag, small windows, and camera pan without covering the inspector. Playback speech does not intercept scene input.

8. Trade-offs and deferred work

The separate UI overlay keeps speech data ownership in the composer and uses one renderer layout for stage hit tests. It adds one more view to position and synchronize, so the stage must be the single placement coordinator. RichTextLabel metadata gives precise glyph selection but small punctuation remains physically small; the local line-token picker provides a reliable 44 px path without replacing the renderer. The picker and transient provisional visual are session-only and add no action fields. General Undo and font bundling remain deferred. The existing capped eight-bubble/240-character bounds keep token scans and popup lists modest; cache token spans per text revision only if profiling shows a cost.

9. Main Speech Style dialog layout amendment (2026-10-02)

The stage-side Speech Style panel follows this structure:

  1. A ribbon title, then a Bubble N of M label with the existing Earlier/Later reorder actions for orientation.
  2. A two-column grid: Message, Frame, Letters, and Effects.
  3. The Add bubble, Remove bubble, and Undo controls together.
  4. One centered Done styling button. Done speech and Cancel speech appear only in Show speech.

Anchor the panel beside the selected bubble when there is room; otherwise place it below or above and keep it inside the stage. Cap width so the stage remains visible. On narrow or short stages, the body scrolls while the title and Done styling action remain visible. Style buttons reflow into two columns; do not require horizontal scrolling.