Skip to content

fts-focus + the iOS fixes - #4

Merged
Codys-Wright merged 24 commits into
mainfrom
fts-ios
Oct 2, 2026
Merged

Codys-Wright merged 24 commits into
mainfrom
fts-ios

Conversation

@Codys-Wright

Copy link
Copy Markdown
  • Merges fts-focus (custom-widget compositing, pointer capture) with ios-safe-area-paint (safe area, touch, idle frames).
  • iOS: document and paced frames are drawn directly (winit-uikit only turns a redraw request into a frame during a touch), and a frame never draws the next inside itself.
  • anyrender_vello's Rasterizer declines a device without indirect execution (the iOS simulator).

Signal pins 6b54c521 (FastTrackStudios/signal#15).

🤖 Generated with Claude Code

Codys-Wright and others added 24 commits September 22, 2026 20:12
handle_pointermove indexed the slab with mousedown_node_id. A press that
re-renders — a tab that remounts a view, a button that replaces itself —
removes that node before the drag moves, and the next move panicked with
'invalid key'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A press can land on a node the same press removes — a menu row that
closes its menu. The next focus change then blurred the removed node,
and snapshot_node indexed the slab with its stale id: "invalid key", a
crash on the next click. Snapshotting a missing node is now nothing,
focusing one is refused, and a focus left on one is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closing a menu frees its nodes at once, but the laid-out tree still
points into them until the next layout: a node's layout_parent, and what
a hit test finds. A pointer move in that moment walked up into a freed
node — node_layout_ancestors indexed the slab with it, "invalid key", and
the window went with it (hovering off a dropdown as it closed). The walks
up (layout ancestors, element ancestors, absolute position) now end at a
missing node, a hit on a removed node hovers nothing, and a hover or
cursor left on one is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ever walked up from

Closing a card by its own close button: the button is detached (removed,
not yet dropped) while its parent is dropped, and the hover still names
it. Unhovering it restyled it, and marking its ancestors dirty indexed the
slab with its stale parent id — "invalid key" at the click. Hover now
only touches nodes still in the document, snapshot_node_and skips detached
ones, and the parent walks (ancestors dirty, child_index, forward,
backward) end at a missing parent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A click's handler can remove its own target (Together, on the share card,
re-renders the card under it). The default action runs after the
handlers, on the stale id: handle_dom_event indexed the slab with it —
"invalid key" on the click. It now returns when the target is gone or
detached; every path below it walks from the target, so a live target is
all they need.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
paint_scene offsets the scene in physical pixels, but both calls handed it
the insets converted to logical ones. On an iPhone (scale 3) the picture
sat a third of the way down the notch while pointer_coords took the whole
inset off input: every tap landed two-thirds of the inset below what it
pressed. Every other use of safe_area_insets here was already physical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… safe area too

The fill started at the insets times the scale: the insets are physical
pixels now (see blitz-shell), so that pushed it past the status bar and
left it, and the home indicator's strip, the renderer's clear colour -
white on a phone in light mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resume sized the surface to the viewport alone, while every resize adds
the insets; on an iPad that launches already the right way up no resize
follows, and the whole picture stays stretched into the taller window,
its bottom off screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…resh

Sizing the surface to the viewport plus the insets read at creation
stretched the picture the other way on an iPad launched in landscape:
those insets (and so the viewport) can be another shape's. The first
frame now does what a resize does — current surface size, current safe
area, viewport from both, surface the whole window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Touch has no move before a press, so the shell synthesises one to put
the hover under the finger. It carried the pressed button, so the
document measured it from the previous press's position as a drag,
took any tap somewhere new for a pan, and dispatched no click on
release: every other tap in a different place was lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On iOS a rotation resizes the surface before UIKit updates the safe-area
insets, so the insets read at the resize were the old orientation's: a phone
turned on its side kept its upright top and bottom bands. Re-read them each
frame, and ask for frames for half a second after a resize so the new ones
are picked up even when nothing else is drawing.
…d right edges

A phone on its side reports the camera housing's inset on both sides, though
only one side has it, and only mid-height: an app that minds the sides
itself can have the width back, keeping the top and bottom insets.
- is_animating asks each custom widget's needs_redraw rather than counting
  a mounted widget as animating: a window with one drew every frame forever.
- The shell reads is_animating after painting, so a widget whose paint
  sets it moving again (a fling, a playhead) is heard in the same frame.
