Skip to content

Commit 4da8e10

Browse files
committed
feat: §4.9 reduced-motion flag + §4.10 thread-model docs
§4.9 — TERMFLOW_REDUCED_MOTION env var (and HOCON termflow.accessibility.reduced-motion) flow through RuntimeCtx.config.accessibility.reducedMotion. Spinner accepts a reducedMotion: Boolean parameter that pins it to frames(0). New docs/guide/accessibility.md page covers the colour, motion, and predictable-structure stance. Closes #139. §4.10 — docs/reference/thread-model.md diagrams the runtime topology (runtime thread + InputKey producer + Sub.Every scheduler + FCmd executor + resize listener + shutdown hook) and documents the invariants apps can rely on. Closes #134. Roadmap: §4.9 / §4.10 marked landed; only §4.2 (release-doc sweep) and §4.5 (rolling-console recipe) remain in §4.
1 parent 59ff7ce commit 4da8e10

9 files changed

Lines changed: 360 additions & 54 deletions

File tree

docs/SUMMARY.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
- [Widgets](guide/widgets.md)
2424
- [Keymap and chords](guide/keymap.md)
2525
- [Theming](guide/theming.md)
26+
- [Accessibility](guide/accessibility.md)
2627
- [Testing](guide/testing.md)
2728

2829
# Cookbook
@@ -43,11 +44,11 @@
4344

4445
- [API (Scaladoc)](reference/api.md)
4546
- [Migration notes](reference/migration.md)
47+
- [Thread model](reference/thread-model.md)
4648

4749
# Contributing
4850

4951
- [Design](contrib/DESIGN.md)
5052
- [Render pipeline](contrib/RENDER_PIPELINE.md)
5153
- [Roadmap](contrib/ROADMAP.md)
5254
- [Running sample apps](contrib/RUN_EXAMPLES.md)
53-
- [Killer demo spec (handover to llm4s)](contrib/KILLER_DEMO_SPEC.md)

docs/contrib/ROADMAP.md

Lines changed: 29 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,8 @@ For 1.0:
192192
additive-changes catalogue (§3.5 / §4.3).
193193
- ☐ README headline screenshot is the llm4s chat client (§3.3 — demo
194194
hosted in `llm4s`, screenshot + link in this README).
195-
- ☐ Pre-1.0 release-hardening checklist complete (§4) — §4.2, §4.5,
196-
§4.9, §4.10, §4.11 still open.
195+
- ☐ Pre-1.0 release-hardening checklist complete (§4) — §4.2, §4.5
196+
still open.
197197

198198
MiMa (§3.1) was originally on this list; it is now scheduled for the
199199
1.0.1 / 1.1.0 cycle with `1.0.0` as the baseline.
@@ -344,36 +344,7 @@ upstream if it becomes load-bearing.
344344

345345
mdBook/linkcheck stay green; the docs site builds unchanged.
346346

347-
### 4.9 Layout v2 close-out — horizontal scroll
348-
349-
`Layout.Scroll` is currently Y-axis only. Closing out Layout v2 before
350-
the 1.0 lock-in means making it a true 2-D viewport so apps can scroll
351-
wide content (log lines, code, tables) horizontally as well as
352-
vertically.
353-
354-
The blocker is `Layout.Clip`: today it trims `TextNode` content past
355-
the **right** edge of the clip band, but `TextNode`s starting to the
356-
**left** of the band are dropped outright. That's fine for vertical
357-
scroll (top-of-line is always at `x=0` inside the clip) but breaks
358-
horizontally. Fix the Clip pass first (#117), then add `offsetX` to
359-
`Scroll` (#116).
360-
361-
Acceptance:
362-
363-
- `Layout.Clip` trims leading cells of `TextNode` / `BoxNode` /
364-
`InputNode` on partial left-edge overlap, mirroring the existing
365-
right-edge truncation. Wide-codepoint cells handled.
366-
- `Layout.Scroll` accepts `offsetX` alongside `offsetY` with mirror
367-
semantics; resolver translates inner by `(-offsetX, -offsetY)` and
368-
relies on Clip to trim both edges.
369-
- `LayoutSpec` covers partial left/right/both clipping plus 2-D scroll
370-
(X-only, Y-only, both, edge cases).
371-
- Cookbook updated with an X-scroll recipe.
372-
- No regression in existing Y-scroll snapshots.
373-
374-
Tracked as #117 (prerequisite) → #116 (the scroll change itself).
375-
376-
### 4.10 Reduced-motion flag
347+
### 4.9 Reduced-motion flag — ☑ landed
377348

378349
A `TERMFLOW_REDUCED_MOTION=1` env var (and `TermFlowConfig` field
379350
defaulting to it) that disables cosmetic animation: `Spinner` renders
@@ -393,7 +364,7 @@ Acceptance:
393364

394365
Tracked as #139.
395366

396-
### 4.11 Thread-model documentation
367+
### 4.10 Thread-model documentation — ☑ landed
397368

398369
A `docs/reference/thread-model.md` (or a new section in the app-layer
399370
guide) explaining the runtime's thread topology — runtime thread,
@@ -521,15 +492,32 @@ Two TermFlow-only wins worth preserving through 1.0:
521492

522493
## 8. Recent decisions (rolling, last ~3 months)
523494

495+
- *2026-04-30* — Stage 4 §4.9 closed: reduced-motion flag landed.
496+
`TERMFLOW_REDUCED_MOTION` env var (and HOCON
497+
`termflow.accessibility.reduced-motion`) flows through
498+
`RuntimeCtx.config.accessibility.reducedMotion`; `Spinner` accepts
499+
`reducedMotion: Boolean` and pins to `frames(0)` when true. New
500+
`docs/guide/accessibility.md` page covers the colour, motion, and
501+
predictable-structure stance.
502+
- *2026-04-30* — Stage 4 §4.10 closed: thread-model documentation
503+
landed at `docs/reference/thread-model.md`. Diagrams the topology
504+
(runtime thread, InputKey producer, Sub.Every scheduler, FCmd
505+
executor, resize listener, shutdown hook) and the invariants apps
506+
can rely on (\`update\` / \`view\` always on the runtime thread,
507+
FCmd continuations come back via the bus, subs lazy-start, CmdBus
508+
serialises, etc.).
524509
- *2026-04-30* — Issue triage: 25 completed issues closed; #154
525-
(relative-coordinate VDom) and #119 (Layout v1→v2 migration guide)
526-
closed as superseded — Layout DSL already supplies the relative-
527-
coordinate tree, and there are no v1 users. Three new entries added
528-
to §4 as 1.0 scope: §4.9 (horizontal scroll close-out — #117 + #116),
529-
§4.10 (reduced-motion flag — #139), §4.11 (thread-model documentation
530-
#134). #141 (TuiRuntime error-path tests) scoped down to two
531-
remaining gaps (Sub mid-stream exception, CmdBus overflow); not a
532-
1.0 blocker.
510+
(relative-coordinate VDom), #119 (Layout v1→v2 migration guide),
511+
#116 (horizontal scroll), and #117 (partial left-edge clip) all
512+
closed as superseded — the first two because the current Layout DSL
513+
already supplies the relative-coordinate tree and there are no v1
514+
users; the latter two because `Layout.Scroll` / `Layout.Clip` only
515+
ever existed on the abandoned `feature/layout-cookbook` branch and
516+
never shipped publicly. Two new entries added to §4 as 1.0 scope:
517+
§4.9 (reduced-motion flag — #139) and §4.10 (thread-model
518+
documentation — #134). #141 (TuiRuntime error-path tests) scoped
519+
down to two remaining gaps (Sub mid-stream exception, CmdBus
520+
overflow); not a 1.0 blocker.
533521
- *2026-04-30* — Stage 4 §4.7 closed: coverage-uplift branch merged
534522
(`a971a94`). `termflow-terminal` 66% → 88% stmts / 63% → 90% branches,
535523
`termflow-screen` 72% → 91% stmts / 65% → 77% branches,

docs/guide/accessibility.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Accessibility
2+
3+
TermFlow targets terminal apps, where the assistive-technology story is
4+
genuinely different from the web: screen readers read the terminal
5+
buffer directly, so the practical levers a TUI library has are around
6+
**colour**, **motion**, and **predictable structure**.
7+
8+
This page documents the levers TermFlow exposes today.
9+
10+
## Colour
11+
12+
- `Theme.dark`, `Theme.light`, and `Theme.mono` ship as built-in palettes.
13+
- Capability detection downgrades cleanly: true-colour → 256 → 16 → 8 →
14+
mono. Apps don't need to branch on the terminal — pick a `Theme` and
15+
let the renderer downgrade.
16+
- The `NO_COLOR` environment variable is honoured: when set, the runtime
17+
forces `Theme.mono`-equivalent rendering regardless of detected
18+
capability.
19+
20+
## Reduced motion
21+
22+
Some users find ambient animation actively unhelpful — vestibular
23+
sensitivity, low-bandwidth sessions, screen-reader environments that
24+
re-announce on every frame, or just personal preference. TermFlow
25+
exposes a single flag for this: `reducedMotion`.
26+
27+
### Activating reduced motion
28+
29+
Either set the environment variable:
30+
31+
```bash
32+
TERMFLOW_REDUCED_MOTION=1
33+
```
34+
35+
…or set it in the HOCON config:
36+
37+
```hocon
38+
termflow {
39+
accessibility {
40+
reduced-motion = true
41+
}
42+
}
43+
```
44+
45+
The env var takes precedence over the config value when set. Truthy
46+
values are anything other than `"0"` or `"false"` (case-insensitive).
47+
48+
### What it affects
49+
50+
The flag is plumbed through `RuntimeCtx.config.accessibility.reducedMotion`
51+
so apps and widgets can read it.
52+
53+
`Spinner` accepts a `reducedMotion: Boolean` parameter that, when
54+
`true`, pins the rendered frame to `frames(0)` regardless of the tick:
55+
56+
```scala
57+
import termflow.tui.widgets.Spinner
58+
59+
def view(model: Model)(using ctx: RuntimeCtx[Msg]): VNode =
60+
Spinner(
61+
Spinner.Braille,
62+
frame = model.tick,
63+
reducedMotion = ctx.config.accessibility.reducedMotion
64+
)
65+
```
66+
67+
App-level animation (sine wave demos, custom progress effects, anything
68+
driven by `Sub.Every` purely for cosmetic reasons) should query the
69+
flag and either skip rendering or fall back to a static representation.
70+
71+
### What it does not affect
72+
73+
- **Functional motion** — cursor movement, focus changes, scroll
74+
position, dialog open/close. Those are part of the app's behaviour,
75+
not cosmetic animation.
76+
- **Determinate `ProgressBar`** — already non-cycling. Only
77+
indeterminate progress indicators count as cosmetic motion, and
78+
TermFlow's `ProgressBar` is determinate-only.
79+
80+
## Predictable structure
81+
82+
The Elm-style architecture (`update` is pure, `view` is a function of
83+
the model) means the rendered frame is fully determined by the model.
84+
That's a foundation accessibility tooling can build on — semantic
85+
annotations, a linear/announcement renderer, or a screen-reader bridge.
86+
Those are post-1.0 work; see the roadmap for current status.

docs/reference/thread-model.md

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# Thread model
2+
3+
TermFlow's runtime uses several threads behind a deliberately small set
4+
of synchronisation points. This page documents the topology and the
5+
invariants apps and widget authors can rely on, so you don't have to
6+
reverse-engineer them from `TuiRuntime.scala`.
7+
8+
## Topology
9+
10+
```
11+
┌─────────────────────────┐
12+
│ runtime thread │
13+
│ (the main loop) │
14+
│ │
15+
│ - drain CmdBus │
16+
│ - run TuiApp.update │
17+
│ - run TuiApp.view │
18+
│ - render frame │
19+
└────┬────────────┬───────┘
20+
▲ │
21+
│ publish │ writes ANSI
22+
│ ▼
23+
┌────┴───┐ ┌───────────────┐
24+
│ CmdBus │ │ TerminalBackend│
25+
│ (queue)│ │ (JLine writer) │
26+
└────▲───┘ └───────────────┘
27+
28+
┌──────────────┼──────────────┬──────────────────┐
29+
│ │ │ │
30+
┌──────────┴──┐ ┌───────┴────┐ ┌──────┴──────┐ ┌────────┴────────┐
31+
│ InputKey │ │ Sub.Every │ │ FCmd Future │ │ TerminalResize │
32+
│ producer │ │ scheduler │ │ executor │ │ SIGWINCH │
33+
│ (one │ │ (one │ │ (global EC) │ │ listener │
34+
│ thread │ │ scheduled │ │ │ │ (JLine │
35+
│ per Sub) │ │ thread │ │ │ │ callback │
36+
│ │ │ per Sub) │ │ │ │ thread) │
37+
└─────────────┘ └────────────┘ └─────────────┘ └─────────────────┘
38+
```
39+
40+
Every off-main-thread component talks to the runtime by publishing to
41+
`CmdBus`. The runtime is the only consumer.
42+
43+
## Threads in detail
44+
45+
| Thread | Lifetime | Role |
46+
|---|---|---|
47+
| **runtime thread** | `TuiRuntime.run(app)` start → `Cmd.Exit` (or shutdown hook) | Executes `update`, `view`, and renders. Single-threaded. |
48+
| **InputKey producer** | Lazy: started by the first `RuntimeCtx.registerSub` of a `Sub.InputKey` | Reads keystrokes off the `TerminalBackend.reader` and publishes `Cmd.GCmd` per parsed key. |
49+
| **Sub.Every scheduler** | Lazy: per-`Sub.Every`, started on `registerSub` | A `ScheduledExecutorService` ticks at the configured period and publishes a `Cmd.GCmd` per tick. |
50+
| **FCmd Future executor** | Per `Cmd.FCmd` — uses `ExecutionContext.global` by default | Runs the user's `Future` body off the runtime thread; the continuation publishes a result `Cmd` back. |
51+
| **Resize listener** | Lazy on `Sub.TerminalResize` | JLine's SIGWINCH callback runs on its own thread; the listener publishes a `Cmd.GCmd` per resize. |
52+
| **JVM shutdown hook** | Registered once by `TuiRuntime.run` | Restores cursor / leaves alt-buffer / disables mouse on abrupt exit. |
53+
54+
## Invariants you can rely on
55+
56+
1. **`update` and `view` always run on the runtime thread.** No two
57+
`update` calls overlap — the bus is a single consumer. Mutating
58+
`Model` inside `update` is therefore safe by construction (and the
59+
model should be immutable anyway).
60+
2. **`Cmd.FCmd` continuations come back through the bus.** When your
61+
`Future[A]` completes, the result mapper runs *off* the runtime
62+
thread, but the produced `Cmd` is published — so the `update` that
63+
sees it runs on the runtime thread again. You don't need to
64+
synchronise on shared state, only on whatever the `Future`'s body
65+
itself touches.
66+
3. **`Sub` callbacks run off their respective threads.** A `Sub.Every`
67+
tick fires on the scheduler thread, an `InputKey` parse fires on
68+
the producer thread. Whatever you do in the callback that produces a
69+
`Cmd` runs off-main, but the resulting `Cmd` always arrives at
70+
`update` on the runtime thread.
71+
4. **Subscriptions start lazily.** `Sub.InputKey`, `Sub.Every`, and
72+
`Sub.TerminalResize` don't spawn threads or schedule timers until
73+
`RuntimeCtx.registerSub` is called. Tests using `TestRuntimeCtx`
74+
keep them dormant deliberately.
75+
5. **`CmdBus` is a serialising queue.** Multiple producer threads can
76+
`publish` concurrently; the runtime's `take` / `poll` is the single
77+
consumer. Order across producers is FIFO by enqueue time.
78+
79+
## Gotchas
80+
81+
- **Don't block the runtime thread.** Anything synchronous inside
82+
`update` blocks the entire frame. Use `Cmd.FCmd` (or `Cmd.asyncResult`)
83+
for I/O.
84+
- **Don't close over mutable state inside `FCmd`.** The body runs
85+
off-main, possibly concurrently with another `FCmd` body, and the
86+
continuation also runs off-main before the resulting `Cmd` is
87+
published. Pass the data you need by value into the `Future`.
88+
- **`Sub.Every` tick drift is not corrected.** The scheduler uses
89+
`scheduleAtFixedRate`, which can drift if the runtime is slow to
90+
drain ticks. If exact wall-clock cadence matters, use
91+
`Sub.Every` for *triggering* and read `System.currentTimeMillis()`
92+
inside `update` for the *timestamp*.
93+
- **JLine callbacks (resize, signals) are not on a TermFlow-managed
94+
thread.** Don't do anything in those callbacks except publish to
95+
`CmdBus`.
96+
97+
## Subscription cleanup
98+
99+
`CmdBus.cancelAllSubscriptions()` is called by the runtime on exit
100+
(both clean exit via `Cmd.Exit` and abrupt exit via the shutdown hook).
101+
Each `Sub`'s `cancel()` is best-effort: it may interrupt the producer
102+
thread, shut down a scheduler, or close a `Reader`. If one cancel
103+
throws, the rest still run.
104+
105+
## Testing
106+
107+
For deterministic tests, use the testkit:
108+
109+
- `TestRuntimeCtx` keeps subs dormant — `registerSub` does not call
110+
`start()`.
111+
- `TuiTestDriver` advances the model by feeding `Msg`s directly,
112+
bypassing the bus and threading concerns.
113+
- `KeySim` and `MouseSim` synthesise input events.
114+
115+
Real-time behaviour (`Sub.Every` cadence, JLine reader latency) is
116+
deliberately *not* covered by the testkit — that's what the sample apps
117+
exist for.

modules/termflow-app/src/main/resources/reference.conf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,8 @@ termflow {
77
enabled = false
88
env-override = ${?TERMFLOW_RENDER_METRICS}
99
}
10+
accessibility {
11+
reduced-motion = false
12+
env-override = ${?TERMFLOW_REDUCED_MOTION}
13+
}
1014
}

0 commit comments

Comments
 (0)