evidence · 20.0 KiB

app-drawer-review.md

Committed at docs/design/app-drawer-review.md. This is what grounds the requirements that cite it.

Open raw source

App drawer review and redesign — September 2026

Requested by the operator: “when i swipe down in the app drawer to scroll back up, it closes the drawer that’s no good also review the design of the app drawer it’s fuggin awful as is performance.” A first pass at the redesign and performance work (grid 3→4 columns, unchanged tile plates, a label raster cache) was reviewed by the coordinator and correctly rejected: “it’s the same design with a 4th column” and the estimated board frame time (95-236ms) “isn’t a fix.” This document describes the actual redesign and performance work that replaced it. Scope: nix/rust-shell-client/src/ navigation.rs, service_ui.rs’s Drawer-only functions, main.rs’s Drawer touch handlers, render.rs’s Drawer paint block, and icon.rs. Out of scope: the theme picker/carousel, the Settings brightness row and the shade (owned by concurrent branches), and Home/dock (a separate grid, home_grid.rs, not touched here). The scroll-reversal-closes-the-drawer bug fix landed separately, in its own commit, before this work; it is not re-described here beyond what this redesign builds on top of it (§1).

This repo already carries docs/design/shell-polish-review-2026-09.md, which reviewed the whole shell including the drawer (§3) three days before this document. That review confirmed search is deferred by an explicit prior decision (the-handheld-presents-a-coherent-shell/design.md decision 2, restated in shell-ux-critique.md §4) — this redesign reopens that decision, at the coordinator’s explicit direction, and implements search (§3).

No board or /dev/ttyACM0 access was used to write this document. Every claim is a direct source read of this worktree, a committed evidence file, or a screenshot rendered on this host via k230-shell-rust --render-fixture drawer OUTPUT.png (see §5) — a real Cairo/Pixman render of the real paint code, not a mockup. Board and real-finger claims are marked UNVERIFIED throughout.

1. What carries over from the bug fix

The drag-to-close fix (a separate, earlier commit on this branch) is unchanged by this redesign: Contact::scrolled_away still gates DrawerNavigation::up’s release-only dismiss check, and service_ui::drawer_close_candidate_after_scroll still gates the live “follow the finger” close drag. This redesign does change where the close-eligible “handle” zone sits, since the header it used to be carved out of no longer exists (§2.3): service_ui::drawer_handle_zone (service_ui.rs:275) now spans the whole, much shorter top chrome (navigation::panel_top..navigation::list_top — handle plus search field, both interactive and neither one prose a scrolling thumb could brush against), replacing the earlier bug-fix commit’s narrower DRAWER_HANDLE_HEIGHT constant. The invariant the bug fix established (the grid itself is close-eligible only once already scrolled to its own top, and a gesture that scrolls away is permanently disqualified) is untouched.

2. The actual redesign

2.1 What the rejected first attempt got wrong

Coordinator’s own words: “it’s the same design with a 4th column.” Correct — the first attempt kept every per-app tile as a themed rounded-rect card (service_card) with an icon-card inset inside it, the full “YOUR DEVICE / All apps / Everything installed…” header, and the “Swipe down to return to cards” footer, and only changed COLUMNS/icon pixel sizes. None of that reads as a Pixel-launcher-style drawer; it reads as the same drawer with a different grid arithmetic. This version removes all of it.

2.2 Per-app tiles: icon and label only, no plate

paint_drawer_tile (render.rs:1640) no longer calls service_card (the rounded-rect card primitive) for the app tile at all. Each cell paints exactly: a 64px icon (DRAWER_ICON_SIZE, render.rs:49), and a single-line, ellipsized 14px label (DRAWER_LABEL_SIZE, render.rs:55) below it, both directly on the sheet’s own background.

Icon fallback: a round, theme-tinted circle, tuned twice on review. First round: a low-alpha (0.30) accent tint over this dark sheet’s own background, which coordinator review correctly called “dark and flat.” Now: a stronger 0.55-alpha accent fill (a tonal container, not a faint tint) with the initial in style.text (bright, guaranteed-contrasting — accent-on-accent would fight the very contrast a tonal container needs), not the accent colour again.

Second round: an initial screenshot (docs/evidence/app-drawer/ redesign-real-icons.png, first revision) showed the fallback circle on nearly every real application — 2048, Clock, Editor, Htop, Net, NetHack, Timer, Video, Weather, only btop++ resolving a real icon. Investigated per the coordinator’s own request (full table in docs/evidence/app-drawer/README.md): a harness gap in this change’s own evidence-gathering, not a resolution failure. Every one of those Icon= names resolves against the board’s real, bundled Yaru-based theme (nix/handheld-theme-icons) via its already-correct Inherits chain (icon.rs::theme_lookup, unchanged) — three of them (htop, foot, mpv) only because the real session’s own XDG_DATA_DIRS (nix/shell.nix’s launcherEnvironment) lists those specific packages’ own share/ trees as individual roots, which the first screenshot’s hand-assembled XDG_DATA_DIRS never included, and never set K230_ICON_THEME either (defaulting to plain hicolor, not this device’s actual Yaru-purple). Re-rendered with the exact real XDG_DATA_DIRS string (extracted directly from the built k230-touch-launcher binary, not hand-assembled) and K230_ICON_THEME=Yaru-purple: every application in the catalog now resolves a real icon — zero fallback circles.

Fixed anyway, as defense in depth, since a future application’s icon name might genuinely not exist in the bundled theme: paint_drawer_tile retries a generic utilities-terminal icon (confirmed present in the bundled theme) for any terminal-emulator-like application (catalog::terminal_like, reading Terminal=/Categories= directly from the raw desktop file) before falling all the way back to the letter circle. Not currently triggered by this catalog, given the harness fix above — every application’s own named icon already resolves.

The “YOUR DEVICE” eyebrow, “All apps” heading, “Everything installed…” subtitle, and the “Swipe down to return to cards” footer are gone entirely — removed from scene()’s Drawer branch, which now returns early into a dedicated paint_drawer (render.rs:1769) that never calls any of that code. In their place: a slim drag handle (navigation::handle_rect, navigation.rs:56) and a rounded, pill-shaped “Search apps” field (navigation::search_field_rect, navigation.rs:64), both painted by paint_drawer_chrome (render.rs:1695). Total top chrome height: 86px, versus the old header’s ~181px.

2.4 Search: implemented, not deferred

Reopening shell-ux-critique.md’s deferred-search decision, at the coordinator’s explicit direction. Tapping the search field focuses it (service_ui::DrawerSearch::focus, service_ui.rs:92) and raises a compact, lowercase-only on-screen keyboard anchored to the sheet’s own bottom edge (navigation::SEARCH_KEYBOARD_HEIGHT = 300px, navigation.rs:85; key layout and hit-testing in navigation::search_keyboard_key_at, navigation.rs:159) — a new, purpose-built compact keyboard, not the existing WiFi password entry’s full QWERTY-with-shift-and-symbols keyboard (render.rs’s paint_wifi), which is hardcoded to that screen’s own full-height layout and not reusable without a separate refactor out of scope here. Lowercase-only is deliberate, not a shortcut: matching is already case-insensitive (service_ui::filter_app_indices, service_ui.rs:142, a plain lowercased-substring scan — cheap enough, over this catalog’s bounded 128-entry cap, that it needs no cache of its own, unlike the painting of the results), so a shift/symbols row would only let someone type characters that can never change which apps match. Filtering is live: every keystroke re-filters and the grid relayouts immediately (still backed by the same cached-bitmap architecture, §3 — a keystroke is exactly one of the events that legitimately invalidates that cache). Filtered results map back to real catalog entries via ShellClient:: drawer_filtered_apps (main.rs:2349), so DrawerAction::Launch/ LongPress (still a display index, matching tile_at’s own convention) resolve to the right app regardless of what’s currently filtered out.

An alphabetical fast-scroller remains out of scope, unchanged from the earlier review’s own reasoning: it would need its own hit-region carved out of the grid’s right edge, coupling grid-width layout math to whether the rail is showing — real work, and this device’s bounded, small app catalog (128-entry cap, and the real installed set is far smaller) does not have the “thousands of apps” problem a rail solves.

2.5 The sheet itself

paint_drawer fills from navigation::panel_top (a fixed 32px inset from the very top of the screen, replacing the old height * 0.19 ≈ 234px gap) down to the screen’s own bottom edge, with the top two corners rounded at 28px (rounded_top, render.rs:1506 — a new helper; the old sheet had no distinct corner rounding of its own at all, per shell-polish-review- 2026-09.md’s own finding) and a flat, opaque theme surface colour (not a gradient brush — see that function’s own comment on why: a gradient brush’s fill_brush issues its own plain-rectangle path, which would overwrite the rounded-top path already set). panel_travel_height’s Drawer case (render.rs) changes from a fixed h * 0.81 to h - navigation::panel_top(height), keeping the compositor’s own reveal travel consistent with the new, taller sheet.

2.6 The grid: 4 columns, 64px icons, ~110px rows, even gutters, centered

navigation.rs: COLUMNS = 4 (navigation.rs:12), ROW_HEIGHT = 110.0 (navigation.rs:18, replacing the old 160px pitch plus a separate 148px tile height — one number now, since there is no plate height to distinguish from row pitch), margin and inter-column gap both 24.0px. At this panel’s 568px width that divides out exactly: 2×24 + 3×24 + 4×112 = 568 — the grid is centered with no remainder and no separate centering offset to compute, and every gap (left margin, each inter-column gap, right margin) reads as one even rhythm rather than a tighter gap inside a wider border. Within each 112px cell, the 64px icon is centered with 24px clearance on every side — matching the gutter exactly. GRID_BOTTOM_INSET shrinks from 72px to a plain 24px safe-bottom margin (navigation.rs:26) now that there is no footer caption reserving space.

2.7 Press feedback

A subtle, low-alpha (0.22) round highlight behind the icon (paint_drawer, drawn as an overlay after the cached grid blit, radius 8px larger than the icon), not the previous bordered-square outline — matches the redesign’s “just the icon” tile shape (a square border around a now-plateless tile would look like a stray artifact, not feedback).

2.8 Already correct, unchanged

Long-press-to-pin (DrawerAction::LongPress, 500ms, navigation.rs) and the fling deceleration curve (DrawerNavigation::tick’s exponential decay) were already right (per the earlier review) and are untouched.

3. Performance: a cached grid bitmap, not a bigger label cache

3.1 Why the first attempt’s fix wasn’t enough

The rejected first attempt added a bounded label-raster cache (IconCache::paint_label) and measured ~20% per-frame improvement, scaled to an estimated 95-236ms/frame board cost — “isn’t a fix,” per the coordinator, correctly: caching labels did nothing about the dominant remaining cost, which was that every visible tile still repainted its full background card, icon-card backdrop and border every single frame, whether or not that tile’s content had changed, plus a full-panel background fill every frame regardless of scroll. RendererCache::draw’s own rebuild trigger (any scroll delta ≥ 0.25px) forces a fresh scene() call on every scroll tick; nothing about a label cache changes how much work that call does.

3.2 The actual fix: DrawerGridCache

DrawerGridCache (render.rs:1546) pre-renders the entire filtered grid — every row, not just the visible ones — into one off-screen ARGB32 bitmap, in content-space coordinates (row 0 at bitmap y = 0, independent of live scroll), keyed on (filtered display list, theme generation, panel width, search query). It is rebuilt only when that key actually changes — a catalog rescan, a theme swap, a width change, or a keystroke in search — never on a plain scroll or fling tick.

paint_drawer (render.rs:1769) then does, every frame: paint the fixed chrome (handle, search field, sheet background — cheap, a handful of small fixed shapes, unrelated to catalog size), clip to the grid’s own viewport rectangle, and blit one vertical slice of the cached bitmap at row_start - scroll — a single cairo_set_source_surface + cairo_paint, not a loop over every visible tile. The press highlight is drawn as a small circular overlay on top afterward (cheap, and it changes independently of the cached content, so baking it into the cache would force a rebuild on every press/release instead of never).

IconCache::paint_label (icon.rs:381, LABEL_CACHE_LIMIT = 48, icon.rs:295) is retained from the earlier attempt and still earns its keep: it is what makes each (rare) cache rebuild cheap, since a keystroke in search rebuilds a mostly-overlapping filtered set whose labels mostly did not change text between one keystroke and the next.

3.3 Host measurement (host build, not board)

A committed, opt-in benchmark (render::tests:: drawer_grid_cache_host_timing, #[ignore]d so it never runs in an ordinary cargo test and cannot make CI flaky on wall-clock noise; run explicitly with cargo test --lib --release drawer_grid_cache_host_timing -- --ignored --nocapture) drives the real RendererCache::draw path — not a lower-level helper — over 200 simulated scroll/fling frames of a 64-app catalog (half with a real icon path, half without, so both IconCache::paint and the round-circle fallback are exercised).

host: AMD Ryzen 9 5950X, single thread, cargo test --release
cold (fresh RendererCache every frame, forcing a full grid-bitmap
      rebuild each time -- this change's "before"):    33.60 ms/frame  mean over 200 frames
warm (persistent cache, scroll-only reuse -- this
      change's "after"):                                0.87 ms/frame  mean over 200 frames

A 38.8× reduction in this benchmark’s own cold-vs-warm terms — well past the “order of magnitude” bar, because the dominant cost this time really was the per-tile repaint work the cache now skips entirely on an ordinary scroll frame, not text shaping.

3.4 Honest board estimate — UNVERIFIED

Scaled by this repo’s own established, explicitly-labeled convention for converting a host measurement on this same 5950X into a board estimate (docs/evidence/card-shell/backdrop-blur-feasibility.md §3: 20-40× slower for a single-issue, in-order 1.6GHz C908 core versus a ~4.5GHz out-of-order desktop core on this kind of scalar, Pixman-backed paint workload — “not derived from any board measurement of this workload […] offered so the conclusion below is checkable, not so the number is treated as calibrated”):

warm estimated board: 17.3 - 34.6 ms/frame

This straddles the ~20ms (50fps) target: at the low end of the multiplier’s own range it clears it (17.3ms), at the high end it does not (34.6ms). This is a real, order-of-magnitude improvement over both prior estimates (the original uncached tile-by-tile approach’s 118-236ms, and the label-cache-only attempt’s 95-236ms) and plausibly close to or at the target — but it is still an estimate, not a measurement, and is reported as such rather than claimed as a pass. §3.5 is the concrete gate that resolves it.

A themed board additionally likely pays less than this benchmark’s own theme: None case for the fixed chrome’s background fill (a flat/brush fill instead of a gradient fallback), so real numbers may run slightly better than this estimate, not worse.

3.5 Board-readable timing log

K230_DRAWER_FRAME (main.rs’s draw, gated to Route::Drawer, DRAWER_FRAME_LOG_INTERVAL = 500ms, main.rs:117, so a sustained scroll/fling logs roughly 2 samples/second rather than one per frame) logs rust-shell {N}ms K230_DRAWER_FRAME ms={frame_render_ms:.2} apps={app_count} scroll={scroll:.0} to the journal via the shell’s existing self.log(...) — no K230_TRACE_PATH capture session required. The coordinator can read this directly on the board with journalctl --user -u k230-shell-rust -f | grep K230_DRAWER_FRAME (or the console equivalent) while scrolling the drawer, and compare the real per-frame ms= value against §3.4’s estimate. This is the concrete gate that resolves §3.4’s UNVERIFIED estimate into a real number — this change adds the instrumentation; it does not itself constitute the board measurement.

4. Commits

Per the coordinator’s explicit instruction, the drag-to-close bug fix (§1) landed in its own commit, with its own tests, with no layout changes — separately from this redesign-and-performance work, which follows in a later commit on the same branch. The redesign and the performance work land together in that later commit: they are not cleanly separable in this implementation, since DrawerGridCache/paint_drawer is the single piece of new code that both paints the redesigned tiles and is the caching mechanism itself — there was no intermediate “redesigned but uncached” version of this code to split a commit boundary through.

5. Evidence

Three screenshots, all real Cairo/Pixman renders of the production paint code (k230-shell-rust --render-fixture drawer OUTPUT.png), at the real panel geometry (568×1232). Exact commands, fixture construction and stated limits: docs/evidence/app-drawer/README.md.

6. What remains open