- Border edges with no width are skipped, and the corner split angle is
  defined when one or both widths are zero: Tailwind's preflight gave every
  rounded element a NaN path per frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- resolve: frames with no damage skip every tree walk; frames whose
  damage is paint-only (colour, shadow, transform) run only the damage
  propagation and transforms passes, not construct/flush/layout.
- compute_layout_damage marks restyles whose layout style structs are
  unchanged (stylo ORs in RELAYOUT for box-shadow etc.), and resolve
  takes the relayout bit off those nodes.
- set_attribute: an unchanged value is a no-op; a style attribute no
  longer forces ALL_DAMAGE on the element and its parent.
- custom widgets no longer keep the window animating; a widget with
  something new to draw schedules an update.
- blitz-shell: redraws requested by document updates are paced to
  FTS_MAX_FPS (default 30, 0 = unpaced); input still redraws at once.

Claude-Session: https://claude.ai/code/session_01Nwusq16bN6CJteQKkaEwzo
- A poll that only woke timers or streams (no mutation, no widget
  needing a redraw) no longer redraws the window. DocumentMutator
  records whether it mutated; BaseDocument::take_mutated and
  widgets_need_redraw decide.
- A widget that is still moving after a frame asks for the next one,
  paced to FTS_MAX_FPS.
- View::poll polls until the runtime is pending again, so its waker is
  always armed. It used to stop after one productive poll and relied on
  every redraw's window event to poll again, which is what kept idle
  windows redrawing forever.
- FTS_FPS_LOG=1 prints frames per second and mean/worst frame cost.

Claude-Session: https://claude.ai/code/session_01Nwusq16bN6CJteQKkaEwzo
Layered compositing, so a frame where only widgets changed skips Blitz's
paint and vello entirely:

- vendor/anyrender (0.11.1) gains anyrender::composite: the painter tells
  the renderer whether it may reuse its base and which layers to draw.
- vendor/anyrender_vello (0.12.0): the window renderer rasterises the page
  into a kept base texture and, every frame, composites the base plus the
  layers (textures, faded; overlays) in one render pass. Rasterizer
  renders any widget scene into a texture of its own (shared vello
  renderer per device). No blocking wait on the GPU after present.
- Widget::composite_texture: a widget whose picture is one texture can be
  a layer. blitz-paint leaves such widgets out of the page and lists them
  as layers (unless under a transform, filter, clip-path or mask; an
  opacity is carried by the layer), and lifts page content painted over a
  layer into an overlay drawn above it.
- blitz-shell: a frame is layers-only when nothing but layer widgets
  changed; FTS_FPS_LOG reports layers-only frames. custom-widget is on by
  default (it was compiled out, with freeing dropped widgets' textures).
- set_attribute to an unchanged value no longer counts as a mutation.

Claude-Session: https://claude.ai/code/session_01Nwusq16bN6CJteQKkaEwzo
A press that lands on a custom widget captures the pointer: its moves
and its release go to that widget, over it or not, until the button is
released (or the pointer cancelled). A knob dragged off a face kept
turning only while the mouse was over the face, and never heard a
release made outside it — the drag stuck.

Claude-Session: https://claude.ai/code/session_01Nwusq16bN6CJteQKkaEwzo
…d idle-frame fixes beside the custom-widget compositing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
winit-uikit decides whether a view is Metal-backed by asking if the *view*
is a CAMetalLayer, which a UIView never is, so every redraw it is asked for
becomes setNeedsDisplay — and UIKit only turns that into a frame while it
is handling a touch. A frame a poll or a timer asked for waited for the next
touch: every tap showed the frame before it, and nothing that moves on its
own moved. `frame_now` draws at once on iOS (and asks elsewhere); an
animating frame's successor comes back through the event loop, paced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ct execution

- A frame drawn on iOS (`frame_now`) never draws the next one inside
  itself: an animation's next frame goes back through the pacer's timer,
  always after a wait. A frame slower than the pace (the simulator paints
  on the CPU) otherwise recursed, and the run loop — touches with it —
  never got a turn.
- anyrender_vello's Rasterizer declines a device without indirect
  execution (the iOS simulator's Metal): Vello's compute stages need it,
  and wgpu aborted the app at the first rasterised widget. The widget
  paints into the page instead, as on any renderer without a device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Codys-Wright
Codys-Wright merged commit 79371a5 into main Oct 2, 2026
13 of 16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants