Skip to content

Feat/live image - #16

Merged
jacquetc merged 74 commits into
devfrom
feat/live-image
Oct 9, 2026
Merged

jacquetc merged 74 commits into
devfrom
feat/live-image

Conversation

@jacquetc

@jacquetc jacquetc commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

Renderer::render does not upload the glyph atlas; the redraw path does it
right after rendering. Automation screenshots skipped that step, so text
first rasterised by the capture's own render drew blank, and the headless
server, which never uploaded the atlas, captured no text at all.

The redraw path's upload and eviction recovery move into
sync_window_glyph_atlas, which automation_screenshot now calls too, over
a shared sync_glyph_atlas helper. HeadlessApp::render_for_capture does the
same for a headless tree and a caller-owned renderer, reaching the
typesetter through the tree's app state, and the MCP headless server keeps
its renderer's atlas version beside the renderer.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
The offscreen instance is now built _from_env, so WGPU_BACKEND applies to
it as it does to windows; under WGPU_BACKEND=vulkan the search finds a
Vulkan adapter (lavapipe on a GPU-less runner) or none, never GL.

create_offscreen_renderer is the panic-free entry shipped code uses (the
headless automation server and the previewer's shots), with
create_test_renderer kept as an alias. require_test_renderer is the door
for GPU tests: TEKSILO_TEST_REQUIRE_ADAPTER=any fails a test on a missing
adapter, =lavapipe also on any adapter is_lavapipe rejects, and the panic
names the adapter found. The renderer and capture GPU tests move onto it.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
Test (linux) installs mesa-vulkan-drivers and points the Vulkan loader at
the lavapipe manifest, with WGPU_BACKEND=vulkan and
TEKSILO_TEST_REQUIRE_ADAPTER=lavapipe, so every test that renders through
test_support::require_test_renderer runs on lavapipe or fails naming the
adapter it found. Until now those tests opened whatever adapter the
runner happened to have, or none, and returned early as passes.
Test (windows) and Test (mac) require any adapter (WARP, Metal).
CONTRIBUTING says how to run the GPU tests the same way locally.

The whole workspace suite passes locally under the Test (linux)
environment.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
SetClip clamped the scissor's origin to the target but took its size from
the unclamped extent, so a clip spanning x -10..20 became 0..30 and drew
10 px past its real edge: a clipping widget scrolled partly off the
leading or top edge, a clipping card panned past the window edge, or a
clip inside a blur intermediate before the scope's origin. The scissor is
now built from its floored near edge and ceiled far edge, both clamped.

Also covers the nested case the per-target clip stacks fix: a clip opened
and closed inside a blur scope while a surface clip is open must neither
intersect with nor restore the surface rect onto the intermediate pass.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…t nodes pin

The dormant-node test passes with the stale-paint fix reverted, because
dormant nodes never reach the end-of-render clear and activation marks a
node for repaint by itself. It is renamed and documented as a guard for
that path. The self-transparent test's comment claimed a cached paint
decides whether a skipped node is re-marked, which nothing does; it now
says the node's opacity binding repaints it either way and the test pins
the dropped cache.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
text-document and text-typeset are not optional anywhere: teksilo-widgets
and teksilo-scene depend on teksilo-text unconditionally, and teksilo-app
depends on teksilo-widgets. The `text` feature on teksilo-app (default on)
only decided whether teksilo-app gave its windows the text backend and
uploaded the glyph atlas: turned off, the whole text stack was still
compiled and every label rendered blank. Nothing built that configuration,
so it had rotted (an unconditional `pub use teksilo_text`, tests needing
the rich-text editor). The umbrella's `text` feature could not even turn
that off, since the umbrella keeps teksilo-app's default features; it only
gated the `teksilo::text` and `teksilo::text_document` re-exports.

teksilo-text is now a plain dependency of teksilo-app and of the umbrella,
and the cfg(feature = "text") gates are gone. The umbrella's fonts-* and
system-emoji features forward to teksilo-text directly.

teksilo-app's dependency on teksilo-text is declared default-features =
false, as teksilo-widgets' already is. Through the workspace dependency it
turned on teksilo-text's default Arabic and Hebrew font bundles, so
`default-features = false` on the umbrella never dropped them (checked
with cargo tree on dev); now it does. teksilo-theme-material3 (under
bundled-fonts) and teksilo-preview-ui turned them on the same way and are
declared likewise; teksilo-widgets-previewer, the stock previewer binary,
asks for teksilo-text's defaults itself so its catalog keeps both fonts.

The app guide (three copies), the umbrella README, the architecture doc
and both CLAUDE files drop the feature.

BREAKING CHANGE: the `text` feature no longer exists on teksilo or
teksilo-app; a dependency that names it must drop it. An app depending on
teksilo-app or teksilo-preview-ui directly no longer gets the Arabic and
Hebrew fallback fonts unless it enables them on teksilo-text.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
The stale-paint entry listed a widget that was itself fully transparent
among those that replayed old paint; its own opacity binding repaints such
a widget when it fades back in, so that case never showed. The clipping
entry said fractional scales, but the translation was scaled twice at
every scale other than 1, 2x included.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…tate current

A minimised window kept rendering on every redraw: on KWin a minimised
window rendered 31-32 frames a second, each blocking the UI thread in
acquire. Now:

- render_frame calls pre_present_notify right before the present, so on
  Wayland winit holds the next RedrawRequested until the compositor's
  frame callback, which a compositor does not send to a surface it does
  not show.
- Every redraw request teksilo-app makes goes through one RedrawGate per
  window (ManagedWindow::request_redraw), which stamps it until the
  redraw is delivered. A deadline already due cannot wake the loop sooner
  than WITHHELD_AFTER (100 ms) after an outstanding request, so a window
  waiting on its compositor does not spin the loop; one still waiting
  then counts as withheld.
- ManagedWindow tracks minimized (Resized placement, initial placement,
  Focused(true), Occluded(false), and a rate-limited is_minimized query
  while unfocused, since X11 reports an iconify with no event) beside
  occluded, and pushes hidden() into the gate on every change.
- A window that is hidden or whose redraw is withheld draws nothing. Its
  requests mark a non-visual tick, which about_to_wait runs through
  run_nonvisual_frame (idle callbacks, layout, size-to-content,
  accessibility sync and delivery, IME), after handing it its waiting
  accessibility actions, and which ends in post_event. A RedrawRequested
  that still reaches a hidden window runs the same and draws nothing,
  except the one frame owed on the way to hidden. Ticks run no sooner
  than HIDDEN_TICK_INTERVAL (100 ms) after the window's previous frame,
  whatever asks for them; a held one sets the window's deadline to when
  the interval lifts. The due test at a timer wake reads the tree's own
  deadline. The cross-window paint pass skips a window that draws
  nothing, whose ticks it would otherwise re-arm. Coming back asks for
  one redraw; a Skipped frame is not retried while hidden.
- AccessKit's handlers no longer wake the loop with a redraw request,
  which bypassed the gate and, while winit holds a redraw on Wayland,
  does not even wake the loop. teksilo-app installs a waker
  (PlatformWindow::set_accessibility_waker, doc-hidden) that posts a
  private AccessibilityWake payload; user_event takes it before any app
  handler, dispatches the window's actions and asks it for a frame
  through the gate.
- Debug builds report a focused window whose redraw has waited more than
  a second. The idle trace prints t=, hidden_redraws and hidden_ticks.

The X11 winit-loop test gains claims (h), (i) and (j), driving the timer
path through new_events(ResumeTimeReached) and about_to_wait; each was
checked to fail with its mechanism removed. F.5 (hidden-window half) and
F.7 pass on a private kwin_wayland --virtual: no frame and no CPU time
from 0.5 s after a minimise, a redraw after each of 50 minimise-restore
cycles and after 200 resizes. Without pre_present_notify the minimised
window renders 31-32 frames a second there.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
The teksilo-corpus cargo-release hook regenerates the corpus (both passes)
in the release commit. Record that a feature commit leaves it stale, and
name the three checks that report the staleness until the release: CI's
build_corpus.py --check and two tests under cargo test --workspace.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
The off-thread wake protocols PR-3 and PR-5 add are lock-free, and a lost
wake there is a picture that freezes until something unrelated redraws,
which no ordinary test reproduces. loom runs every interleaving of a model.

- loom 0.7 joins [workspace.dependencies].
- teksilo-canvas gains `#[doc(hidden)] pub mod sync`, the one facade every
  crate's protocols take the atomics, locks and shared Arcs loom must see
  from: std's
  in an ordinary build, loom's under `--cfg teksilo_loom`, together with
  loom's `model` and `thread`. loom is a `[target.'cfg(teksilo_loom)']`
  dependency of teksilo-canvas so that core, platform and terminal model
  their own code through the same types without depending on loom
  themselves; it appears in an application's Cargo.lock and is never
  fetched or built otherwise. The cfg is crate-specific: concurrent-queue
  and event-listener in this lockfile switch to loom types under a bare
  `cfg(loom)`.
- canvas, core, platform and terminal declare the cfg to check-cfg.
- A CI job, "Concurrency models (loom)", lints the cfg-only code, fails a
  crate whose `loom_` models stopped compiling, and runs them at
  LOOM_MAX_PREEMPTIONS=3 with an optimised test profile. Its package list
  grows as the models land.
- CONTRIBUTING documents the commands, the cfg-only lint and the models,
  and CLAUDE.md's pre-commit list points to them.

`sync::loom_tests::loom_explores_more_than_one_interleaving` checks that
the cfg reaches the facade and that loom explores more than one
interleaving.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
A neutral `teksilo_canvas::wake` module that the trigger (core), the window
wake target (platform) and the live image (later) all build on:

- `RedrawWaker`, the one thing a producer holds to wake a window:
  `wake(WakeKind)` and a lock-free, advisory `is_hidden`.
- `WakeKind { Draw, Layout }`, ordered so a change that asks for both wakes
  once with the larger. A window that draws nothing may drop `Draw` and owes
  one when shown; `Layout` is never dropped.
- `WakeFlag` (doc-hidden): the clear-to-set flag behind coalescing. raise
  and take are both swap(.., AcqRel), and park is an RMW, so no SeqCst is
  needed and loom can check the argument as written: the RMWs on one
  location are totally ordered, so a change is either seen by the read after
  the take or its producer wakes. take is AcqRel so the platform's own
  Relaxed redraw flag is ordered behind it. `peek` is the same kind of RMW
  (fetch_or(false, AcqRel)), for a consumer that decides from the flag
  whether to serve the change at all before taking it: a raise after it
  synchronises with it, which a load would not, so a wake cannot merge into
  a redraw the frame deciding against it is already delivering.
- `WakeGate` (doc-hidden): hidden and missed in one AtomicU8, replacing the
  spec's two SeqCst booleans. pass is a strong compare_exchange loop, hide a
  fetch_or, show a fetch_and, so exactly one of "the producer routes" and
  "the reveal re-issues" happens.
- `CountingWaker`: counts wakes per kind and lets a test wait for one made
  on another thread. Public, at wake:: rather than the spec's
  live_image::testing::, with count_of (approved).

Eight loom models (smoke included). Each mutation below was run and turned
the listed models red:
- raise as swap(true, Relaxed), or take as swap(false, Release):
  change_is_seen_or_wakes, park_keeps_the_change;
- raise as a load then a store: those two and one_wake_per_raise;
- park as a store: park_keeps_the_change;
- show as a load then a store, or pass as a check then a fetch_or:
  gate_reveal_race, draw_path_composes;
- a pass that drops without recording: gate_hide_race, gate_reveal_race,
  draw_path_composes;
- peek as an Acquire load: peek_orders_the_decision_before_a_raise.
Real-thread tests cover the edge, take, park, the gate's single owed wake,
the kind order, CountingWaker, and a four-producer stress run (20 runs).

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…d with the window

`WindowWakeTarget` (teksilo-platform `wake.rs`) is the window's
`RedrawWaker`, routing by kind:

- Draw: dropped while the window is hidden (`WakeGate`, one redraw owed on
  reveal) and otherwise a winit redraw request, made under a slot lock so
  that once `disconnect` returns no call reaches winit. A request winit is
  already holding merges into it, which is right for pixels.
- Layout (and the accessibility handlers): never dropped, and posted
  through a state route, one post per burst until the event loop takes it.
  A redraw request cannot reach a Wayland window whose redraw winit is
  holding: a further request does not even wake the loop.
- On macOS, a draw wake made off the main thread never asks winit itself,
  since winit runs such a request synchronously on the main thread while
  the slot is held, and a main thread taking the slot meanwhile would
  deadlock: it posts through an off-main route, or with none installed
  hands the request to the main dispatch queue (`dispatch2`, already in the
  lockfile through winit and wgpu), which asks winit on the main thread
  after the same disconnect check.

`PlatformWindow` gains `redraw_waker`, `set_off_main_wake_route`,
`disconnect_redraw_waker`, `set_hidden`, `is_hidden`, `live_wake_stats`
(spec), the doc-hidden `set_state_wake_route`, `take_posted_wake`,
`defer_redraw_until_shown` (approved) and `show_for_drawing` (a frame about
to run shows the window without the redraw a reveal would ask for, since
it serves the draw wakes dropped meanwhile), and a `Drop` that
disconnects.
`request_redraw` goes through the slot, so it is a no-op once disconnected.
The three AccessKit handlers wake through the target, which replaces PR-2's
unreleased `AccessibilityWake`/`set_accessibility_waker`; teksilo-app
installs its private payload as the state route and re-arms the
coalescing before acting on it (the full payload rework is the next
commit). That also fixes an exit panic: winit unwraps the channel send
behind an X11 `request_redraw` (x11/window.rs:1845), so an accessibility
request arriving after the event loop ended panicked the accessibility
thread; a post fails quietly instead.

Locks and atomics come from `teksilo_canvas::sync`, so `loom` models the
real target: `loom_disconnect_vs_wake` (a wake racing `disconnect` never
reaches the window after it returns, and the window is gone by then). The
CI loom job adds teksilo-platform.

Tests E.1-E.4, E.6, state coalescing, defer-until-shown, show-for-frame,
the macOS off-main wake never waiting (macOS only; compile- and
clippy-checked here for aarch64-apple-darwin),
kind dispatch, and the accessibility handlers posting (built headless). Each mutation
below was run and reddened the listed tests:
- disconnect only clearing `connected`: E.1, E.2, loom;
- a request on an Arc cloned out of the slot: E.2, loom;
- the hidden gate ignored for draw wakes: E.4, E.6, dispatch;
- state wakes gated: E.4, dispatch;
- the off-main route taken under the slot: E.3;
- state coalescing ignored: state_wakes_coalesce_until_taken.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…hen the loop ends

- teksilo-core: `AppEvent` is `#[non_exhaustive]` (breaking; every use in
  the workspace already had a wildcard). `WidgetTree::set_redraw_waker`
  stores the waker content updated off the UI thread wakes the tree's window
  through, and the doc-hidden `redraw_waker` reads it back.
- teksilo-app installs each window's waker on its tree before the root
  builder runs, so a source attached in `build()` already wakes the window.
- One private payload, `WindowWake { window, kind }`, replaces PR-2's
  `AccessibilityWake`. `window_manager::wake_routes` builds the window's
  routes: the state route always, the off-main draw route only on macOS.
  `user_event` takes the payload before any app handler or observer.
  `window_wake` re-arms the target's coalescing before acting; a state wake
  then dispatches the window's accessibility actions and asks for a frame
  through the redraw gate (a redraw, or a tick for a window that draws
  nothing) and ends in post_event; a draw wake asks for a redraw, or is owed
  for later if the window draws nothing.
- The wake target learns when the window draws nothing, not only when it is
  hidden: `ManagedWindow::sync_draws_nothing` pushes the gate's hidden-or-
  withheld state, on every hidden change and delivered redraw and on every
  loop turn, since a redraw becomes withheld with time alone. The redraw
  handler shows the window for the frame it runs (`show_for_drawing`): that
  frame serves the draw wakes dropped while the window drew nothing, and is
  itself the last frame or the reveal a hidden transition asks for, so it
  asks for no second one.
- `exiting()` disconnects every window's waker, and the driver does so again
  after the loop returns: winit's X11 request_redraw unwraps a send to the
  event loop, which panics once the loop is gone.
- The idle trace counts app events, posted wakes by kind, and window wakes
  routed and dropped, each window against its own last sample.
- redraw_gate's module docs say that a draw wake a producer thread makes
  asks winit itself, unstamped, which only defers pixels.

Tests: `wake_routes_post_draw_wakes_only_off_main`, the core waker test,
and X11 winit-loop claims (k) no WindowWake reaches on_app_event and a draw
wake to a hidden window is owed, not ticked; (l) the waker is installed
before the root is built; (m) on X11 a draw wake posts nothing and a burst
of state wakes posts once; (n) `exiting` alone leaves every waker a no-op
while the loop still exists (checked at every run's `exiting`), and a waker
kept past the loop wakes harmlessly; (i) gains the draws-nothing sync. Each mutation below reddened the loop
test: interception below the app handler; a draw payload answered with a
gated request; no waker before the root builder; no state route; no
per-turn draws-nothing sync; and all three disconnects removed, which
reproduces winit's `EventLoopClosed` unwrap panic.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…scale

`WidgetTree::device_scale_signal` and its `BuildContext` twin (approved)
mirror `device_scale_factor` as a `Signal<f32>`, set by
`set_device_scale_factor` inside its existing change guard.

The setter's rustdoc said "a scale change already triggers a relayout",
which is false: the ScaleFactorChanged arm marks nothing, and
`layout_with_ops` returns early when the logical proposal is unchanged. It
now says a scale change relayouts nothing by itself, and that a widget
whose layout reads `LayoutContext::scale_factor` binds the signal at
`Relayout`; `LayoutContext::scale_factor`'s doc says the same. A bound
widget is reached by post_event's reconcile, which runs `layout` when a
signal is dirty; a tree with no binding pays nothing.

Tests: the signal follows the setter and an unchanged scale notifies
nobody; a scale change relayouts a widget bound to it, at the new scale,
without repainting its child; with nothing bound it marks nothing. Without
the `set`, the first two fail.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
A window moved to a display with another scale keeps its logical size, so
nothing relayouts and `place_children`, which issues `set_bounds` with the
scale an engine positioning in device pixels needs (WebKitGTK on X11),
never ran: the page kept the old scale until its own bounds changed. Its
comment claimed a scale change triggers a relayout, which is false.

`build` binds `device_scale_signal` at `Relayout`; `place_children`
already forces `set_bounds` when the scale changed.

`a_scale_change_reissues_set_bounds` checks one more SetBounds, at the
same logical bounds, after a scale change, and that it carries the new
scale, which `MemoryWebViewRecords::scale_log` (new) reports beside the op
log so `WebViewOp::SetBounds`, an exhaustive public enum, is unchanged. It
fails without the binding, and with a stale scale passed to the engine.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
A state wake (a Layout wake, an accessibility client attaching or acting)
asked the window for a redraw. On a window that draws, that renders and
presents a frame per burst even when nothing visible changed: a terminal
printing in a background tab would redraw its window at the rate its output
arrives. The decision to process such output off-paint makes this the
common case, and "idle GPU and CPU when nothing changes" forbids it.

`RedrawGate::request_tick` asks for a non-visual tick without asking
winit. `take_tick` now runs one for a window that draws too, when no
redraw is on its way (that redraw's frame does the same work); every tick
stays rate-limited to TICK_INTERVAL (100 ms, renamed from
HIDDEN_TICK_INTERVAL) after the window's last frame. For a shown window
that waits for nothing, `deadline` wakes the loop for a pending tick once
the interval allows and never delays a timer. `window_wake` answers a
state wake with `request_tick`; the tick ends in post_event, which redraws
the window only if the tick changed something visible.

The idle trace's `hidden_ticks` becomes `ticks`.

Tests: two gate unit tests (a shown window's state tick asks winit nothing,
is rate-limited and never delays a timer; it waits for a redraw already on
its way) and X11 winit-loop claim (o) (a state wake to a window that draws
asks for no redraw, and its tick lays out). Answering with a redraw, or a
take_tick that refuses windows that draw, reddens (o).

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
… for single widgets

`RepaintTrigger` (Send + Sync, Clone shares it) marks the widgets it is
attached to from any thread and wakes only their windows, through the
tree's redraw waker (now stored in a crate-private `OffThreadRegistry`):

- `request_repaint`: a Draw wake; the paint walker takes the flag right
  before the widget's `paint()`, so only that widget repaints. The pre-pass
  marks a widget only when its paint is cached, so one that is not painted
  (dormant, clipped out) keeps the flag and absorbs a burst in one wake.
- `request_relayout`: a Layout wake, which reaches a window that draws
  nothing; the pre-pass marks the widget and its ancestors. Activation
  takes a relayout requested while the widget slept (the pre-pass skips
  sleepers), and one woken inside the walk by a culling parent is laid out
  again next pass, since it may have been measured before it woke.
- `request_pull` with `BuildContext::on_trigger_pull(hook)`: the hook
  runs in the pre-pass for the widget whether it is active, dormant or
  culled, and returns a `PullOutcome { Unchanged, Repaint, Relayout }`.
  A widget not shown takes its content in without its window drawing a
  frame: a repaint drops its paint caches instead of marking it, and a
  relayout waits for its activation. The wake is a state wake, which
  reaches a window that draws nothing. teksilo-app then asks the tree
  (doc-hidden `off_thread_needs_frame`) whether a widget the window showed
  in its last frame, not transparent by an opacity of its own, has a
  pull or a relayout pending: if so, and the window draws, it asks for a
  redraw, which takes it in at once; otherwise a rate-limited tick. A
  producer cannot decide this: its view of the window is stale, and a draw
  wake it routed to a Wayland window whose frames the compositor had just
  stopped asking for would sit in winit, unseen by the redraw gate, with
  the request coalesced behind it for good (found in review, reproduced on
  a private KWin with an unfocused window minimised, fixed). This is the
  off-paint path the terminal will use (decided 2026-10-05; deviates from
  spec 5.12).

Requests coalesce on a per-widget `WakeFlag` (one wake per window per
request, deduplicated by waker); attaching is register-then-read and
idempotent per (widget, trigger). A widget's wake state outlives its
rebuilds, with whatever is pending on it: a rebuild releases only the
triggers its new `build()` no longer attaches (and the state, if it
attaches none and sets no hook), the widget's destruction releases the
rest, the pre-pass releases orphans, attaching prunes released entries,
and the registry's Drop releases what is left. The pre-pass decides
whether to mark a widget with `WakeFlag::peek`. A waker installed
after a request is woken for it. `RepaintTriggerStats` counts requests,
wakes, coalesced requests, repaints, relayouts, pulls and attachments;
`WidgetTree::repaint_trigger_count` counts widgets with one attached.

Locks and atomics come from `teksilo_canvas::sync`; two loom models run on
the real trigger: register-then-read attach, and a waker installed while a
request is made. The CI loom job adds teksilo-core.

Tests: 12 unit tests, 23 tree tests (B.16-B.23, dormant and mid-walk
relayout, pull for shown, dormant and clipped-out widgets, relayout via
the hook, orphan, prune, late waker, F.1 strong, a pull pending across
a rebuild, a request made during one, a rebuild dropping one of two
triggers, a stale paint dropped while clipped out, a transparent widget)
and a flood test (4 threads x 20,000 requests with rebuilds, frames only
on wakes, no request after the producers'; 20 runs). Each mutation below was
run and reddened at least one test: raise always waking; the walker not
taking repaint; relayout without ancestors; marking without the cache
gate; activation not taking relayout; no mid-walk re-mark; no registry
Drop; destroy not cancelling; non-idempotent attach; no orphan release; no
prune; set_waker skipping existing widgets; no re-wake on install (loom
too); pull always drawing; a hidden pull marking paint; pulls
skipped for sleepers; a rebuild releasing the state; no stale-paint
drop; no opacity check; activation not taking a relayout; stale triggers
kept; never or always drawing a frame for a pending pull; and, for the
flood test, a request that never wakes.

The pull vocabulary (`request_pull`, `on_trigger_pull`, `PullOutcome`,
`RepaintTriggerStats::pulls`, and the doc-hidden `off_thread_needs_frame`)
was chosen in review on 2026-10-06.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…und the queue, blink on deadlines

The terminal no longer posts `RepaintWindowRequest` (which repainted every
widget of its window and redrew every window) and no longer drains its
child's output in `paint()`, which a terminal not painted (background tab,
hidden window, scrolled out) never runs: its output piled up without limit
and its title, cwd, exit status and accessibility went stale.

- The PTY reader thread fills a `ReaderQueue` (new `reader.rs`: one lock,
  one condition, both from `teksilo_canvas::sync`) and calls
  `RepaintTrigger::request_pull()` after each read and at EOF. The queue
  never holds more than 4 MiB (`READER_QUEUE_CAP`): the reader reads at
  most the room left, and with none it waits for space while the child
  waits on its write. Shutdown is a flag under the queue lock, so a reader
  about to wait cannot miss it. The queue is a VecDeque the UI thread takes
  from in chunks (`take_up_to`).
- A pull parses for at most 8 ms (`PULL_BUDGET`), 64 KiB at a time and
  at least one chunk, then asks for another pull for the rest: a flood of
  output never holds the UI thread, its other windows and widgets, for long
  (the paint-time drain this replaces was unbounded).
- The terminal attaches the trigger and registers a pull hook in every
  build. The hook (an `OutputSink` owning the state, signals, callbacks,
  label, bell and touch layer) drains the queue, advances the engine, fires
  the callbacks and signals, re-projects the touch handles, and returns
  `Repaint` only when the screen changed or the bell started. It runs in
  the layout pre-pass whether or not the terminal is shown, so a terminal
  nobody sees keeps its state current and its window draws no frame for
  it (decided 2026-10-05, deviating from spec 5.12's drain in paint).
  `paint()` draws the snapshot and nothing else.
- Direct mutations (clear, scroll, selection) repaint through
  `request_repaint`.
- The caret blink is a pure `frame_step`: it toggles every 500 ms while
  focused in an active window with a caret in view, and schedules the next
  toggle with `wake_at_handle`, instead of re-arming 60 frames a second; it
  repaints through a `paint_tick` signal bound `RepaintOnly`. With no caret
  in view (hidden by the child, or scrolled out) it schedules nothing, and
  a snapshot that brings the caret back restarts it
  (`TerminalState::set_snapshot`). The window's active state is seeded in
  `build()`: its effect fires on changes only, and the paint that used to
  copy it is gone. That also fixes the
  blink and the visual bell, which changed state without marking the
  terminal for paint. The bell asks for frames only while it fades
  (`BELL_FLASH`, 150 ms in both places now), then one last repaint.
- The reader thread always runs, headless included; `spawn_engine` needs
  no poster or window. `PtyReader`'s docs say when a read returns (a
  grandchild holding the PTY keeps it blocked after a kill), and
  `LeaveRunning`'s say when the child gets its hangup: the next time it
  writes, since the blocked reader holds the PTY open until then. Both were
  already so; the docs claimed otherwise.
- `MemoryEngine`'s child is a `MemoryOutput` per spawn (public, Send +
  Sync + Clone): it reads what a test writes, blocks until then, and ends
  on `close`, `kill` or the engine's drop. `MemoryEngineFactory::output()`
  returns the next spawned engine's.

Tests: J.1-J.5 (100 runs each; J.3b waits for the reader to block in its
read, then checks it outlives the terminal while the engine is held; J.5
checks the chain ends when the window goes inactive), a dormant terminal
taking output in without drawing, the queue cap (exact) and resume, a
pull parsing within its budget and asking for the rest, a
reader held at the cap ending with the terminal, the reader thread never
queueing past the cap, frame-step units (a hidden caret stops the blink, a
returning one restarts it), a terminal built in an inactive window not
blinking, the touch handles following output that moves the selection (the
hook's branch, untested until now), the bell's last repaint, reader-queue
units that wait for the reader to block instead of sleeping, two loom
models (backpressure delivers every byte; shutdown wakes a waiting
reader), and a MemoryOutput doctest. Each mutation below reddened at least
one test or model: no notify on take; no cap wait; reads not cut to the
room; no budget, or no pull asked for the rest; the shutdown flag after
the notify (loom); no trigger attach; no
pull hook; a hook that always repaints; a blink that pumps frames, that
ignores the caret's visibility or the window's state, or that never
restarts; no seed of the window's state; no touch refresh in the hook; no
last bell repaint; kill or engine drop not closing the output; dropping
without shutting the reader down.

Live, on a private kwin_wayland --virtual, debug build, idle trace:
- SHELL=/usr/bin/yes, focused, then minimised: visible, 92 frames in ~3 s
  with one posted state wake each and 0 app events; minimised, 0 frames
  and 27 ticks (under ten a second) still taking output in; restored,
  drawing again.
- A child printing numbered lines in a window first made unfocused, then
  minimised: it kept writing (1.1 million lines in seconds 5-10 of the
  minimise, the visible rate), with 0 frames and 39 ticks in ~9 s. Before
  the state-wake routing in the RepaintTrigger commit, it froze there.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
`RepaintWindowRequest` is deprecated since 0.16.0 in favour of
`RepaintTrigger`. Its routing is unchanged: teksilo-app still marks every
widget of the named window paint-dirty and redraws every window. The plan's
first draft narrowed that routing on the premise that no app handler sees
the payload, which is false (an app can post it and rely on the repaint),
so the behaviour stays until the type is removed at the next breaking
release.

- `#[deprecated(since = "0.16.0")]` with a note naming
  `BuildContext::attach_repaint_trigger`; the rustdoc says what it does
  (every widget, every window) and what to use instead.
- The re-export in teksilo-core carries `#[allow(deprecated)]`; teksilo-app
  downcasts the payload in `repaint_request_window`, a helper because an
  `else if let` condition cannot carry the attribute, with a unit test that
  the payload still names its window.
- `WidgetTree::mark_all_needs_paint_only`'s rustdoc no longer presents the
  deprecated request as the way to repaint off-thread content.
- docs: idle-and-animation's "Off-thread repaint" section becomes
  "Off-thread content: `RepaintTrigger`" (attach, request_repaint /
  request_relayout, request_pull with a pull hook, which wake reaches
  a window that draws nothing and who decides whether it draws, the
  deprecated request last); terminal.md and multi-window.md say what the
  terminal uses now and that the request is deprecated, and terminal.md's
  "primitives this widget introduced" says which it introduced; CLAUDE.md's
  terminal entry likewise; architecture §20.2 names the trigger beside the
  event-loop proxy.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
… PTY with the engine

Two defects of the default engine's PTY, found in the PR-3 review and older
than it.

- Writes waited on the UI thread. `Pty::write` was a blocking `write_all`
  on the master, so a child that stops reading its input froze the
  application: a paste into a stalled child, or a child flooding status
  queries (DSR, DA) whose replies the engine writes back from inside
  `advance`. Writes now go to an input queue drained by a writer thread
  ("teksilo-terminal-pty-input"); the queue holds 4 MiB at most
  (`INPUT_QUEUE_CAP`), and what a child that never reads would push past it
  is dropped. portable-pty's writer is no longer used on Unix: its drop also
  wrote newline + EOF with a blocking write.
- The PTY outlived the engine. The reader was portable-pty's dup of the
  master, so while the terminal's reader thread was blocked in it the
  master stayed open: a `LeaveRunning` child got no hangup until its next
  write after the terminal went, and after a kill a background process the
  child had started (a grandchild still holding the child's side) kept the
  reader thread alive. On Unix the reader and the writer now use
  descriptors of their own on the master, non-blocking, each polled with a
  stop pipe whose write end `Pty` closes on kill and drop: the read returns
  the end at once, the writer abandons what is queued, and the master
  closes with the engine, which hangs the child's session up. On Windows
  the pseudoconsole closing with the engine ends both, as before.

The reader thread, once the terminal is gone, now reads on and discards
what it reads until the end of the output instead of stopping: a child left
running is not blocked on its writes by a terminal that no longer exists,
and a Windows pseudoconsole, whose close can wait for its output to be
drained, can close. `PtyReader`'s docs say a reader should return the end
once its engine is killed or dropped; `LeaveRunning`'s say the child is hung
up as the engine drops, which is now so.

Tests: four real-PTY tests (`tests/pty.rs`, Unix with the alacritty
backend): a child left running is hung up when the engine goes while a
read is blocked; a kill ends the read while a HUP-ignoring grandchild holds
the PTY; writing 8 MiB to a child that never reads returns at once; input
reaches the child and its reply comes back. Against the previous pty.rs the
first three fail (10 s timeouts, and a write that waited 1.6 s); the fourth
guards the round trip. Unit tests: the input queue's cap and close, a
writer released by the close, the reader thread draining to the end once
the terminal is gone, and the reader held at the cap now draining and
ending with the engine. Checked for Windows and macOS with cargo check and
clippy (x86_64-pc-windows-msvc, aarch64-apple-darwin).

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
vte holds the output of a synchronized update (DEC private mode 2026,
`ESC [ ? 2026 h`) in a buffer until the child ends it, or until its owner
ends it once the 150 ms timeout has passed: `Processor::sync_timeout()`
reports the deadline and `stop_sync` releases the buffer. The engine never
asked, so a child that crashed or was killed mid-frame left what it printed
invisible until more output arrived. Found in the PR-3 review; older than
it.

- `TerminalEngine` gains two defaulted methods:
  `synchronized_update_deadline() -> Option<Instant>` and
  `end_synchronized_update()`. The alacritty engine answers from vte and
  ends the update with `stop_sync`, pumping the events it produces; an
  engine that holds nothing back keeps the defaults.
- The pull hook ends a synchronized update whose deadline has passed
  before taking new output, records the engine's deadline after it, and
  schedules a wake for it. The frame step, run when the wake fires, asks for
  a pull once the deadline is due, which the off-thread pre-pass of the
  same layout pass takes; and it re-arms the deadline beside the caret
  blink's, since the tree's wake slot is shared and emptied when it fires.

Tests: the frame step waking for the deadline (alone and before the blink)
and asking for a pull once due; a terminal showing an unended update at
its deadline (MemoryEngine, through test-only fields); the alacritty engine
holding `ESC [ ? 2026 h` output back with a deadline and showing it once
ended. Each mutation reddened its test: the hook never ending the update,
the frame step not re-arming the deadline, no pull requested, and the
engine's end a no-op. Clippy also checked for Windows and macOS.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…rimary flag

Two ways injected pointer input differed from what the platform translator
produces, each visible to a handler.

The mouse arm built its events with the legacy constructors, whose pointer is
the singular mouse holding nothing. A real mouse reports the buttons it holds on
every sample: the press adds its own, a move keeps them, the release takes its
own away. So `ctx.pointer().buttons` lost the button in the middle of an
injected drag, and `query_pointers` showed the dragging mouse as up. The
executor keeps no state between ops and a drag is three of them, so the held
mask is read back from the tree's pointer table, which records every sample's
`PointerInfo`. `ButtonMask` has no difference operation; a release intersects
the held mask with every other button, and a debug assertion fails if that list
stops covering `ButtonMask::ALL`.

Touch and pen samples were built with `PointerInfo::touch`, whose W3C
`primary` flag is clear, so no injected finger was ever primary and a widget
that serves only the primary contact (the splitter and dock resize handles)
ignored every finger a probe put down. The flag now follows the translator: a
finger is primary when no other finger is live, a stylus when no finger is. It
is decided when the pointer enters the table and read back from the table
afterwards, as the translator stores it on its contact and on its pen session,
so the second finger of a gesture is not promoted when the first one lifts.

Spec I.1 and I.2. `test_api`'s touch and pen helpers still leave the flag
clear, as `PointerInfo::touch` documents for a producer that does not know the
sequence; the executor's comment now says where the two part.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…ject_key calls

`type_text` sent one `Key::Character` press per character and no release.
Real typing is neither: the platform translator reports an ASCII letter as its
named key (`Key::A`..`Key::Z`) with Shift held for a capital, a space as
`Key::Space`, and every press is followed by its release. So a widget tracking
held keys saw every typed key stay down, and a letter shortcut, or a widget
matching `Key::A`, never saw a typed letter at all.

Typing now goes through one door, `WidgetTree::type_text_with_ops`, which both
the automation bridge and `WidgetTree::type_text` call, so a widget test and a
probe type alike. Each character is a press carrying the character as its text,
then the release: a letter as its named key with Shift for a capital; the five
characters a named key types (Space, Tab, Backspace, Escape, Enter, whose text
`Key::to_text` gives) as that key, with `\n`, `\r` and `\r\n` each one Enter;
anything else as its own `Key::Character` with no modifier, since which
modifier types a symbol depends on the layout. Breaking for a caller that
typed a tab or a line break expecting the character: it now presses the key,
which moves focus or splits a block as it would for a user. Text that must
arrive without keys goes through `type_ime`.

`inject_key` gains `text`, what the press types (default: the platform's own,
unchanged), and `phase` (`press`, `down`, `up`; default `press`), so a probe
can hold a key across other input or repeat its press. A `text` on an `up` is
refused, since a release carries none. Key names now cover every named key by
its `Display` name, the name `get_shortcuts` reports a chord by: `Ins`, `Menu`
and `F13`..`F24` were refused before; `insert` and `contextmenu` also resolve.

Audit before landing (spec I.3's precondition): no curated probe, skribisto or
skribisto-pro script types into a focused Button, which acts on a Space
release; every `type_text` there targets a text field or an editor, and none
types a tab or a line break. Widget tests that call `type_text` pass
unchanged; two automation fixtures that inserted only `Key::Character` presses
now insert the press's text, as a real field does.

Also: three error messages whose line continuation had lost its backslash
carried a run of spaces in the middle.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
A probe aimed inside a widget by adding the widget's origin to an offset:
`bounds.x + x`. That is where the handler's local point is only when no
transform sits above the widget. Under a `Scale` or a `Rotate`, or inside a
`SceneView`, the press landed somewhere else, and nothing said so.

Core: `WidgetTree::window_to_local` is the conversion the dispatcher applies
before a handler sees a position, and `WidgetTree::local_to_window` its exact
inverse, content-transform branch included. `EventContext::to_local` is the
same conversion inside a handler, for the positions a handler is handed in
window space (`Scroll` and `PointerCancel`'s `window_position`, `coalesced()`,
`pointer_position()`); `None` outside per-node dispatch. All three rest on a
crate-private `LocalFrame` (the transform, its inverse and the origin), which
`local_pointer_position` now uses with the same arithmetic it always did, so
no position a handler receives moves by an ulp. Each per-node dispatch builds
the frame once and uses it to localise the event and to answer `to_local`; a
gesture's positions are converted with one walk up the tree rather than one
per point. A singular transform keeps the fallback localisation always had (no
transform), and its inverse does the same, so the two stay each other's
inverse.

Automation (spec 9.6.10, I.4, I.5): `inject_pointer`, `long_press` and each
`inject_touch_sequence` step take `node`, which makes `x` and `y` local to that
node and sends the press at `local_to_window`. A synthetic node (a scene item,
a text run) is refused with BAD_ARGUMENT naming its owner, since a point local
to it would silently be local to its owner's frame. A touch sequence resolves
every node it names before its first step, so a missing one dispatches
nothing. `scroll` takes `at` (node-local) and `lines`, which sends
`ScrollDelta::Lines` reported as `ScrollSource::Wheel`, what a notch is.

Breaking (behaviour): `scroll` without `at` now aims at the centre of the
node's resolved bounds, where `right_click` and `drag_node` already aim,
rather than the centre of its untransformed arena box. The two differ only
under a transform. Breaking (Rust): the new DTO fields break struct-literal
construction of `InjectPointer`, `LongPress`, `Scroll` and `TouchStep`;
`x`/`y` stay required and their wire meaning is unchanged without `node`.

`tools.py` and SERVER_INSTRUCTIONS are regenerated; the `source` aiming and
`live_image_map` of 9.6.10 belong to LiveImage and come with it.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
… ImageOrientation

Every widget that shows a raster decides the same three things: how the
source is turned for display, where the turned picture lands in the box,
and, for input, which source pixel a point shows. ImageWidget computed the
second privately; nothing computed the third. LiveImage needs all three in
one place, and so does automation, which depends on canvas but not on
widgets (spec 4.2, 9.6.10).

`teksilo_canvas::image_geometry` holds them:
- `ImageFit`, moved from teksilo-widgets (which re-exports it, so every
  path still compiles), gains `fitted_rect`, ImageWidget's private fit made
  public and shared. ImageWidget now calls it, with no change in behaviour.
- `ImageGeometry::compute` places a source in a box; `snapped` moves the
  picture's edges to the device grid. It takes the widget-local to device
  transform rather than the spec's (window origin, scale) pair, so a picture
  under an ancestor's axis-aligned scale snaps where it lands on screen;
  under a rotation or shear it is left as it is, and an axis snapping would
  collapse keeps its edges.
- The mapping is the floor rule on half-open pixel squares of spec 8.3:
  `map_to_source`, `map_to_source_clamped`, `map_to_source_f32`,
  `map_from_source`. The window-space variant of the spec is not here: PR-4's
  `WidgetTree::window_to_local` and `EventContext::to_local` convert a window
  point into the handler space these take, under any transform. All mapping
  methods return `Option`, `None` with nothing to show, so a zero-size
  source cannot underflow.
- `oriented_crop` gives the visible part of a picture and its corner texture
  coordinates from the same orientation tables, so drawing and mapping
  cannot disagree.
- `PixelRect`, the rectangle of source pixels the mapping and the live
  source's damage share; its edge arithmetic is done in u64.

`exif::Orientation` becomes `ImageOrientation` (decision 4), moved beside the
geometry, keeping `Normal` and `from_tiff`, gaining the strict `from_exif` and
`displayed_size`; `Orientation` stays as a deprecated alias.

Tests: the spec's worked values (8.4, both tables), every orientation against
`apply_orientation` as the oracle, C.9's sweep (every fit, both directions,
every orientation, scales 1, 1.25, 1.5 and 2), and five properties
(tests/prop_image_geometry.rs): a pixel's displayed centre maps back to it,
snapping is idempotent and on the grid within half a pixel, the three
mappings agree inside the picture, crop corners sample the mapped points, and
`PixelRect` union and intersection bound their operands. Eighteen mutants
of the tables, the snapping, the fits and the rect arithmetic each turn a
test red.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
The producer half of LiveImage (spec 4.1, 4.5, 5): `teksilo_canvas::live_image`.
A `LiveImageSource` holds one frame, latest wins; producers write it through
`LiveImageWriter`s, in transactions only `commit()` publishes; each attached
widget (a `LiveImageConsumer`, one per widget per window) is woken once per
burst through its window's `RedrawWaker`.

State and lock. One lock guards the buffer, size, stride, status, writer
count and session, generation, the damage log and pending damage. It is a
`parking_lot::Mutex` (`try_lock_for` bounds a renderer's wait, `unlock_fair`
hands the lock to a parked one), reached only through a wrapper whose every
release runs the busy-lock handoff, so a renderer that found the lock held
by `writer()`, a session drop or a size hint is woken too, not only by a
transaction's end (plan correction). Status and size are mirrored into one
`AtomicU32` (2 + 15 + 15 bits, const-asserted) for the lock-free pre-pass,
the generation into an atomic, and every stat into atomics, so `stats()` and
`Debug` never wait.

Wake flags, made loom-exact. Per consumer: `geometry` (status and size,
taken by the pre-pass, woken with `WakeKind::Layout`, which a hidden window
still receives), `pixels` (commits, taken by the live pass, `Draw`) and
`handoff`. All are PR-3's `WakeFlag`, acquire-release swaps on one atomic, so
the argument needs no SeqCst. The handoff departs from the spec: a reader
that finds the lock held raises its own flag and the source's, then retries
once; every unlock takes the source's flag and wakes the consumers whose
flags it finds. The retry is what makes it exact even though parking_lot's
failed `try_lock` is a Relaxed load: if the unlock's swap comes first, the
registration happens after the unlock and the retry sees it released; if
not, the unlock reads the registration. Wakes are deduplicated per window,
with the larger kind, and called after every lock is released;
`catch_unwind`-guarded when a writer drops during a panic.

Sessions. `writer()` joins the session (`Disconnected` to `Waiting`),
`writer_exclusive()` revokes every older writer (`Revoked`); the session's
last token frees the pixels and disconnects. A token dropped on a thread that
holds a transaction on the source cannot lock: it leaves its release to that
transaction, which applies it as it ends (thread-local list keyed by
`LiveImageId`). Every other locking call there panics naming the source;
`try_lock` reports `WouldBlock`, a renderer read is busy (an async task
suspended in a transaction on the UI thread), so nothing waits for itself.
Lock order (state, consumer list, waker slot) is checked in debug builds.

Damage. A commit records at most 16 rects (more widen to their bounding box)
or Full when they cover half the frame, in a ring of 16 entries held inline
(no allocation per commit or plan); each records its `Instant` in every
build. The planner of spec 5.5 gives a consumer at generation c the union of
c+1..=L, exact duplicates once, Full past 16 commits, 16 rects or half the
frame (union area computed exactly over at most 16 rects). A resize, a
freed buffer, a new session and a transaction a panic abandoned make the
next commit Full.

`attach` is register-then-read: the consumer is published to the source's
list before it records the meta it lays out with, and prunes the list, so a
one-commit source attached on every rebuild cannot grow it. Size limits
(`MAX_DIMENSION` 16 384, `MAX_BYTES` 1 GiB) are checked in u64; the buffer
grows by `try_reserve_exact` (`OutOfMemory`) and reuses its capacity.

API deviations from the spec, for review: `ImageFilter` is `ScalingFilter`
(decision 4); stats split into `LiveImageSourceStats` and
`LiveImageAttachmentStats` (with `LiveImageStats` holding both), all
`#[non_exhaustive]`; `LivePixelFormat::bytes_per_pixel()` replaces the const;
`set_size_hint` returns `Result`, and the builder's `size_hint` sets nothing
for a size that would refuse; `RejectedFrame` and `LiveImageError` are
`thiserror` errors. The guard is `!Send` through a marker of its own (no
direct lock_api dependency), with a compile-fail doctest pinned to E0277 and
a `Sync` doctest; the root `clippy.toml` sets `await-holding-invalid-types`.
The renderer side of the consumer is `#[doc(hidden)]` here; the live-pass
engine of the next commit narrows what only it uses.

`parking_lot` joins `[workspace.dependencies]`; 0.12.5 is already in the
lockfile. Tests (A.1-A.16, A.18): errors and their no-change guarantee,
transactions, panics, copies, the status machine, sessions, swap identity,
reentrancy, the planner, flags, the handoff, attach and detach, render
records; stress runs with four producers × 10 000 commits against a
mirroring consumer and two threads × 50 000 writer drops and joins. loom
models: a commit is uploaded or wakes, a size change reaches layout or sends
a Layout wake, attach registers then reads, a busy read is handed off, the
last writer's drop races a new writer. Each model and 24 headless mutants
turn red under the mutation they name.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…r and the mirror

The draw half of LiveImage (spec 4.2, 6.1-6.4) and the plan's single
engine.

Frame. `Canvas::draw_live_image(consumer, &LiveImageDraw)` pushes a
`LiveImageQuad` into the new `RenderFrame::live_images` and a
`DrawCommand::LiveImage`. A quad carries the consumer (an `Arc`), the
visible rect and its corner texture coordinates from `oriented_crop` (so
`Cover` and oversized `None` crop without a scissor), the filter, the paused
bit, and `painted`: the size the window's last layout pre-pass recorded
while `Live`, never a fresher atomic, so it agrees with the layout it is
drawn in. A picture that does not show still emits a quad (painted `None`),
because the renderer takes a widget's pixel flag only for quads in the
frame. `merge` shifts the new command and `draw_render_frame` offsets the
quads. `LiveImageDraw::new` with setters, and both types `#[non_exhaustive]`.

Engine. `internal::LivePass<B: LiveTextureBackend>` is the live pass,
written once (plan correction: the spec's mirror would have duplicated the
renderer's decisions). teksilo-render implements the backend over wgpu in a
later commit; `live_image::testing::LiveImageMirror` is the same engine over
`Vec<u8>` textures, so headless tests run the renderer's own rows. Per
source and frame, in the order of spec 6.3: flags taken (parked for a paused
quad) before any generation is read; the wanted size, chosen
deterministically when one frame paints two (the source's own, else the
larger); an oversized side, a refused creation within its one-second
backoff, and a lost device draw what they can and park the pixel flags, so
a broken source costs no redraw per commit; an all-paused source with a
texture of the wanted size keeps it without locking (never blank
otherwise); a staged texture created outside the lock; no lock when nothing
is new; then `try_read`, or a bounded wait (one refresh plus the time a
2 GB/s copy of the frame takes in a presented frame, a second in a capture)
for a staged texture, after two busy frames, and in every capture; a busy
lock or a buffer of another size than the layout draws the held texture;
under the lock, the staged texture filled whole and swapped in, or the
plan's rects, in bands of at most 8 MiB of staging. The engine returns a
decision per quad (draw the texture or not, deferred, the generation it
shows), which the draw and the screenshot reply both read.

Staging budget (decision 2). A presented frame spends at most 128 MiB on
whole-frame fills; a larger fill spreads over several frames, waking the
window for each, while the previous picture keeps drawing, and its last
frame writes the damage since its first band under the same lock, then
swaps. If that damage is the whole frame it restarts, at most twice, then
completes in one frame. A plan into the current texture larger than the
budget fills a second texture the same way rather than tear it; without
memory for a second texture, the next frame uploads in place. A capture
finishes every band before the draw.

Release (decision 3). After each pass the textures are those of the
sources the frame drew. A source's texture of at most 4 MiB, live and
committed once (the one-commit images of a later commit), moves to a 16 MiB
pool of parked textures that hold only a `Weak` to the source: an image
scrolled out and back is not uploaded again. A dead source's parked
texture is freed at the next pass, the oldest past the budget first.
`LiveTextureStats` reports `textures`/`bytes` and
`textures_parked`/`bytes_parked` separately; `set_park_budget(0)` restores
the strict rule. Consumers that were in the last frame and are not in this
one become unobserved and unpaused, and dropping the pass (the window
closing) unobserves the rest.

Records: a capture counts in `captures`, never in `frames_drawn`, and never
raises `displayed_generation`; a lost device advances nothing, so a probe
sees the stream stall. The consumer methods only the engine calls are
crate-private again.

Breaking: `RenderFrame` is `#[non_exhaustive]`, and `DrawCommand` gains
`LiveImage` (it stays exhaustive, so the renderer's match keeps its check;
this commit's arm draws nothing until the renderer holds live textures).

Tests: 30 engine scenarios on the mirror, every row above, the decision
table, the park pool's size cap, eviction and dead sources, pause mixing and
"never blank", capture through a pause and a held lock, the allocation
backoff, the lost device, spread fills with a commit during them, a restart
against a full-frame producer, the second texture and its in-place fallback,
bands, and the frame plumbing (A.17, A.19, C.17). 24 mutants of the engine
each turn one red.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…ugh the pre-pass

The tree half of LiveImage (spec 4.3, 5.6, 7.12): the live-image kind of
the off-thread registry PR-3 introduced for `RepaintTrigger`.

`BuildContext::attach_live_image(source, &LiveImageSignals)` attaches a
source to the widget being built, in this window, with the window's waker,
and returns a `LiveImageAttachment` (an `Rc`): its consumer is what the
widget hands `Canvas::draw_live_image`, and it records the placement the
widget's `place_children` computes, for automation. `LiveImageSignals`
(`frame_size`, `status`) are owned by the widget from construction and
written by the attach and by the pre-pass only; the widget binds them
(`Relayout` for the size).

Register-then-read at attach: the source publishes the consumer before
reading its meta, which the attach writes into the Signals (`set_if_changed`)
and the consumer records for paint. A widget built after this frame's
pre-pass, under a `Rebuild` binding, or attached to a source that will never
commit again, lays out and draws in its first frame (B.13).

The pre-pass (`poll_off_thread`, triggers first, then live images, before
the idle early return) takes every attachment's geometry flag, active or
dormant, then reads each source's meta once, records it in every consumer
of that source and writes the Signals. Two phases: everything is collected
before a Signal is set, so no registry borrow is held while observers run.
An attachment whose widget left the arena without the destroy path is
detached (self-healing, as for triggers). A pixel-only commit marks nothing,
so `render` returns the cached frame and the renderer uploads (AC3).

Lifecycle: a rebuild detaches the widget's attachments and its `build()`
attaches anew (the renderer keys textures by source, so nothing is
uploaded again); destruction and the tree's drop detach them;
`set_redraw_waker` reaches existing consumers and wakes them for what was
raised without one. `off_thread_needs_frame` now also answers for a live
image whose size or status changed: a shown widget draws a frame, a
dormant one gets the frame that draws nothing and still learns its size.

Lookups through the registry's widget-id index, so a `WidgetBuilder`
wrapper does not hide them: `live_image_attachment_count`,
`live_image_consumer`, `live_image_stats`, `live_image_geometry`,
`live_image_attachments`.

Tests on the renderer's live pass with memory textures: B.1 (rebuild keeps
the texture), B.2, B.5 (dormant: one wake, size still current), a clipped
widget's bursts wake nobody, B.6 (exactly one further wake for a commit
between paint and the live pass), B.7, B.8, B.10, B.11 (AC5), B.12 through
a wrapper, B.13, the attach writing the Signals, a late waker, orphans, and
AC1-AC4 (one wake per hundred commits, no paint, the cached frame replayed,
nothing posted). B.3/B.4 are PR-1's; B.9 lands with the widget, B.14/B.15
with the pause. Eleven mutants each turn a test red.

While writing B.5: a culling parent that parks a child in a pass that marks
nothing for paint leaves the parked child in the replayed frame (the
composed-frame cache is not dropped on that path; `WidgetTree::set_dormant`
drops it). Every culling widget in the workspace repaints when it re-culls,
so nothing shows it today; the test's parent repaints as they do.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…ealth and reclaim

The wgpu half of LiveImage (spec 6, 9.6): `LivePass<WgpuBackend>`, the
engine the mirror already runs on memory textures, now runs on every
render before anything is recorded, so its writes land in the frame's
submission.

The backend creates `Rgba8UnormSrgb` textures (one level) inside an
`OutOfMemory` scope nested in a `Validation` one, so a refused texture is
a value (`TextureOutOfMemory`, the engine's backoff row) and never wgpu's
panicking default handler; the view and bind groups are made only for a
texture that exists, when it is first drawn. A write is one
`write_texture` straight from the locked buffer at the source's stride,
with the band's x and y in the offset. Band rows are costed at
`COPY_BYTES_PER_ROW_ALIGNMENT`, which bounds the copy pitch wgpu stages
at on every backend. Two samplers (linear, nearest) owned by the backend;
`Trilinear` samples linearly until the mip chain lands.

The quad shader gains two flags, so no producer converts pixels: 4 swaps
red and blue for a BGR source, 2 ignores the fourth byte of an RGBX one.
The draw arm flushes the batches and draws the quad alone with its own
bind group, only when the engine decided to draw it (the held texture has
the quad's painted size, so a picture is never stretched across a size
change). A quad mapping one texel to one device pixel under a transform
with no rotation or scale snaps to the pixel grid, as glyphs do.
`stream_quad_counts` counts live quads.

`Renderer::render_capture` runs the live pass in `Capture` mode and
`render` in `Present`, through one private function.

Device health. A device lost callback is the only report wgpu gives, and
one device holds one callback. The spec keys a process-wide latch by
`wgpu::Device`; that is unsound in wgpu 30: `Device`'s `Eq`/`Hash`
compare the wgpu-core id only (`impl_eq_ord_hash_proxy!(CoreDevice =>
.id)`), and two instances in one process hand out the same ids, so a
registry keyed by device lets one device's loss mark another (the private
device test hit it). The latch is therefore carried with the device:
`DeviceHealth::install` sets the callback where the device is opened
(`shared_gpu_for` in teksilo-platform, test_support's shared device) and
every renderer on that device takes the same latch through the new
`Renderer::with_device_health`; `Renderer::new` installs its own. A
`Destroyed` loss (the process destroying the device on purpose) is not a
loss. For review: `DeviceHealth` and `Renderer::with_device_health` are
public API the plan did not list.

Reclaim. A dropped texture is freed at the device maintenance after the
last submission using it; a window that stops drawing submits nothing
more. A render that dropped one flags its device (by latch) with that
submission, as does a renderer's drop; `poll_gpu_reclaim` polls each
flagged device without blocking (`PollType::Wait` with a zero timeout)
and keeps only those still on a timeout. teksilo-app calls it in the next
commit.

Tests, all on lavapipe and RADV: D.1, D.3-D.7, D.9, D.10, D.12, D.14-D.19
(D.16 also checks that a texture refused at a new size draws background
rather than the previous size stretched), the mirror against the GPU on
one frame sequence, a frame holding only a live picture, one inside a blur
subtree, 1:1 snapping, a spread fill, and, on a device of its own (a
separate binary, one test, so no other renderer flags it): the reclaim
poll emptying with no frame, a shared latch across two renderers, a lost
device stopping the counters, and a destroyed one not lost. Twelve
mutants (byte swap, opaque, any-size draw, ignored UVs, one sampler, no
snap, no reclaim flag, capture as present, unpitched bands, x-less offset,
no OOM path, lost ignored) each turn a test red.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
With an assistive technology attached, teksilo-app throttles the
delivery of move-only accessibility updates to ten a second. The throttle
looked at the time since the last delivery and nothing else: a frame
drawn within 100 ms of it held the tree back and asked for a wake 100 ms
after that delivery, even when the tree was what the adapter already
held, and that wake drew a frame whose delivery handed the adapter a copy
of its own tree, which it compares node by node to find nothing. Any app
that changes now and then drew each change twice for a screen-reader
user: at 10 Hz, a live picture's commit cost two frames and four timer
wakes instead of one frame.

`WidgetTree::accessibility_delivery_owed(window_focused)` (doc-hidden)
says whether a delivery would hand the adapter anything it does not hold:
the tree changed since the last delivery (a walk with new content, a node
moved), the window's focus did, or nothing was delivered yet. The
decision is now `a11y_delivery`, a pure function: the adapter's request
for the whole tree is delivered; otherwise nothing is done unless
something is owed, and only then does the throttle deliver or hold back
for a frame. A walk that found the same tree owes nothing either.

Measured with the bench at 10 Hz in the private session, accessibility
on: 10 frames and no timer wake for 10 commits, from 20 and 40. Four
reader scenarios with Orca (scroll reveal, slider keys, the calendar's
announcer, toggles) pass all 31 checks; scroll reveal and slider keys
give the same verdicts, step for step, as before the change.

The design record also corrects a reading of its own: commit-to-upload
latency follows the phase between the producer's clock and the
display's, which varies between runs (p50 2.2 to 13.9 ms), not whether
accessibility is on.

Tests: what is owed after nothing, a delivery, a focus change, an idle
frame, a move, a walk that changed nothing and new content; the decision
for every owed / not owed case, the throttle's hold and its end, a walk,
the first delivery and a full-tree request. Each fails with `owes`
answering yes always, or with the not-owed case removed.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…nced

The Wayland tablet shim reads its queue on a thread of its own, so after
an external wake the event loop arms a look 4 ms later for packets that
thread may still be turning into pen samples. It armed that look after
every external wake on every window, as long as the compositor offers
the tablet protocol, which a compositor with tablet support does whether
or not a tablet is attached (KWin here, with none). A seat announces a tool before the tool can be in proximity,
and the shim already stands its own thread down to four looks a second
until one is: with no tool, the look finds nothing and costs the loop a
second turn per wake. A live picture at 60 Hz paid 60 of them a second.

`WaylandPenSource::polls_off_thread` now answers whether a tool is
announced, and the trait's documentation says why a source with a thread
may answer no. Once a tool is announced the next tablet event, which
wakes the loop itself, arms the look as before.

Measured with the bench at 10 Hz in the private session, accessibility
off: no timer wake for 10 commits, from 10.

Tests: the answer follows the announced tool, on and back off; the
source's contract test now asks with a tool announced. The first fails
with the old answer restored.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
A perf profile of the UI thread with full 1080p frames, one window and
then two, puts 94 % of write_texture's samples in the copy into wgpu's
staging buffer and at most 0.6 % in allocation: AC13's two-window
overrun is the second 8 MiB copy per commit, not staging churn, so the
staging ring's gate does not trip. The record says so, and names the
change that would remove the second copy (a live texture per device,
which every window shares, instead of per window), not decided.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…rent one fits

A resize stages a texture of the new size outside the lock and fills it
under the lock. When that fill meets a producer's transaction it waits
for a later frame, and if the size goes back to the current texture's
before then (8 x 8, then 4 x 4 blocked, then 8 x 8), the current texture
fits again. The live pass dropped a staged texture of the wrong shape
only when the current one did not fit, so it kept the 4 x 4 texture and
filled it with the 8 x 8 frame: the mirror panicked on the write, and
wgpu refused it as a texture overrun.

The pass now drops a staged texture of another shape than the wanted one
first, whatever the current texture is, and releases it; a staged texture
that fits is a fill in progress beside a current texture of the same
size, and stays. Rule 5 of `LivePass`'s documentation says so.

Tests: the sequence above through the mirror, which panicked before, and
on the GPU (D.5's resize with the fill blocked), which hit wgpu's
`TextureOverrun` on `write_texture` before; both now show the 8 x 8
frame from one texture. Run on RADV and lavapipe.

No CHANGELOG entry: live pictures are new in this release.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…st trigger

When a rebuild's `build()` no longer attached any `RepaintTrigger` and
set no pull hook, `finish_off_thread_rebuild` released the widget's wake
state through `cancel_by_widget`, the destruction path, which also
detaches every live image of the widget, the ones that same `build()`
had just attached included. The widget's picture then stopped: commits
woke nothing, and its size and status no longer reached its layout
signals.

Releasing the wake state is now a step of its own, `release_triggers`,
which the rebuild uses; destruction and the orphan sweeps still release
both.

Test: a widget attaching a trigger and a live image, rebuilt without the
trigger: the trigger is released, the attachment is kept, a commit wakes
the window and uploads, and a resize reaches the size signal. It failed
with no attachment left before the change.

No CHANGELOG entry: repaint triggers and live pictures are new in this
release.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
With `device_pixels(true)`, a `LiveImage`'s box is the picture's size at
one source pixel per device pixel: an 8 x 8 source is 4 x 4 logical
pixels at scale 2. The placement was then computed by
`ImageGeometry::compute` from the source's 8 x 8 pixels, as logical
pixels. `ImageFit::None` drew the picture at twice its device-pixel size,
so only its central quarter showed in the box, and `ImageFit::ScaleDown`
drew it at twice that size in a larger box: the documented "every texel
lands on one device pixel" did not hold.

The picture is now fitted from its natural size, the same one the box is
measured from (`fitted_rect` of the natural size, then
`ImageGeometry::new`), so `None` draws all of it at one texel a device
pixel and `ScaleDown` never grows it past that. `Fill`, `Contain` and
`Cover` do not depend on the content's absolute size and are unchanged,
as is every fit without `device_pixels`. `device_pixels`' documentation
and the design record say so.

Test: at scale 2, an 8 x 8 source with `None` in its natural 4 x 4 box
draws all of it (content = bounds, full texture coordinates); with
`None` and `ScaleDown` in a 10 x 10 box it is centred at 4 x 4; with
`Contain` it fills the box; without `device_pixels`, `None` is centred at
8 x 8. It fails with the placement fitted from the pixel count again.

No CHANGELOG entry: live pictures are new in this release.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
`LiveImage::pixel_snap(false)` left the placement where the layout put
it, but the renderer snapped every quad drawn one texel to one device
pixel under a translation, whatever the widget asked. A picture whose
position is animated by a fraction of a pixel per frame therefore moved
in whole-pixel steps, which is what the option exists to avoid.

`LiveImageDraw` gains `pixel_snap` (field and setter, default true) and
`LiveImageQuad` carries it; the renderer snaps a one-to-one quad only
when it is set, and `LiveImage` passes its own. `live_quad_verts` now
takes the quad, which also keeps it within clippy's argument count.

Tests: on the GPU (RADV and lavapipe), a red-then-blue 2 x 1 picture at
x = 0.25 drawn one-to-one blends its two texels at pixel 1 with the snap
off, and is the blue texel there with it on; `LiveImage::pixel_snap(false)`
draws a quad that says so, and the default one that may be snapped. The
GPU test fails with the renderer snapping regardless, the widget test
with the widget not passing its setting.

The CHANGELOG's `LiveImageDraw` entry (new in this release) names the
option; the guide, the design record and `pixel_snap`'s documentation
say the renderer follows it too.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…s transform changes

`LiveImage` snaps its placement to the device-pixel grid in
`place_children`, through its effective transform times the device
scale. A transform scope is bound at `RepaintOnly` and lays nothing out,
so after an ancestor's transform changed, the picture kept the snap of
the transform it was first laid out under: 10.3 logical pixels stayed 10
wide under a 2x scale instead of 10.5 (21 device pixels), and the paint,
replayed from its cache, drew that stale placement.

The attachment now records the transform the placement snapped under
(`LiveImageAttachment::set_snapped_under`, `None` for a picture that does
not snap), and the layout pre-pass, which runs before the idle early
return, lays an active widget out again when its effective transform has
changed and either the new one or the recorded one snaps
(`ImageGeometry::snaps_under`, the predicate `snapped` now uses). Two
rotations, under neither of which anything snaps, lay nothing out, so a
rotation animation keeps costing paints only; and a picture with
`pixel_snap` off records no transform, so it is never laid out for one.

Tests: the reported case, a 10.3 x 7.7 picture under a parent whose
scale goes from 1 to 2, re-placed at 10.5 and painted from it; and,
through a widget counting its placements, a translation and a turn to or
from a rotation re-place it, a frame without change and a rotation to
another do not, and a picture that does not snap is never re-placed. The
first fails with the pre-pass mark removed; the second also with the
rotation guard removed.

The CHANGELOG entries of `ImageGeometry` and `LiveImageAttachment` (new
in this release) name the additions; the guide and the design record say
the snap follows a transform change.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
Two windows showing one source each copied every commit out of the
source's buffer into wgpu's staging under the source's lock: two 8 MiB
copies per 1080p commit on the UI thread, which put AC13 over its goal
(lock hold p99 1.66 ms against 1.5) and doubled the UI thread's CPU.

Each window keeps its own texture. After each render a live pass
publishes its current textures, with the generation each holds, to a
table its device carries (DeviceTextures, held by DeviceHealth, which
every renderer on SharedGpu shares). A window whose texture lacks the
latest commit, which another window's texture of the wanted size already
holds, records a texture-to-texture copy of level 0 at the head of its
frame's encoder, before its mip passes, and takes no lock. It copies the
whole frame, since knowing what changed would need the lock; on the
device that costs far less than the copy into staging it replaces. A
staged texture is filled so when the current one does not fit, and
dropped otherwise, so a fill spread over several frames finishes at once.

The queue orders it: the other window submitted the writes that filled
its texture with its own last frame, and the copy is in a later
submission. Only passes that published from the copying pass's thread are
copied from, since a window rendering whole on its thread, from the live
pass to the submission, is what keeps another from writing the texture in
between; every window renders on the main thread.

A texture per device instead would also save GPU memory, but would break
the per-window pause (AC17), two windows at two sizes during a resize,
and the per-window lifecycle and stats; it was not done.

Measured with live-image-bench, full 1920x1080 frames rotated every
5 s in two windows, private KWin at 60 Hz, RADV, 30 s to 70 s: the
window that uploads holds the lock 1.00-1.04 ms at p99 over three runs
(1.61-1.69 ms without the copy, two runs of the same binary the same
afternoon; one window alone 1.05-1.12 ms), the window that copies takes
no lock and its live pass 26-30 us at p99, and the UI thread went from
12.0-14.6 % to 8.9-10.1 % of a core. GPU memory did not move. A second
window opened and closed four times went back to within 1 MiB of its
level 7-47 ms after three of the closes and kept a 4 MiB allocator block
after the fourth, as the same run without the copy did after another
close. AC13 is met.

tools/live_image_measure.py starts AC13 at 30 s and counts a window's
lock holds only from the seconds it uploaded: a window that only copies
takes no lock, and its latest 1,024 holds then reach back to its last
upload, a rotation's first one or the start-up's, which read as a 3.96 ms
p99 in the first run.

LiveTextureStats::sibling_copies counts the copies, an attachment's
`uploads` now counts both ways a window's texture was brought up to date,
the automation DTO carries the new counter, and the idle trace's live
line prints `copies=` between `uploads=` and `contended=`.
The measurement script reads it, and still reads lines without it.

The mirror's level 0 is an Arc shared with the texture it was copied
from, until either is written, and LiveImageMirror::on_same_device gives
headless tests a second window on the same device. D.10 now checks one
upload then one copy, and new GPU tests check the copied pixels exactly
over alternating windows and the copied texture's mip chain.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…gure is

A review read the acceptance table's "Met" as contractual where it rests
on one run on one host. The measurements section now says that a figure
is one run unless it is a range naming its runs, that the plan made the
release host's figures reported rather than blocking, and why CI times
nothing. The acceptance criteria open with the two kinds of evidence: the
exact one CI runs on every commit (counts, bytes, pixels, decisions) and
the reference host's timings, where "Met" means the run, or every run of
a range, was within the goal there.

Each timing row now gives its runs, folding in the reruns of 2026-10-07:
AC6 at 1080p 1.06-1.19 ms over three runs, AC7 2.4-3.8 % over four, AC8
7.2-8.2 % over three, AC13 with one window 1.04-1.15 ms over four, the
commit-to-upload p50 spread 2.2-15.1 ms. AC11 now says what four runs of
four closes showed: within 1 MiB after 13 of 16, and a 4 MiB allocator
block kept after the other three, with and without the copy, rather than
"each of four closes" from the one run that had none.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…ayers are there

A review read teksilo-canvas holding the wake layer and the live-image
protocol as an inverted layering under a name that says only drawing,
and asked for a crate of their own. The layering is the one v0.15.1 had
(canvas below core and render, the same 22 dependents), and the code is
where it has to be: teksilo-render consumes live pictures and depends on
canvas, not core; a RenderFrame carries them; producers use them without
the GUI. A crate of their own would sit below canvas, canvas would depend
on it, and an edit would rebuild the same crates: a comment added to
wake.rs, svg.rs or a widget's button.rs each made `cargo check
--workspace` recheck all 89 crates in about 4 s.

So nothing moves, and what reads as a lie is told instead:

- teksilo-canvas gets its first crate documentation: the paint model,
  live pictures and the wake layer, and why the last two are here;
- its Cargo description names all three;
- docs/architecture.md's crate table and .claude/CLAUDE.md's
  architecture entry say the same, the latter also naming the
  doc-hidden `sync` module that swaps in loom;
- the design record's code map names `internal::DeviceTextures`, and a
  paragraph records the review, the two splits weighed (the wake layer
  alone, the producer side of live pictures) with their costs, and that
  a later split can keep today's paths as re-exports.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
70bd810 made the second window to draw a commit copy the first's
texture on the GPU instead of uploading it again, and left
.claude/CLAUDE.md describing one upload per window. Its LiveImage line
now says that windows on one device upload a commit once, under which
condition, and what counts it; the key-files line names siblings.rs.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
The reclaim registry kept one submission per device, and a new flag
replaced it on the ground that submissions complete in order, so the
later one covers both. But the later flag is not the later submission.
Windows sharing a device flag in the order they drop textures: when
window B drops a texture after its frame 12 and window A then closes,
flagging its last frame, 10, the record waits for 10. The poll finds 10
complete and drops the record while 12, and B's texture with it, is
still on the GPU, and that texture then waits for some window's next
frame to be freed.

wgpu does not let two SubmissionIndex values be compared, so a record
now holds every flagged submission not yet seen complete, and the poll
keeps it while any is still running. A flag first forgets the record's
submissions already complete, so it holds no more than the flagged work
in flight even in a headless renderer that never polls.

The private-device test reproduces it on the review's terms: a long
copy submitted and flagged, then an older, complete submission flagged
after it; the poll must still report work pending. Flags replacing the
record again turns it red.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…ink definition

extract_widget_api.py dropped every doc line it took for a reference
definition, `[label]: target`, and rustfmt wraps doc comments, so a line
can begin with a link and a colon in the middle of a sentence:

    /// Drive this widget through `handle`, made beforehand with
    /// [`LiveImageHandle::new`]: the form a `teksu!` tree can use, where
    /// [`handle`](Self::handle) cannot be called.

The catalog page read "made beforehand with `handle` cannot be called."
rustdoc renders the line, as CommonMark says: a definition cannot
interrupt a paragraph, and nothing but a title may follow its target.
The generator now holds a definition to both rules. Its last pass also
left a backticked shortcut link raw when a colon followed it, to spare
definitions, so seventeen pages showed `[`X`]:` as written; it now spares
only the definitions it keeps (web targets), line by line.

Regenerated with the fixed generator (public rustdoc as --api-dir, so
every API link is unchanged): four pages get their line back
(LiveImage, rich text, tree_source, chart_selection), seventeen show the
name as code, and LiveImage's page also catches up with two method docs
that changed since its last generation. A self-test pins the wrapped line, and the embedded copy in
cargo-teksilo is synced.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
Checking the regenerated catalog page against the code found one claim
stale: `LiveImage::new` said a switch of source makes the window drop
the old source's texture at its next frame. A small picture committed
once is parked instead, kept a while in case it comes back; the doc now
says so, and the page is regenerated from it.

docs/widgets-overview.md is written by hand and still described the
widget as it first landed. Its line now adds what came later: windows on
one GPU uploading a commit once, LiveImageDiffWriter for producers that
hand over whole frames, `scaling` and the mip chain behind `Trilinear`,
`pause_when_inactive` and `dim_when_disabled`.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
"Builder methods at a glance" was one line of every public method name,
getters included (269 of them on 40 pages, `LiveImage::handle` among
them), with nothing to say what each returns or where its entry is. A
page now opens the way Qt's class reference does: its first paragraph,
then "Public types" (kind, name, first sentence), then "Public
functions": one table per type, the page's own type first, grouped into
constructors (no receiver, returning Self or the type), builder methods
(`self -> Self`), methods, associated functions, and associated constants
and types; each row gives the return type and the call, generics and
parameters without the receiver, and links to its entry through an
`<a id>` placed before it, which mdBook and GitHub both keep. The rest of
the module documentation follows under "Detailed description", its
headings demoted two levels: a module doc's `# Sizing` rendered as a title
of the page's own rank.

In the book, function names are FernTech teal, in the tables and in each
`pub fn` heading of the API reference, while the parameters keep the text
colour. The pages stay plain Markdown: docs/assets/teksilo-catalog.js marks
the names in the rendered page and teksilo-catalog.css colours them, both
loaded by book.toml. IntUI's accent, #0FB5CC, is 2.5:1 on a white page,
too light for text, so each theme gets a shade of the family that keeps
4.5:1: IntUI's own darker teal #0A7B8B on light (5.0:1), #086B78 on the
beige rust theme (4.7:1), IntUI's dark accent #19BDD4 on coal, navy and
ayu (7.7:1 and more).

The catalog index's briefs read only the first line of a doc, so 70 of
204 stopped mid-sentence ("a flexible gap that claims all available
space on the"); a brief now joins the lines its sentence was wrapped on,
does not end on an "e.g." or a dot inside code, and cuts a long sentence
at a word.

Checked across the 204 pages: every table link reaches its anchor, every
visible member of every type is in its table, and the `--format md`,
`text` and `json` outputs `cargo teksilo symbol` relies on are unchanged.
Regenerated against a public rustdoc (`--api-dir`), so no API link moved.
Self-tests pin the grouping, the signature parser, the anchors and the
brief. book.toml's header said the generated pages are gitignored; they
are committed.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
… the bench

Three changes for what an agent working on an app reads about LiveImage:

- crates/teksilo/src/app_guide.md, which the corpus indexes and docs.rs
  renders, never mentioned LiveImage. Its "Live dashboards and background
  work" section gains "Pixels another thread draws": why not ImageWidget,
  a producer that keeps writing (a doctest), what dropping the last
  writer does, transactions and LiveImageDiffWriter, mapping input through
  the handle, and where the guide and the demo are.
- tools/build_corpus.py leaves out examples/live_image_bench: a
  measurement harness whose 18 chunks would rank for every LiveImage
  question beside the demo, which is the example to learn from. The
  module docstring names it as a fourth exclusion. A generation into a
  scratch directory gives 57 example crates and 2,147 chunks, every line
  range verified; the committed corpus is regenerated at the release, as
  before.
- LiveImage's module documentation (and so its catalog page) showed a
  producer thread that wrote one frame and ended, dropping its writer:
  the source then frees its pixels and the picture goes. The example now
  keeps writing, with a comment saying why.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
Two causes, both in the tests rather than the code under test:

- On macOS the menu bar has no Alt+letter branch: the OS composes
  Option+letter into an accented character before winit sees it, so
  `MenuBarDispatcher::try_handle` compiles the branch out. teksilo-widgets
  already gates its own Alt+letter tests; teksilo-app's three
  `alt_letter_opens_its_menu_*` tests and their helpers now are gated the
  same way. `the_menu_bar_keys_do_nothing_behind_an_in_tree_message_box`
  keeps its F10 half on every platform and gates only its Alt+E half.
- `disconnect_waits_for_an_inflight_wake` held a request in progress
  through an off-main draw wake, which on macOS goes to the main dispatch
  queue that nothing drains in a test, so the request never started and
  the test timed out. It now uses a state wake with no route, which asks
  the window from the calling thread on every platform; what it checks,
  that `disconnect` waits for the slot, is unchanged.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
The corpus is regenerated in the release commit only, so between
releases every edit to an indexed document made three checks fail on
every pull request and on main, reporting the release's pending work as
a fault in the change under review.

- `every_chunk_line_range_reproduces_its_own_text_in_a_checkout` and
  `every_document_reconstructs_byte_exactly` are `#[ignore]`d, with the
  reason written above each. Their range-ordering half, which needs no
  checkout, is split out as `every_chunk_line_range_is_ordered` and
  keeps running.
- The pre-release hook runs both by exact name with `--ignored`, right
  after it regenerates the corpus: the one moment they must pass. Before
  this, the release ran them only through the tag's `cargo test`, which
  skips ignored tests.
- CI's `build_corpus.py --check` step reports a stale corpus as a notice,
  and fails only on a tag, where it means the release commit skipped the
  hook.

CLAUDE.md and engineering/docs/agent-tooling.md say so.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
The checklist ran on a Mac mini M4 under Metal at e385a69 (external
3440 x 1440 display, 100 Hz, scale 1.0). The design record now has its
results: the suite on Metal (10,380 passed, none failed), F.3's 48 checks,
F.4's twenty runs and the manual open/close and Cmd-Q, the minimise probe's
six checks and a covered window drawing nothing (AC16), one posted event
per off-main wake (AC2), one frame per commit with VoiceOver on, and the
live pass's timings on Metal, which miss AC6.

AC2 is met (it was "met off macOS"), AC16 is met on macOS too, and AC6 is
met on the reference host but not on Metal. Section 12 lists the two
faults as open: a window AppKit never marks visible spinning on skipped
frames, and Metal staging each upload in a new buffer under the source's
lock, which trips the staging ring's gate.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…s without pause

The macOS run found a window spinning at about 120,000 RedrawRequested a
second and drawing nothing: one opened while the display slept or the
screen was locked. wgpu's Metal backend refuses every acquire while the
window's occlusionState lacks Visible; winit reports occlusion only when
that state changes, so a window never shown never hears it; and a skipped
frame was retried at once, every time, unless the window was already
known hidden. Every new window did the same briefly while it first
appeared (300 to 1,000 empty RedrawRequested). The unbounded retry dates
from April; FrameOutcome::Skipped's doc already said "once".

Two parts:

- The redraw gate bounds the retry on every platform. The first skipped
  frame in a row is retried at once, each later one after a wait that
  doubles from 16 ms to 250 ms, which joins the window's wake deadline
  and is taken by request_redraw_due. A rendered frame ends the series,
  and so does going hidden or being shown.
- On macOS a skipped frame asks AppKit
  (PlatformWindow::occluded_now, NSWindow occlusionState). Not visible,
  the window is marked occluded and the hidden-window gate closes: wakes
  are dropped, no deadline, no retry. winit's Occluded(false) normally
  reveals it; as it is the only thing that would, the window also asks
  again every 500 ms, so a missing event cannot leave it blank. winit's
  own Occluded event overrides what asking found.

Gate tests pin the retry series, its cap, its reset and its deadline;
six mutations of it are caught. The wiring in teksilo-app needs a
surface that refuses to draw and is checked on the Mac
(liveimage-macos-rerun-task.md); the macOS code compiles and lints for
aarch64-apple-darwin here.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
Queue::write_texture stages each call in a buffer it creates for it. On
Metal that is a new MTLBuffer, allocated and first touched under the
source's lock: the macOS run measured the live pass at p99 1,068 us for
AC7's workload and 3,563 us for 1080p, and a standalone program on the
same M4 put a 1080p write at 2.7 / 3.7 ms (p50 / p99) paced at 60 Hz,
against 1.0 / 1.55 ms into a buffer already mapped. That is the staging
ring's gate, per-call allocation in the profile, tripping on Metal.

The backend now copies each band, under the lock, into a chunk of a
MAP_WRITE | COPY_SRC buffer already mapped (live_staging.rs: 8 MiB chunks,
bands at 256-byte offsets), and records its copy_buffer_to_texture at the
head of the frame's encoder, before the copies between windows and the
mip passes. Chunks a frame wrote are unmapped before its submission and
mapped again after it; the pool takes them back at its next use, polling
the device once before making another. A chunk unused for a second goes,
and all of them go at a frame holding no live texture; a freed chunk
counts as a release for the reclaim poll. write_texture remains the
fallback when no chunk can be made. A band whose rows already have the
copy pitch, as a 1920-pixel row does, is one copy; a first version copied
row by row and was slower at 1080p than write_texture.

On the reference host (RADV), alternating runs: AC7's workload prepare
p50 / p99 67 / 136 us against 105 / 213 us, lock hold p99 126 against
206 us; full 1080p prepare p99 1,023 and 993 us against 1,064 and
1,062 us, UI thread 7.0-7.1 % against 7.1-7.2 %. GPU memory did not
drift. The Mac's gain is to be measured (liveimage-macos-rerun-task.md).

New GPU tests: a stream reuses one chunk and shows each commit exactly
at a width whose rows do not fill the copy pitch; the bands of a frame
share a chunk; without staging the upload still lands; unused staging is
freed, and all of it once no live texture remains. Seven mutations of the
pool are caught. Doc-hidden test hooks on Renderer: live_staging_stats,
set_live_staging_idle_lifetime, fail_next_live_staging.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
D3D12 WARP (the Microsoft Basic Render Driver a GPU-less Windows host or
a VM renders on) shades the texels along a scissor edge on an odd
coordinate wrong: a rect upload at y = 203 left row 101 of level 1
transparent black, and the levels below inherited it. live_mips d23 and
d25 failed on it; the whole-chain path, unscissored, never did.

The scissor now grows outward to even coordinates, clamped to the level.
That is correct on any adapter: a texel outside the footprint is a
function of current texels of the level below, so the pass writes back
the value it holds. The footprint rebuild is again byte-identical to a
whole one on WARP.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
The macOS re-run found finding C: with the displays asleep and the screen
locked, a window that was already showing drew 59 to 61 frames a second
for 22 s. AppKit keeps such a window's occlusionState Visible, so wgpu
acquires every frame and winit reports no occlusion; a window opened
during the sleep was hidden correctly, by the skipped-frame check, but
nothing told teksilo-app about the windows already up.

- teksilo-platform's SessionWatch (session.rs, session/macos.rs) observes
  NSWorkspace's screens-did-sleep/wake and session-did-resign/become-
  active, and the distributed com.apple.screenIsLocked/Unlocked, which
  Apple does not document. Those two are registered with
  DeliverImmediately: Cocoa holds the distributed centre's notifications
  while the application is inactive, as it is under the lock screen, so
  the lock would otherwise be heard at the unlock. Each of the three
  reasons is set and cleared by its own notifications, in atomics, from
  whatever thread posts them.
- teksilo-app starts the watch in resumed, before the first window. A
  change posts a private SessionChanged, taken before any app handler
  like WindowWake; WindowManager::set_session_away then sets every
  window's session_away, part of hidden(), and syncs it, so each hides
  with its one last frame and comes back with the redraw a reveal owes.
  A window created meanwhile is born with it.
- A clearing notification that never came would leave every window
  blank. So while away the loop rereads the system every second
  (CGDisplayIsAsleep on the main display, the session dictionary's
  console and lock flags) and clears a reason it no longer reports. It
  never sets one, and the watch starts with none set: a wrong reading
  costs frames drawn for nobody, which is how it was before.

There is no watch elsewhere: a Wayland compositor already stops frame
callbacks to a surface it does not show. PlatformWindow::occluded_now's
doc claimed AppKit reports a sleeping display and a locked screen; it
does only for a window it never showed, and now says so.

The reason logic has unit tests. Claim (u) of the X11 event-loop test
covers teksilo-app's side: every window hidden while away, a window
created meanwhile born hidden, no reread before the interval, a reading
that finds the session present showing each window with a redraw, and
the event never reaching on_app_event. Four mutations (hidden() ignoring
the flag, a new window not born with it, the event not intercepted, the
interval ignored) each redden it. The observers run only on a Mac
(liveimage-macos-rerun2-task.md); the macOS code compiles and lints for
aarch64-apple-darwin here. New macOS dependencies objc2-core-foundation
and objc2-core-graphics already resolve through objc2-app-kit.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
The second macOS checklist ran at 230b994 on the same Mac mini M4. The
record now has its results: the suites of render, app and platform on
Metal (498 passed), fix A holding (no spin, a window opened on a sleeping
display drawing nothing, both windows back within 0.6 s of the wake, 2 to
4 extra RedrawRequested per window open instead of 300 to 1,000), the
close loop, F.3 and the minimise probe again, and the live pass with
staging kept mapped.

AC6 is met on Metal for AC7's workload (p99 at most 0.46 ms) and not for
full 1080p (1.84 to 1.90 ms), which is what a paced copy of the frame
costs on that machine; AC13 is over on Metal for the same reason (1.82 to
1.88 ms, one window), with no frame ever contended. Getting the copy out
of the lock was weighed again and not done for 0.16.0. Section 5 gains
the session watch, and section 12 lists finding C, a window already
showing drawing through display sleep and the lock, as fixed and not yet
run on a Mac.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
The third macOS checklist ran at 7bcf0d5 on the same Mac mini M4. With
the display put to sleep and the screen locking with it, both windows,
including one opened during the sleep, drew no frame and answered no
RedrawRequested until the wake, and drew again in the line of the wake;
the run used 4.1 s of CPU against 6.7 s before the fix. With the screen
locked by itself for 18 s and the display on, no frame until the unlock.
CGDisplayIsAsleep read asleep for the whole sleep, and the session's
lock flag kept the window hidden through the once-a-second reread, so
both readings the safety net trusts hold on macOS 26.5. The close loop,
F.3 and the minimise probe passed again. Switching to another user's
session was not run.

Section 12 marks finding C as run on the Mac, and AC16's evidence notes
no frame through a display sleep or a screen lock.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
The Windows checklist ran at 7bcf0d5 on Windows 11 in a VirtualBox VM,
where wgpu renders on D3D12 WARP, at scale 1.0. The record now has its
results: the workspace's tests on DX12 (10,385 passed, none failed, the
copy between windows and the mip-chain tests among them), the automation
smoke tests over the named pipe, F.3 and the three other curated probes,
the minimise probe and a minimise by hand (AC16), no posted wake and no
app event over 2,474 trace lines (AC2), the close loop, and one frame per
commit with Narrator on and with a UI Automation client attached.

AC16 is met on Windows too. AC6, AC8 and AC13 stay unmeasured there: the
live pass's timings on WARP, which draws on the CPUs of a VM, are recorded
and not judged. Section 12 records the WARP scissor fault fixed before the
run (even_scissor), and what is still to run on Windows: a scale other than
1.0, and the timings on a machine with a D3D12 driver.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
@jacquetc
jacquetc merged commit 1cd6684 into dev Oct 9, 2026
15 of 19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant