Repository navigation
Feat/live image - #16
Merged
Merged
Conversation
jacquetc
force-pushed
the
feat/live-image
branch
from
October 7, 2026 07:38
1b80120 to
fef5c46
Compare
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>
jacquetc
force-pushed
the
feat/live-image
branch
from
October 8, 2026 08:02
5ab1c36 to
e385a69
Compare
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>
jacquetc
force-pushed
the
feat/live-image
branch
from
October 8, 2026 21:11
6f0f4a7 to
7bcf0d5
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.