Skip to content

Input, focus, menu and rendering fixes from a real-input self-test on KDE Wayland - #476

Open
fschutt wants to merge 4278 commits into
masterfrom
fix/input-bugs-2026-09-19
Open

fschutt wants to merge 4278 commits into
masterfrom
fix/input-bugs-2026-09-19

Conversation

@fschutt

@fschutt fschutt commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

Summary

Bug-hunting follow-up to the 2026-09-16 handoff, driven by a real-input self-test of AzWidgets on KDE Plasma
Wayland (ydotool for the pointer and keys, Spectacle for what the user actually sees). Every engine and
shell fix has a test that was seen RED before the change. The Wayland protocol fixes (right-click, menu
measurement, anchor edge and size rounding, decoration mode) have no compositor in the test harness: they
carry unit tests of the pure decisions they now make and were verified live with real input.

Fixed

Commit User-visible bug Root cause Other backends
fix(scroll) thumb held scrollbar fades while its thumb is dragged the drag lived in shell state the fade could not see shared path, all backends
fix(focus) ring after relayout no focus ring after Tab out of the TextArea tween post-pass ran before the layout result was stored shared engine
fix(events) FocusLost target widget on_focus_lost hooks never ran planner resolved Blur against the node focused now shared shell + E2E runner
fix(events) focus-only change Tab shows no ring on Wayland a focus change with no CSS delta never rebuilt the display list X11/Windows/macOS map the tier the same way
fix(text) reveal across a wrap fast typing shows glyphs ahead of the caret reveal clip dropped for a diagonal (wrapping) glide shared engine
fix(layout) abspos + opacity the Tooltip's hidden tip is always painted abspos boxes skipped the opacity/transform stacking-context checks shared engine
fix(layout) abspos in flex the Toast paints over the row above it abspos children of any flex/grid parent kept taffy's placement against that parent shared engine
fix(cpurender) empty frame the UI turns orange after scrolling an incremental frame with nothing to repaint took the FULL repaint branch while reporting no damage; on KWin's ARGB8888 pool that rewrote the whole buffer in renderer byte order and nothing converted it wasted full repaint on every backend
fix(wayland) right-click context menu never opened the press read a stale hover snapshot X11 already hit-tests; Windows/macOS use OS events
fix(wayland) menu measured menus clipped / wrong padding popup created at an estimate, never sized to content X11 has size-to-content; Windows/macOS native menus
fix(wayland) edge + rounding dropdowns open one trigger-width to the right; 258 logical -> 259 px every menu anchored at the pointer corner; positioner truncated, buffer ceiled Wayland only
fix(wayland) decorations double titlebar NoTitle requested server-side decorations X11 maps NoTitle to a border-only frame
fix(wayland) refusal read (follow-up) Tab out of the TextArea dropped focus; azul's CSD titlebar appeared late above the app's own the decoration handler read our own client-side request as a refusal and injected a full CSD titlebar at the next rebuild, shifting every node id — not run locally (wrap-up); see below Wayland only
fix(menu) font size menus a quarter smaller than the desktop's system font sizes are points, the menu stylesheet wrote them as pixels macOS keeps the number (Cocoa point = logical px); Windows uses native menus

A disjoint-rect split of the damaged raster (for a related swizzle leak) was tried and reverted in this
branch
: it seams box-shadows and anti-aliasing across the pieces (the slider test caught 4 stale pixels).

Verification: layout integration battery 1114 passed, layout unit tests 7749 passed, shell unit tests 2379
passed. Live on KDE Plasma 5.27 Wayland with real input: 40 one-click wheel scrolls with the native
backbuffer hit 15 empty-damage frames without a colour swap (before: swapped within 3-9 clicks); the
context menu opens at the pointer at its measured size.

Unverified commit: 7b0f0b635 (decoration refusal) was committed when its RED/GREEN build was stopped for
the wrap-up. Its test a_granted_client_side_request_is_not_a_refusal runs with the shell unit tests.

Plus tooling: scripts/ydotool_start.sh (daemon + socket permissions + flat pointer acceleration for the
virtual device, without which every "go to x,y" pinned the cursor in a screen corner) and
scripts/ydotool_input.py (press, hold, drag, wheel and keys through the daemon's socket), and the
AZ_BB_PROBE native-backbuffer pixel probe that found the orange frame, and AZ_RECONCILE_DEBUG, which names
every node a DOM rebuild failed to match.

Investigated, not a bug

  • Drag selection in the single-line input: slow, flick, above/below the band and press-on-unfocused all
    select and paint correctly with real input.
  • Wheel over widgets: the page keeps scrolling (12 clicks = 240px over the gutter, the TextArea and the
    Slider alike); the colour change seen while sweeping was the orange-frame bug.

Still open (recorded, not in this PR)

  • Late decoration switch (latent on GNOME/Mutter): the handler's regeneration request at startup did not
    rebuild with the new flags; a real refusal still reshapes the DOM at the next app rebuild.
  • Reconcile loses nodes behind an inserted prefix: 14 prepended nodes left 48 old nodes unmatched,
    including focusable widget roots, so focus is dropped.
  • Root overflow is unreachable (all backends). The root element's viewport overflow propagation
    exists, but the root box has height:auto and never overflows itself; with the injected menu bar and
    the 8px body margin the demo's page bottom is cut off by 42px. Needs the viewport, not the root box, as
    the scroll container.
  • NoTitle windows have no window controls on Wayland (and X11). With the double titlebar fixed,
    nobody draws minimize/maximize/close; the app's own title row needs azul-drawn, KDE-like controls.
  • Native backbuffer on ARGB8888 pools: the damaged renderer merges overlapping damage into its
    bounding box but reports the separate rects (the fix belongs in the report, a raster split seams
    box-shadows); overlapping moves in one frame unswizzle twice; popups write into a buffer the compositor
    may hold. Longer term: render B,G,R,A natively like Qt/GTK instead of swizzling in place.
  • Context menus now open at the pointer at their measured size in the desktop's font size, but still lack
    Breeze's rounded corners, border, shadow and taller rows. The software menu bar and the fake titlebar
    do not look like KDE's yet.
  • Clipboard round-trip, document-level multi-node selection and selection-drag autoscroll were not
    reached in this round.
  • Local clippy: this branch's changed files are clean; the azul-core findings derivable_impls /
    implicit_clone pre-date the branch and are already fixed on master.

Full status, bug ledger and the real-input self-test driver: private gist
https://gist.github.com/fschutt/ea8988a6b9494763a122bc8e94e94d22

🤖 Generated with Claude Code

https://claude.ai/code/session_01ETMF11HyFDjLsvwEckEx7x

fschutt added a commit that referenced this pull request Sep 28, 2026
…2026-09-19

Brings PR #476 up to date with master and the documentation branch, so
the PR is mergeable again. Conflicts resolved:

- api.json: a three-way JSON merge against the merge base (every key
  changed on one side only; 0 true conflicts), re-saved by
  `azul-doc normalize`.
- common/event.rs: master only rewrapped these regions (rustfmt); the
  app-cursor paths, live focus resolution and the removed scrollbar drag
  keep this branch's refactors (add_app_cursor, resolve_focus_target_live,
  the press router - which already reads get_scroll_node_info).
- cpurender/raster.rs: master's "the masked region is the element" and
  this branch's half-pixel coverage fix combined - both the element box
  and the mask rect are pixel-aligned bounding boxes (floor/ceil).
- radio_group.rs / segmented.rs: master's `callback.invoke(..)` (managed
  hosts get their context) with this branch's `&mut CallbackInfo`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cd9Y37iWnwHFPHYfneLQfi
fschutt added a commit that referenced this pull request Oct 2, 2026
… is gone after a frame (RED)

PR #476 ledger, engine backlog 4: video decode / camera / screen capture /
microphone workers keep running after their node leaves the DOM -
run_all_threads only reaps FINISHED threads, and a capture or decode loop
never finishes.

Headless test (dll/src/desktop/shell2/headless/mod.rs): a node whose
AfterMount starts a capture-style worker (runs until TerminateThread,
bounded to 4 s) is dropped by the next DOM. Asserts the worker is told to
stop, and once it returned, ONE thread-poll frame removes it from the
window and the thread poll stops.

(The shells' remove_threads now go through LayoutWindow::remove_thread,
which already existed; the next commit makes it forget the owner too.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fschutt added a commit that referenced this pull request Oct 2, 2026
… (RED)

PR #476 ledger, idle leftovers: AzWidgets at rest held 476 FLIP moves whose
values had converged (translate 15 -> 0.016) but never finished, so
needs_animation_frame stayed true and the window never went idle.

core/src/animation_test.rs:
- the converged state (0.016, v -0.2) is settled, and one tick at it -
  even a zero-length one - finishes the channel on its target;
- a spring crossing its target fast is NOT settled (no mid-flight stop);
- a state the criterion accepts never moves further than EPSILON_VALUE
  again (SMOOTH / GENTLE / SNAPPY);
- a 300 px FLIP move settles within 1.1 s at 60 / 120 / 240 Hz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fschutt added a commit that referenced this pull request Oct 2, 2026
…ange (112cf83..53c978b)

Three read-only reviews: EDITORS (Mail, Notes, Writer, Tasks, Review, Contacts,
Calendar - one shared RichTextEditor from AzNotes), OFFICE (Sheets, Show, Photo,
VideoCut, Paint, Drive, Meet, Maps, Calculator, Setup, Builder, Shells, Widgets,
Storage, AppKit), WIDGETS_API (widgets, core/css/dll, api.json, autofix). Every
finding is a next-wave item in the PR #476 ledger. Top: deterministic Uuid::v4
ids overwrite saved documents (Notes, Photo, Show, VideoCut).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WyGP18BMbfHvnTRYqNVjbP
fschutt and others added 26 commits October 3, 2026 15:58
…ser decision)

A udmabuf is only sampled in place when the GPU accepts its LINEAR pitch;
256 bytes is the multiple every common GPU accepts (what Qt picked). The test
states pitch = align_up(width * 4, 256), padding in whole pixels
(`padding_px` / `pitch_px` added: the renderer views a slot as a pixmap
`pitch / 4` pixels wide). Fails against the tight pitch.

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

CSS 2.1 s10.8.1: every line box of a block container starts with a strut of
the container's own first available font. collect_font_stacks_from_styled_dom
keyed only TEXT nodes (and placeholders), so a block whose inline content was
only boxes (an icon row of inline-blocks or images) never loaded its face:
fc.rs took the 0.8em / 0.2em strut guess and line-height normal as 1em (a 10px
inline-block in 16px Arial: a 16px line, Chrome 18).

Phase 1 now also keys the PARENT of every inline-level box (display inline /
inline-block / inline-flex / inline-grid / inline-table) - the container its
line boxes belong to (an inline box with no glyphs holds a strut of its own
font, keyed the same way). Keys are deduplicated by (family, weight, style),
so a document whose containers share their text's font resolves no new chain.
A minimal solver3 edit (MAILREF8 owns solver3 sizing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ares, not their order

NodeData's Hash folded the inline declarations' discriminants in order. An
imperative patch (`set_css_property` -> `upsert_inline_css_property`) moves
its declaration to the end of the inline style, so the patched node hashed
unlike a fresh build of the same widget in the same state - and exactly
like an untouched twin elsewhere. reconcile_dom's subtree pass (A2) then
gave the rebuilt widget the twin's identity: in AzWidgets the clicked
Switch matched the ShellSettingsDialog's switch 7400 px further down (the
Dialogs section, 2026-10-01, put a second switch on the page), its track
got a FLIP slide from there and its transitions / overrides were remapped
onto the other switch - "the toggle immediately transitions".

The inline part of the hash is now a count plus a wrapping sum of
per-property hashes: order-free, and a property declared twice still
counts twice. Values stay out of the hash as before (a toggle is the same
node).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he main checkout of a worktree)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pool_layout: stride = align_up(width * 4, 256). WIRING PENDING in the next
commits (the native slot target, the popup copy, the tooltip blit still
assume tight rows until then).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mounts (RED)

