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.
2.3 Header and footer: replaced with a handle and a search field
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.
docs/evidence/app-drawer/original-before-redesign.png— the true original design (temporarily restored from this branch’s own merge-base,d054d16b, in this same worktree, then reverted back), for a real before/after rather than a description.docs/evidence/app-drawer/redesign-fixture.png— the redesign against a 24-app fixture catalog with no resolvable icons (every tile exercises the round fallback): 4 columns, circular icons, search field, no plates, no header/footer, rounded top sheet corners.docs/evidence/app-drawer/redesign-real-icons.png— the redesign against this repo’s own real installed-app set and real icon packages (viewnior,galculator,zathura,btop, from the built system closure) — a real icon (btop++) resolves and paints correctly alongside the round fallback for the rest of that particular set. See that README’s own stated limits on exactly which apps this ad-hoc environment did and did not surface.
6. What remains open
- Board proof of §3.5’s frame timing: the actual
K230_DRAWER_FRAME ms=values on hardware, to replace §3.4’s estimate with a measurement, and confirm whether the ~20ms target is actually met. - Board proof of the bug fix (§1, tracked against its own commit): a real-finger scroll-then-reverse gesture on glass.
- Board proof of search: real-finger taps on the search field and the compact keyboard, and real-finger scrolling of filtered results.
- The alphabetical fast-scroller, explicitly scoped out (§2.4).
- If §3.5’s board measurement is still over budget despite the cache, the next lever is damage-limited scroll-direction blitting (shift the already-composited on-screen bitmap by the scroll delta and paint only the newly-exposed strip, rather than re-blitting the full viewport from the cached bitmap every frame) — not attempted here, since the cached-bitmap architecture already turns the per-frame cost into one blit of a bounded (viewport-sized) area, not one proportional to catalog size; a further win here is a smaller, marginal one relative to §3.2’s own gain, not the same order of magnitude.