Files
stickman/specs/speech_style_toolbar.md
T
ryan 6666173c4f 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.
2026-10-03 14:49:22 -04:00

139 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `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
```text
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_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/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:
```gdscript
{
"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.
```mermaid
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:
```gdscript
"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.