THREADS8. The map tile sweep (map_on_after_mount) is a 250 ms timer that
never ends itself: a map that left the DOM kept waking the app four times
a second and kept its tile cache alive, and a remount added a second sweep.
The rule the node threads follow (managers::thread_owner) is stated for
timers: unit tests of the manager (stopped once, follows a moved node,
child DOM of an unmounted VirtualView, a removed timer is never stopped for
its old node) and a headless test (AfterMount timer gone after the unmount,
the app timer untouched, the shell drained the stop list).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
THREADS8, GREEN part a for 5c7e4e4: ThreadOwnerManager gains timer
owners (bind_timer / timer_owner / forget_timer) and a stop list
(take_timers_to_stop / timers_to_stop). remap_node_ids moves a timer owner
with its node and puts the timer of an unmounted node (or of a node in the
child DOM of an unmounted VirtualView) on the stop list; threads and timers
share one `follow` rule. Not wired yet: the window drop and the dispatcher
binding come next.

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

THREADS8, GREEN part b: LayoutWindow::remap_node_ids classifies `timers` as
node-keyed through thread_owners and removes the timers the manager put on
its stop list, so they never fire again; the shell stops the OS timers from
the same list (next commit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New CpuBackend contract field `native_target_row_padding_px` (0 everywhere
until a shell sets it). The test arms a slot whose rows are 13 px longer than
the frame (a 256-byte-pitch wl_shm slot in miniature) and asserts the frame
is rendered INTO it (no owned-path fallback) and every row matches the owned
render, full and incremental. Fails today: render_frame refuses any target
whose width differs from the frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…started to the node

THREADS8, GREEN part c1: dispatch_events_propagated collects the AddTimer of
an AfterMount / NodeResized / Updated callback (binds_threads_to_node) and
binds the timer to the node once the changes are applied (if it is still
running); applying AddTimer / RemoveTimer forgets an old owner of the id.

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

The armed target may be `frame width + padding` pixels wide (its width is the
slot pitch); the frame lands in columns 0..frame width of every row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
azul.h's load-time check is a GCC / Clang constructor or, in C++, a static
object. MSVC compiling C - the C compiler of Windows - gets neither: a C
program built with cl.exe runs unchecked unless it calls AzAbi_check()
itself. The test asks for an `#elif defined(_MSC_VER)` arm after the C++
one: an entry in the CRT initializer table `.CRT$XCU` (selectany, one per
program), kept by `/include:` with the 32-bit x86 underscore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
THREADS8, GREEN part c2 for 5c7e4e4: a provided
PlatformWindow::stop_timers_of_unmounted_nodes drains the thread-owner
manager stop list into stop_timer (NSTimer / SetTimer / timerfd / headless).
It runs at the start of dispatch_pending_lifecycle_events (after every
rebuild pass of regenerate_layout) and of invoke_expired_timers (for a
rebuild outside regenerate_layout, e.g. a VirtualView re-render).

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

The native path hands render_frame a pixmap `stride / 4` pixels wide over the
slot and sets native_target_row_padding_px = pitch - frame width, so the
renderer writes rows at the slot pitch (256-byte multiple). Present damage,
the commit swizzle and the catch-up copies already address rows by
`cpu_state.stride`.

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

CSS 2.1 s10.8 (user ruling 2026-10-03, Chrome is the reference):
- vertical-align: middle put the box's midpoint at the baseline PLUS half the
  x-height, i.e. moved it down; the spec raises it (y = baseline - xh/2).
  A 10px box sat at 11.8 (Chrome 4.84), a 24px middle icon in 16px text at
  16 - below its own line (Chrome 0).
- calculate_line_metrics counted every baseline-relative box by its unshifted
  ascent / descent, so a shifted box (middle, text-top, text-bottom, a
  length) was placed partly outside the line box it was counted in; and the
  strut joined the line box only AFTER the top / bottom pass, so a 24px
  bottom-aligned box in an 18px strut line made a 27.2px line with the box
  3.2px down (Chrome 24 / 0).

One helper, baseline_shift(align, ascent, descent, constraints), is the rule
both the line box and the placement read. The strut starts pass 1. sub / super
keep their line-ascent-relative shift and their old contribution (unchanged;
Chrome's font-size/5+1 and /3+1 shifts are left - see the report).

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

opus_apple.rs: AudioToolbox dlopen'd (AudioConverterNew / Dispose /
FillComplexBuffer / Get/SetProperty / GetPropertyInfo - the VideoToolbox
rule: no build-time link, a missing symbol leaves the handles closed).
An encoder is one converter float PCM (the app's rate, 1-2 channels) ->
Opus 48 kHz, 960 frames a packet, at the asked bit rate; its input
callback hands out everything queued in one buffer kept alive until the
next callback, and answers 'nodt' when dry (ends the call, not the
stream). A decoder is the converter back, one packet per decode, with
an encoder's magic cookie for the same format where the converter wants
one.

codec.rs: the handles hold an EncoderInner / DecoderInner only with a
live engine (honest handles); frames in another rate or channel count
are refused; closed off Apple with the reason said once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New last arm of azul.h's load-time check, after the GCC / Clang constructor
and the C++ static object:

    #elif defined(_MSC_VER)
    #pragma section(".CRT$XCU", read)
    static void __cdecl AzAbi_checkAtLoad(void) { AzAbi_check(); }
    __declspec(selectany) __declspec(allocate(".CRT$XCU")) void (__cdecl *AzAbi_checkAtLoadEntry)(void) = AzAbi_checkAtLoad;
    #if defined(_M_IX86)
    #pragma comment(linker, "/include:_AzAbi_checkAtLoadEntry")
    #else
    #pragma comment(linker, "/include:AzAbi_checkAtLoadEntry")
    #endif

`selectany` (as guiddef.h's DEFINE_GUID uses in C) makes the entry one per
program though every translation unit defines it; `/include:` keeps it with
/OPT:REF and /GL. Checked with clang in MSVC mode (x86_64- and
i686-pc-windows-msvc, -fms-extensions -fms-compatibility, -O2): the object
has the `.CRT$XCU` entry and the right `/include:` directive. NOT verified
with cl.exe (no Windows here): the post-release Windows C leg is the first
real compile.

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

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

MAILREF8 group C (mailgun billing, 26 boxes 90px low): a nested 80%-wide
table is laid out at its min-content in the cell's min-content measurement,
and layout_cell_for_height kept max(final layout, measurement). Chrome 154
numbers in the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot the measurement's

MAILREF8 group C: layout_cell_for_height (block branch) took
max(final layout at the column width, the min/max-content measurement's
used_size). The measurement lays a nested percentage-width table out at its
min-content, so Mailgun billing's invoice row was 412px for Chrome's 323 and
everything below it 90px low. The cell's own height is read by
cell_specified_border_box_height, which the measured term stood in for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fschutt and others added 4 commits October 3, 2026 18:25
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…choCanceller) - module exception + the tool's move

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

The generated EchoCanceller handle is a raw pointer, so the Arc<Mutex<Playout>>
holding it was not Send. The library keeps the canceller's state behind a
Mutex and AzMeet only reaches it under the Playout mutex (SAFETY comment).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…4 boxes mismatched)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fschutt added a commit that referenced this pull request Oct 3, 2026
…minate

THREADS8 (PR #476 engine backlog 4). Root cause: both workers took their
receiver as `_recv` and never read it, so the TerminateThread an unmounted
<video> (managers::thread_owner) or a closing window sends was never seen:
the worker ran until the grace period detached it, and its Tick / resize
messages piled up unread. Each frame now starts with the shared
capture_common::terminate_requested (the camera / screencap / mic workers
use it already), which also drains the other messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fschutt and others added 25 commits October 3, 2026 18:45
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…RED)

The wave-8 build still re-laid out ~10% of AzWidgets per switch-knob frame
(coordinator: 55-58 ms, root_layout_pass 19 ms, 288 text re-flows, 618 taffy
misses). AZ_TAFFY_DEBUG + AZ_RECON_DEBUG on the prebuilt app: 607 of the 618
misses are in one region - the form whose column ends with its "Send the raw
form" button (DOM 3459-3461) - and the reconcile builds that button, its <p>
and its text FRESH on every tick ("COUNT MISMATCH parent dom 3459: old 0 new
1", recon_old_idx_none 3, fp_new_node 3), dirtying every ancestor to the body.

- text_after_a_block_is_carried_over_by_the_next_layout: a box with a block
  and then text and a span; a relayout of the unchanged page must build 0
  nodes fresh and find 0 dirty. RED: the trailing inline run is fresh each
  pass.
- The AzWidgets-like card of a_knob_frame_costs_the_same_on_a_page_twice_as_long
  gains the shape LAYOUTPERF8's page lacked: inline content AFTER a block
  ("More about note i" + a span). RED again: every card's trailing run is
  rebuilt each frame, so the cost doubles with the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s old self (GREEN)

reconcile_recursive looked up the inline children of an anonymous block's
run in the old tree two ways: a run BEFORE a block fell back from the parent's
direct children to the whole old tree (`dom_to_layout`), the run that ENDS the
box did not - and those children are never direct children of the parent,
they sit in the anonymous block. So text, a span or a button ending a box
after a block was rebuilt FRESH by every reconcile (with its subtree), was
intrinsic-dirty every pass, and cleared the per-node and flex caches of every
ancestor up to the root.

AzWidgets: the form column ends with its "Send the raw form" button (DOM
3459-3461); every switch-knob frame of the wave-8 build rebuilt it and
re-laid out the form - 607 of the frame's 618 taffy misses, the 288 text
re-flows, most of root_layout_pass's 19 ms. Any page whose boxes end with
text after a block (mail HTML, documents) relaid out from the root on every
relayout, even with nothing changed.

One lookup now, `old_layout_index_of`, for all three sites that match a child
built inside a box the parent's layout made around it: both inline-run
branches and `reconcile_child_under` (anonymous table boxes), which held the
same fallback inline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The font manager keeps one chain cache and one signature of the stacks it was
resolved for; a view's child DOM, laid out inside its host's pass, replaces
both, so the host never matches its own signature again. Prebuilt AzWidgets
(three views): every pass - every switch-knob frame - resolves the page's fonts
again (font_chain_resolve 2.2 ms of a tick; each view pass resolves its own,
~20 us).

RED: after a pass the cache holds no chain for the host's serif stack, and a
relayout of the unchanged host resolves twice (font_load_missing 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…heir signature in place (GREEN)

layout_dom_recursive_with_viewport already swaps the host's layout cache out
for a child DOM's pass; the font manager's chain cache and the signature of
the stacks it was resolved for now get the same treatment. The child resolves
against an empty slot; afterwards the host's chains and signature are back,
with the child's chains for the stacks only it uses kept beside them (text
edited inside the view still finds its chains).

AzWidgets (three views): the page's fonts were resolved again on every pass -
every switch-knob frame paid font_chain_resolve 2.2 ms for a page whose font
stacks never changed. Expected after: the host skips (its signature matches),
each view resolves its own small DOM (~20 us).

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hains, VirtualView passes, expected numbers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the reconcile (no fresh rebuild every layout), VirtualView passes keep the host's font chains

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eal app; 8C investigates on the build

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

The LAYOUTPERF8B re-measure on the 19:04 build was UNCHANGED because it never
ran that build. run_capped exports DYLD_LIBRARY_PATH=target/azul-lib and then
starts `env AZ_BACKEND=headless ... App`; /usr/bin/env is SIP-protected, so the
kernel strips every DYLD_* variable when it starts it, and App loaded the
libazul.dylib its install name points at instead:
target/release/build/azul-dll-78dff1e65276e337/out/libazul.dylib, dated 18:38
(target/azul-lib/libazul.dylib is 19:04 - the build refreshed that copy, not the
build-script one). Shown with a non-SIP python: through the old runner
DYLD_LIBRARY_PATH is None, through this one it is target/azul-lib.

Re-run correctly (no env in between), the same tick on the same build:
solver3 20.0 ms (was 33.6), root_layout_pass 5.8 ms (19.4), text_layout_flow 76
(288), taffy misses 184 (618), no font_chain_resolve in the root pass, no
fresh nodes in the root reconcile, 38.8-43 ms wall profiled (55-58).

The documented `-- env A=B App` form now works: the runner consumes the env
assignments with `export` and starts the program directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…age column measured twice, cards whose height depends on it (RED)

Measured on the 8B build (with the right library - see 653a502): the knob
tick still lays out 184 flex items again and re-flows 76 text runs, all but 11
in the form section. AZ_TAFFY_DEBUG shows why: the page column (layout node 27,
a flex item with a visible overflow) is measured by its container twice per
pass - its basis under a max-content height and its automatic minimum under a
min-content height - and in each run it asks the form section for its width
at the height the section gets in THAT run:
  MISS n2038 kd=(None,Some(938.0)) avail=(Definite(852.0),Definite(12693.0))
  MISS n2038 kd=(None,Some(906.0)) avail=(Definite(852.0),Definite(12661.0))
Two keys, one measurement slot of taffy's cache: each run evicts the other's
entry, so every tick misses both - and every level below repeats it.

The test page gets the same shape: the cards sit in a page column with a
visible overflow inside the scrolling one, and each card holds a group in
wrapping columns (as tall as its options stacked at max-content, as one option
at min-content). a_knob_frame_costs_the_same_on_a_page_twice_as_long is RED
again: every card misses twice per frame, so the cost doubles with the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… measured under two keys of one class keeps both (GREEN)

taffy's Cache keeps one measurement per slot class. A flex container measured
twice per pass under different available main sizes (its basis at max-content,
its automatic minimum at min-content - every flex item with a visible overflow)
asks each item for its cross size at the main size the item got in that run;
an item whose size depends on it (wrapping lines) gets two keys of one class,
each run evicts the other's entry, and every pass misses both with the item's
whole subtree. Now that clean nodes keep their caches across passes
(LAYOUTPERF8), that thrash is what the AzWidgets knob tick still paid: 173 of
its 184 taffy misses and its 76 text re-flows, all in the form section the page
column asked for (None, 938) and (None, 906).

TaffyMeasureSpill (taffy_bridge.rs) keeps the last two distinct keys per class,
with taffy's own match rule. It lives in the node's NodeCache (which follows the
node across passes) and is allocated only at a node's first eviction - a second
store into a class, which can only follow a miss (NodeCache::taffy_slots_stored).
It is part of the taffy cache for validity: never read while the primary is
empty, dropped by the first store after the primary was emptied (every clear -
the dirty closure, a restyle, a clone that cannot keep it - empties the
primary), and with NodeCache::clear. Counted in the memory report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wo ways per class

Counted on the AzWidgets tick (AZ_TAFFY_DEBUG, the right library): the form
section's descendants are asked up to FOUR distinct keys of one slot class
per pass and up to seven in all. Two ways per class would cycle through four
keys and miss every one (LRU with fewer ways than a cyclic set). A ring of 12
measurements of any class, searched with taffy's own rule (its Cache::get
searches all slots too), holds every key such a node is asked; from the second
pass on all of them hit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… thrash, expected numbers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nodes measured at two heights per pass; run_capped starts the app itself (SIP strips DYLD_* through /usr/bin/env)

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

build_link.rs copies the AZ_LINK_PATH library into the build script's
OUT_DIR and sets that copy's absolute path as the install name, but emitted
no rerun-if-changed for it (the static path does). After a dylib rebuild the
apps kept loading the old copy unless DYLD_LIBRARY_PATH reached them, which
SIP prevents through /usr/bin/env - a perf fix measured as 'no change' was
the old library (2026-10-03).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s 157-309)

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant