Jank hunt: repro on emulator, rule out 6 causes, add A/B tooling

Findings (emulator programmatic-scroll repro, phone log data):
- In-place cell updates (cells.set_row_data) replaced whole-row
  replacement: no subtree teardown, texture upload only on pixel
  change. MAX_PAINT_CELLS_PER_TICK=2.
- Static placeholder replaces the animation-tick() shimmer: the per-cell
  sin() binding was O(all 8577 cells) every frame (71% CPU idle).
  O(visible) shimmer is M6 work.
- nblog: non-blocking logger (bounded mpsc + drain thread calling
  __android_log_write). logd backpressure was stalling the UI thread
  10-40 ms. android_logger dependency no longer used on-device.
- cpuload (SELinux-blocked) removed; min_refresh_rate=120 pin kills the
  SystemUI 60Hz flap (noted in HANDOFF, reversible).
- Emulator: during every 28-146 ms stall the UI thread is 100% on-CPU;
  capping the list to 300 items does NOT help (scene size is not the
  cost); grey 1x1 images cut 30-146 ms to a steady ~16.6 ms -> the
  cost is software-GL rasterization of ~30 textured cells at 1080x2400
  (emulator ceiling; the emulator validates logic, not feel).
- Phone 28-39 ms spikes are NOT first-paint/upload timing (they happen
  ~10x more often than cells enter the viewport) and not upload CPU
  cost (1.2-2.9 ms). Left: GPU/present contention vs Slint EGL path.

Tooling (all tagged REMOVE after the hunt, file-gated, off by default):
- .gallery_cmd scroll hook + SCRDBG/TICK/APLY logs
- .gallery_max (item cap) and .gallery_grey (1x1 images) A/B knobs
- HANDOFF 8.5: full findings + decisive phone A/B plan for tomorrow
- jsample.c (NDK /proc sampler, /tmp): per-thread state+wchan

Also: fix pre-existing doctest (indented module-doc block parsed as
Rust); 15 unit tests + doctest green; aarch64 release APK builds.
This commit is contained in:
Greg Pomerantz 2026-10-28 00:43:35 -04:00
parent 2e9e5b78b6
commit a6b5e31924
4 changed files with 586 additions and 172 deletions

View File

@ -336,6 +336,71 @@ Measure with: `adb shell am start -W` (cold start),
`dumpsys gfxinfo wow.gmp.gallery framestats`, Perfetto trace (GPU + main + `dumpsys gfxinfo wow.gmp.gallery framestats`, Perfetto trace (GPU + main +
pool threads), and `logcat` pool timing logs behind a `debug_timing` flag. pool threads), and `logcat` pool timing logs behind a `debug_timing` flag.
## 8.5 Jank hunt (IN PROGRESS — phone is the acceptance target)
Symptom: user reports janky grid scroll (moderate drag, no fling) on the
phone (Pixel 9 Pro, 120 Hz). Emulator repro via programmatic scroll hook.
### What is ruled out (all measured)
- **App tick cost**: 7–34 µs/tick, even inside 28–39 ms gaps. Not the tick.
- **Logd backpressure**: fixed — `nblog` (bounded mpsc + drain thread,
nanosecond `try_send` from UI). Idle CPU 71% → 5%.
- **60/120 Hz display zone flap**: SystemUI (uid 10156) was asserting a 60 Hz
override every minute; pinned with `settings put system min_refresh_rate
120` (reversible: `settings delete system min_refresh_rate`).
- **Shimmer animation**: `animation-tick()` in a per-cell binding evaluated
O(ALL 8577 cells) `sin()` every frame (70.9% CPU idle). Now a static
placeholder; proper O(visible) shimmer is M6 work.
- **Whole-row model replacement**: now in-place `cells.set_row_data` (one
property change; texture upload only when pixels change).
- **Texture upload CPU**: APLY 1.2–2.9 ms/cell (480 KB) — under budget, and
too cheap to explain 28–39 ms (would need 12–17 MB/s on a Mali).
### Measured facts
- Emulator, programmatic scroll (`/sdcard/Download/.gallery_cmd` →
`scroll <px/ms> <ms>`), real textures: 28–146 ms cadence. During every
stall the UI thread is **100 % on-CPU** (jsample: state R, wchan 0) —
Slint's render pass is CPU-bound.
- Emulator, 300 items instead of 5852: **equally janky** → scene/item count
is NOT the cost driver.
- Emulator, grey 1×1 images (`.gallery_grey` touch file): steady ~16.6 ms
(60 Hz pacing), worst 44 ms → the cost is **rasterizing ~30 live textured
cells at 1080×2400 through the emulator's software GL (llvmpipe)**.
The emulator has a hard rendering ceiling and can never scroll this
smoothly — it validates LOGIC (invariants, paint coverage), not feel.
- Phone: 64 % of frames on the 8.3 ms (120 Hz) grid; ~4 % take 28–39 ms of
non-tick work with d≈0 (UI thread not in our code). Frequency ≈ every
170 ms — much faster than new cells enter the viewport (~1.7 s), so it is
NOT first-paint/upload timing. Suspects left: GPU/present contention
(Mali + SystemUI both at 120 Hz), Slint EGL/swap path, or an
event-loop/vsync interaction. Needs the phone.
### Tools / hooks in the tree (all tagged "REMOVE after the hunt")
- `src/lib.rs`: `DbgScroll` + `.gallery_cmd` file (also `stop`);
knobs `.gallery_max` (item cap) and `.gallery_grey` (1×1 grey images);
`SCRDBG`/`JANKTEST`/`TICK`/`APLY` logs at `info`.
- `jsample.c` (in /tmp, NDK-cross with
`~/android-sdk/ndk/27.0.12077973/.../x86_64-linux-android26 -static`):
per-thread `/proc stat` + state + wchan sampler at 1 ms; usage
`jsample <interval_ms> <pid> <tid...>`.
### Next (phone, morning)
1. **Decisive A/B on the phone**: `touch /sdcard/Download/.gallery_grey`,
restart app, user scrolls 30 s, capture logcat. Then remove the file,
restart, repeat. Grey smooth + textured janky → texture/GPU path is the
culprit (then: smaller decode target, upload strategy, possibly Slint
image pipeline). Grey also janky → base render/present path (then: Slint
window/EGL config, refresh-rate interaction, vsync driver).
2. Run `jsample` over one of the user's real scrolls and wchan-correlate
the 28–39 ms windows (is android_main R, S/futex, or S/do_epoll_wait?).
3. Compare against Fossify (installed on the phone, user says smooth):
`dumpsys SurfaceFlinger --latency` on both apps' layers during identical
scrolls.
4. Candidate code changes (after the A/B): GRID_TARGET 500→400 (one-time
cache invalidation — don't do it before knowing the culprit), 1 painted
cell/tick instead of 2, worker nice level (+2) so turbojpeg bursts
can't preempt the UI thread.
## 9. Milestones ## 9. Milestones
- **M0 — scaffold (DONE)**: builds (x86_64 + aarch64 toolchain), installs, - **M0 — scaffold (DONE)**: builds (x86_64 + aarch64 toolchain), installs,

View File

@ -4,31 +4,41 @@
//! - scan thread: one per launch, walks storage, owns the scan DB connection. //! - scan thread: one per launch, walks storage, owns the scan DB connection.
//! - thumb engine: process-wide pool (dispatcher + N workers), created on the //! - thumb engine: process-wide pool (dispatcher + N workers), created on the
//! UI thread once; workers own one DB connection each (WAL). //! UI thread once; workers own one DB connection each (WAL).
//! - UI thread: owns the window + model; runs the reconcile tick 10x/s via a //! - UI thread: owns the window + model; runs the reconcile tick at display
//! Slint timer; builds `slint::Image` here (Image is !Send, pixel bytes are). //! rate (~120 Hz, VSync-quantized on Android) via a Slint timer; builds
//! `slint::Image` here (Image is !Send, pixel bytes are).
//! //!
//! # Thumbnail loading: viewport reconciliation (rewritten 2026-10-27) //! # Thumbnail loading: viewport reconciliation (v3, 2026-10-27)
//! //!
//! One source of truth for what matters: the **viewport** (visible rows ± //! One source of truth for what matters: the **viewport** (visible rows ±
//! `MARGIN_ROWS`), updated by scroll events — and only by them. Scroll //! `MARGIN_ROWS`). Its ground truth is POLLED from the list's actual
//! handlers do no work; they move the target. //! geometry every tick (`list-y`/`list-height`/`list-width` in main.slint):
//! the `scrolled` callback is only a fast-path hint for drags, because
//! Slint 1.18's Flickable fires `flicked` on pointer moves and ONCE at fling
//! start — the momentum animation updates content-y per frame WITHOUT
//! re-firing it (i-slint-core items/flickable.rs, `animate()`). A callback-
//! only viewport freezes for the whole fling and is the "stop → shimmer →
//! small move → pop" bug.
//! //!
//! The 10 Hz tick is the single authority. Every tick, looking only at //! The ~120 Hz tick is the single authority. Every tick, looking only at
//! CURRENT state (never at scroll history): //! CURRENT state (never at scroll history), doing work bounded to ONE frame
//! 1. CONSUME: drain finished results; paint the viewport ones (capped); //! (≤4 cell paints + ≤4 cell evictions — spread across frames, never batched,
//! so content updates land every frame instead of in 100 ms pulses):
//! 1. CONSUME: drain finished results; paint the viewport ones (≤4 cells);
//! drop the rest — every finished decode is on the disk cache, so a //! drop the rest — every finished decode is on the disk cache, so a
//! dropped result costs a ~10 ms cache hit on re-entry, never a re-decode. //! dropped result costs a ~10 ms cache hit on re-entry, never a re-decode.
//! 2. EVICT: release pixels for painted cells far outside the viewport //! 2. EVICT: release pixels for painted cells outside the RETENTION band
//! (farthest first; row-budgeted, plus a hard `MAX_PAINTED` cap). //! (viewport ± `RETENTION_ROWS`, farthest first; ≤4 cells/tick; hard
//! `MAX_PAINTED` cap). Retention (≈2 screens, ~165 MB) is much wider
//! than the submit margin so short back-scrolls are pixel-instant.
//! 3. FILL: submit every viewport cell that has no pixels and no in-flight //! 3. FILL: submit every viewport cell that has no pixels and no in-flight
//! job, center-first. //! job, center-first.
//! 4. PRUNE: drop queued jobs outside the retention band (belt; the engine //! 4. PRUNE: drop queued jobs outside the submit band (belt; the engine
//! also enforces this per-worker — see below). //! also enforces this per-worker — see below).
//! //!
//! The invariant, RESTORED unconditionally each tick: //! The invariant, RESTORED unconditionally each tick: every viewport cell
//! //! is PAINTED, or has a job IN FLIGHT, or is permanently FAILED
//! every viewport cell is PAINTED, or has a job IN FLIGHT, //! (undecodable).
//! or is permanently FAILED (undecodable).
//! //!
//! Because it is restored (not maintained), no scroll pattern, cap hit, //! Because it is restored (not maintained), no scroll pattern, cap hit,
//! dropped result, or list reset can leave a visible cell forgotten until //! dropped result, or list reset can leave a visible cell forgotten until
@ -45,14 +55,17 @@
//! A burst is never bigger than the viewport (~45), so fresh viewport work //! A burst is never bigger than the viewport (~45), so fresh viewport work
//! is never itself dropped. //! is never itself dropped.
//! //!
//! Consequences (stress-verified 2026-10-27, emulator debug build): //! Consequences (stress-verified 2026-10-27, emulator debug build + phone):
//! - After any stop — fast up/down, cold landing, or chaos flicking — the //! - Newly revealed cells are submitted within one tick (≤8–16 ms) of
//! viewport is fully covered within decode throughput time (0–120 ms warm, //! becoming visible — including while a fling's momentum carries the list,
//! ~1 s for a cold 45-cell landing on a slow emulator; ~0.25 s on a phone). //! because the tick polls the real position. A disk-cache hit (5–15 ms)
//! - No stale backlog exists: measured oldest in-flight job after chaos //! finishes while the cell is still in the margin: back-scrolls within the
//! scrolling was 217 ms (pre-fix design: 8 s), max queue depth one //! retention band are pixel-instant (0 ms), beyond it ~10–20 ms.
//! viewport. A visible cell is empty only while its decode is genuinely //! - No stale backlog exists: worker-queue prepend + cap keep the viewport
//! in flight — never "until the next small scroll move". //! first (measured oldest in-flight job after chaos scrolling: 217 ms vs
//! 8 s pre-redesign; max queue depth one viewport).
//! - A visible cell is empty only while its decode is genuinely in flight —
//! never "until the next small scroll move".
//! //!
//! # Memory bound //! # Memory bound
//! Grid cells decode at 500 px (~0.7 MB RGB8 each); the model may NOT keep //! Grid cells decode at 500 px (~0.7 MB RGB8 each); the model may NOT keep
@ -80,7 +93,7 @@ use slint::{
}; };
use crate::scan::Entry; use crate::scan::Entry;
use crate::thumb::{Engine, Job, JobResult, GRID_TARGET}; use crate::thumb::{Engine, Job, JobResult, GRID_TARGET, MAX_WORKER_QUEUE};
/// Must match main.slint (GridRow layout + ListView spacing + padding). /// Must match main.slint (GridRow layout + ListView spacing + padding).
const GRID_COLS: usize = 3; const GRID_COLS: usize = 3;
@ -88,20 +101,42 @@ const GRID_PADDING: f32 = 12.0;
const GRID_GAP: f32 = 4.0; const GRID_GAP: f32 = 4.0;
const ROW_SPACING: f32 = 4.0; const ROW_SPACING: f32 = 4.0;
/// Rows of margin around the visible range. The union is the "viewport": /// Rows of margin around the visible range. The union is the "viewport":
/// the only cells the loader ever submits; painted cells at this distance /// the only cells the loader submits. A margin of 4 rows means a cell is
/// or beyond are evicted. /// submitted ~100 ms before it becomes visible at fling speed, so its
/// decode (disk-cache hit ≈ 5–15 ms) finishes before reveal.
const MARGIN_ROWS: usize = 4; const MARGIN_ROWS: usize = 4;
/// Rows of pixel RETENTION around the viewport (L1 cache): painted cells
/// within this distance keep their pixels (instant re-show on back-scroll);
/// beyond it they are evicted to the disk cache (re-show ≈ 10–20 ms).
/// RETENTION > MARGIN on purpose: the RAM cost is 750 KB/cell and it makes
/// short back-scrolls pixel-instant (compute vs RAM: ~165 MB total, see
/// MAX_PAINTED).
const RETENTION_ROWS: usize = 32;
/// Viewport used until the first scroll event (≈2 screens). /// Viewport used until the first scroll event (≈2 screens).
const INITIAL_VIEWPORT_CELLS: usize = 60; const INITIAL_VIEWPORT_CELLS: usize = 60;
/// Hard cap on cells holding pixels in the model (memory safety valve for /// Hard cap on cells holding pixels in the model: 225 × ~0.73 MB ≈ 165 MB
/// pathological flicking; steady state is far below this). /// (≈1–2 % of a modern phone's RAM; bounded by RETENTION_ROWS in practice).
const MAX_PAINTED: usize = 120; const MAX_PAINTED: usize = 225;
/// Max ROW replacements per tick (paints + evictions share the budget). /// Jank-hunt test knob (REMOVE after): live cap on painted cells, default
/// Bounding per-tick work is what keeps the UI thread at frame rate: /// MAX_PAINTED; overridable via /sdcard/Download/.gallery_max_painted.
/// without it, a mass update after a 200-cell cold fill ANR'd (2026-10-27). static MAX_PAINTED_LIVE: std::sync::atomic::AtomicUsize =
const MAX_ROWS_PER_TICK: usize = 16; std::sync::atomic::AtomicUsize::new(MAX_PAINTED);
const MAX_CELLS_PER_TICK: usize = MAX_ROWS_PER_TICK * GRID_COLS; /// Jank-hunt test knob (REMOVE after): 1x1 grey cell images (no texture work).
const DRAIN_INTERVAL_MS: u64 = 100; static JANK_GREY_IMAGES: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// Per-tick (per-frame) model work budgets. The tick runs at display rate
/// (~120 Hz): work MUST fit one frame, so updates are SPREAD across frames
/// instead of batched (the old 16-row/100 ms batch was visible as a content
/// pulse + frame hitches during scroll). Each update is an IN-PLACE nested
/// model mutation (one cell property change; texture upload only when the
/// pixels actually change) — cheap enough for 4+4 per tick.
const MAX_PAINT_CELLS_PER_TICK: usize = 2;
const MAX_EVICT_CELLS_PER_TICK: usize = 4;
/// Reconcile tick period. 8 ms ≈ one 120 Hz frame; Slint's event loop is
/// VSync-paced on Android, so the actual cadence quantizes to the display
/// refresh (8 ms on 120 Hz, ~16 ms on 60 Hz). Per-tick work is tiny and
/// idempotent, so jitter is harmless.
const DRAIN_INTERVAL_MS: u64 = 8;
/// A viewport cell waiting longer than this for its in-flight job is a /// A viewport cell waiting longer than this for its in-flight job is a
/// stall; log a diagnostic (rate-limited). /// stall; log a diagnostic (rate-limited).
const STALL_ALARM_MS: u64 = 500; const STALL_ALARM_MS: u64 = 500;
@ -129,6 +164,23 @@ struct AppState {
painted_total: AtomicU64, painted_total: AtomicU64,
/// Rate-limits the stall alarm. UI thread only. /// Rate-limits the stall alarm. UI thread only.
last_stall_log: Mutex<Instant>, last_stall_log: Mutex<Instant>,
/// Jank-hunt test hook: file-triggered programmatic scroll (see tick).
dbg: Mutex<DbgScroll>,
}
/// Test hook (jank hunt): `/sdcard/Download/.gallery_cmd` with
/// `scroll <px_per_ms> <duration_ms>` or `stop` drives the list at a
/// perfectly smooth constant velocity — an input source with ZERO transport
/// jitter (adb `input` injects through virtio at ~60 Hz in bursts, which
/// masquerades as app jank). Removes the OS input pipeline from the
/// equation: if a programmatic scroll is janky, the app/render is at fault.
struct DbgScroll {
active_until: Option<Instant>,
vel: f32,
cmd_mtime: Option<std::time::SystemTime>,
last_check: Instant,
last_y: f32,
last_t: Instant,
} }
/// Create the pool + cache dir once per process. /// Create the pool + cache dir once per process.
@ -138,16 +190,31 @@ fn state() -> &'static AppState {
let cache_dir = base.join("thumbs"); let cache_dir = base.join("thumbs");
let workers = let workers =
(std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4) / 2).clamp(2, 8); (std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4) / 2).clamp(2, 8);
// 96 = thumb::MAX_WORKER_QUEUE: bound each worker's pending queue so // MAX_WORKER_QUEUE (96): bound each worker's pending queue so stale
// stale transit work can never delay the viewport (overflow is // transit work can never delay the viewport (overflow is dropped
// dropped with a `skipped` result; re-entry is a disk-cache hit). // with a `skipped` result; re-entry is a disk-cache hit).
let engine = Engine::new(cache_dir, workers, GRID_TARGET, 96).expect("thumb engine"); let engine =
Engine::new(cache_dir, workers, GRID_TARGET, MAX_WORKER_QUEUE).expect("thumb engine");
log::info!( log::info!(
"gallery: thumb engine up: {} workers, target {} px, cache {}", "gallery: thumb engine up: {} workers, target {} px, cache {}",
workers, workers,
GRID_TARGET, GRID_TARGET,
engine.cache_dir().display() engine.cache_dir().display()
); );
// Jank-hunt test knobs (REMOVE after).
#[cfg(target_os = "android")]
{
if let Ok(s) = std::fs::read_to_string("/sdcard/Download/.gallery_max_painted") {
if let Ok(m) = s.trim().parse::<usize>() {
MAX_PAINTED_LIVE.store(m, Ordering::Relaxed);
log::info!("JANKTEST max_painted -> {m}");
}
}
if std::path::Path::new("/sdcard/Download/.gallery_grey").exists() {
JANK_GREY_IMAGES.store(true, Ordering::Relaxed);
log::info!("JANKTEST grey images ON");
}
}
AppState { AppState {
engine, engine,
entries: Mutex::new(Vec::new()), entries: Mutex::new(Vec::new()),
@ -157,6 +224,14 @@ fn state() -> &'static AppState {
failed: Mutex::new(HashSet::new()), failed: Mutex::new(HashSet::new()),
painted_total: AtomicU64::new(0), painted_total: AtomicU64::new(0),
last_stall_log: Mutex::new(Instant::now()), last_stall_log: Mutex::new(Instant::now()),
dbg: Mutex::new(DbgScroll {
active_until: None,
vel: 0.0,
cmd_mtime: None,
last_check: Instant::now(),
last_y: 0.0,
last_t: Instant::now(),
}),
} }
}) })
} }
@ -171,9 +246,9 @@ fn empty_cell() -> Cell {
/// Chunk entries into rows of GRID_COLS (trailing cells empty). The `cells` /// Chunk entries into rows of GRID_COLS (trailing cells empty). The `cells`
/// field is a nested model (ModelRc<Cell>), not a Vec: the grid template /// field is a nested model (ModelRc<Cell>), not a Vec: the grid template
/// iterates it per row. Cell updates (apply_row_batch) replace the whole /// iterates it per row. Cell updates (apply_cell_updates) mutate the nested
/// outer row — the only update path that reaches ListView rows recycled /// model IN PLACE — Slint's ListView keeps every row alive (rendered items
/// while off-screen (verified on emulator, 2026-10-27). /// are culled, not recycled), so the live row's model is always reachable.
fn chunk(entries: &[Entry]) -> Vec<Item> { fn chunk(entries: &[Entry]) -> Vec<Item> {
entries entries
.chunks(GRID_COLS) .chunks(GRID_COLS)
@ -213,6 +288,23 @@ fn set_list(weak: &slint::Weak<Main>, entries: Vec<Entry>, status: String) {
app.inflight.lock().unwrap().clear(); app.inflight.lock().unwrap().clear();
app.painted.lock().unwrap().clear(); app.painted.lock().unwrap().clear();
app.failed.lock().unwrap().clear(); app.failed.lock().unwrap().clear();
// Jank-hunt test knob (REMOVE after): cap the item count to isolate
// scene-size cost from textured-cell cost.
#[cfg(target_os = "android")]
let entries = {
let e = entries;
match std::fs::read_to_string("/sdcard/Download/.gallery_max").ok().and_then(
|s| s.trim().parse::<usize>().ok(),
) {
Some(m) if m < e.len() => {
log::info!("JANKTEST capping items {} -> {}", e.len(), m);
let mut e = e;
e.truncate(m);
e
}
_ => e,
}
};
*app.entries.lock().unwrap() = entries.clone(); *app.entries.lock().unwrap() = entries.clone();
let n = entries.len(); let n = entries.len();
*app.viewport.lock().unwrap() = 0..INITIAL_VIEWPORT_CELLS.min(n); *app.viewport.lock().unwrap() = 0..INITIAL_VIEWPORT_CELLS.min(n);
@ -233,61 +325,73 @@ fn set_status(weak: &slint::Weak<Main>, status: String) {
}); });
} }
/// Apply a batch of cell paints + evictions, replacing each affected OUTER /// Apply cell paints + evictions as IN-PLACE nested-model updates (UI
/// row exactly once (UI thread). Row replacement — not in-place nested-model /// thread): each cell is one `cells.set_row_data(col, cell)` on the live
/// updates — is the only update path that reliably reaches ListView rows /// row's own model. Only that cell's property changes — no element subtree
/// that were recycled while off-screen (gotcha #12). Returns the number of /// is recreated and textures are re-uploaded only when the pixels actually
/// paint cells applied. At most `MAX_ROWS_PER_TICK` rows are applied; /// change. (Whole-row replacement destroyed/recreated 3 CellView subtrees
/// unapplied paints are dropped on purpose — their cells are no longer /// AND re-uploaded all 3 textures per row; batched at 10 Hz/16 rows that
/// painted and no longer in flight, so the fill step re-submits them (disk- /// was the scroll hitch + content pulse.) Slint's ListView for-loop keeps
/// cache hit) and they land next tick. /// every row alive, so the live row's model is always reachable.
fn apply_row_batch( /// Returns the number of paint cells applied. Paints are capped at
/// `MAX_PAINT_CELLS_PER_TICK`; unapplied paints are dropped on purpose —
/// their cells are no longer painted and no longer in flight, so the fill
/// step re-submits them (disk-cache hit) and they land next tick.
fn apply_cell_updates(
app: &AppState, app: &AppState,
m: &VecModel<Item>, m: &VecModel<Item>,
entries: &[Entry], entries: &[Entry],
paints: &[JobResult], paints: &[JobResult],
clears: &[usize], clears: &[usize],
) -> usize { ) -> usize {
use std::collections::BTreeMap; let mut painted_now = 0usize;
// row -> (col, new cell) updates
let mut rows: BTreeMap<usize, Vec<(usize, Cell)>> = BTreeMap::new();
for r in paints { for r in paints {
if painted_now >= MAX_PAINT_CELLS_PER_TICK {
break; // the rest is re-covered by the fill step
}
if !r.ok || r.id >= entries.len() || entries[r.id].path != r.path { if !r.ok || r.id >= entries.len() || entries[r.id].path != r.path {
continue; // list moved on; drop continue; // list moved on; drop
} }
let row = r.id / GRID_COLS;
if row >= m.row_count() {
continue;
}
// row_data clones the Item struct (the ModelRc is shared with the
// model row), so mutating it mutates the live model the grid binds
// to.
let Some(item) = m.row_data(row) else {
continue;
};
let Some(cells) = cells_of(&item) else {
continue;
};
// Jank-hunt test knob (REMOVE after): 1x1 grey images — same item
// count, zero real texture work. Isolates base window-render cost.
let image = if JANK_GREY_IMAGES.load(Ordering::Relaxed) {
let mut g = SharedPixelBuffer::<Rgb8Pixel>::new(1, 1);
g.make_mut_bytes().fill(32);
Image::from_rgb8(g)
} else {
let mut buffer = SharedPixelBuffer::<Rgb8Pixel>::new(r.width, r.height); let mut buffer = SharedPixelBuffer::<Rgb8Pixel>::new(r.width, r.height);
buffer.make_mut_bytes().copy_from_slice(&r.rgb); buffer.make_mut_bytes().copy_from_slice(&r.rgb);
rows.entry(r.id / GRID_COLS) Image::from_rgb8(buffer)
.or_default() };
.push(( cells.set_row_data(
r.id % GRID_COLS, r.id % GRID_COLS,
Cell { Cell {
path: SharedString::from(r.path.clone()), path: SharedString::from(r.path.clone()),
image: Image::from_rgb8(buffer), image,
loaded: true, loaded: true,
}, },
)); );
app.painted.lock().unwrap().insert(r.id);
painted_now += 1;
} }
for &id in clears { for &id in clears {
if id >= entries.len() { if id >= entries.len() {
continue; continue;
} }
rows.entry(id / GRID_COLS).or_default().push(( let row = id / GRID_COLS;
id % GRID_COLS,
Cell {
path: SharedString::from(entries[id].path.clone()),
image: Image::default(),
loaded: false,
},
));
}
let mut n = 0usize;
let mut rows_done = 0usize;
for (row, updates) in rows {
if rows_done >= MAX_ROWS_PER_TICK {
break; // ANR guard; the rest is re-covered by the fill step
}
rows_done += 1;
if row >= m.row_count() { if row >= m.row_count() {
continue; continue;
} }
@ -297,79 +401,172 @@ fn apply_row_batch(
let Some(cells) = cells_of(&item) else { let Some(cells) = cells_of(&item) else {
continue; continue;
}; };
let mut new_cells: Vec<Cell> = ( cells.set_row_data(
0..cells.row_count().min(GRID_COLS) id % GRID_COLS,
) Cell {
.map(|i| cells.row_data(i).unwrap_or_else(empty_cell)) path: SharedString::from(entries[id].path.clone()),
.collect(); image: Image::default(),
new_cells.resize(GRID_COLS, empty_cell()); loaded: false,
let mut touched = false;
for (col, c) in updates {
if col >= GRID_COLS {
continue;
}
let id = row * GRID_COLS + col;
if c.loaded {
if id >= entries.len() || entries[id].path != c.path.as_str() {
continue;
}
app.painted.lock().unwrap().insert(id);
n += 1;
} else {
if id >= entries.len() || entries[id].path != c.path.as_str() {
continue;
}
app.painted.lock().unwrap().remove(&id);
// Release the dedupe entry so the fill step re-submits this
// cell when it is in the viewport again (re-submit is a
// disk-cache hit).
app.inflight.lock().unwrap().remove(&entries[id].path);
}
new_cells[col] = c;
touched = true;
}
if touched {
m.set_row_data(
row,
Item {
cells: ModelRc::new(VecModel::from(new_cells)),
}, },
); );
app.painted.lock().unwrap().remove(&id);
// Release the dedupe entry so the fill step re-submits this cell
// when it is in the viewport again (re-submit is a disk-cache hit).
app.inflight.lock().unwrap().remove(&entries[id].path);
} }
} painted_now
n
} }
/// Scroll event (main.slint `scrolled` → `scroll-changed(y, height, width)`; /// Map the list's actual geometry to the cell viewport (visible rows ±
/// content-y is negative while scrolled). MUST stay cheap — it fires on every /// MARGIN_ROWS). content-y is negative while scrolled; `width`/`height` are
/// scroll frame: it ONLY moves the viewport target. All work (submit, paint, /// the list's visible size. Shared by the scroll callback (fast path) and
/// evict) happens in the 10 Hz tick, which reconciles the viewport whether /// the tick's poll (ground truth — see start_drain_timer).
/// or not scrolling is happening. fn viewport_for(y: f32, height: f32, width: f32, n: usize) -> Option<Range<usize>> {
fn on_scroll(app: &AppState, y: f32, height: f32, width: f32) { if width <= 0.0 || n == 0 {
if width <= 0.0 { return None;
return;
} }
let cell = (width - 2.0 * GRID_PADDING - 2.0 * GRID_GAP) / GRID_COLS as f32; let cell = (width - 2.0 * GRID_PADDING - 2.0 * GRID_GAP) / GRID_COLS as f32;
let row_h = cell + ROW_SPACING; let row_h = cell + ROW_SPACING;
let n = app.entries.lock().unwrap().len();
if n == 0 {
return;
}
let first = (y.abs() / row_h) as usize; let first = (y.abs() / row_h) as usize;
let visible = (height / row_h) as usize + 1; let visible = (height / row_h) as usize + 1;
let margin = MARGIN_ROWS * GRID_COLS; let margin = MARGIN_ROWS * GRID_COLS;
let vp = (first * GRID_COLS) Some((first * GRID_COLS)
.saturating_sub(margin) .saturating_sub(margin)
..((first + visible + MARGIN_ROWS) * GRID_COLS).min(n); ..((first + visible + MARGIN_ROWS) * GRID_COLS).min(n))
*app.viewport.lock().unwrap() = vp;
} }
/// The reconcile tick, 10x/s: consume finished results (paint the viewport // ---- Jank-hunt instrumentation (2026-10-27; REMOVE after the hunt) ----
/// ones), evict far pixels, and fill the viewport (submit what's missing, // Every PROCESSED scroll callback logs SCR: the offset stream. Uneven dy =
/// center-first). This is the ONLY place jobs are submitted. After the fill // uneven motion quantum; time gaps = the event loop was starved (blocked UI
/// step, the invariant holds: every viewport cell is painted, in flight, or // thread or GPU). Every reconcile tick logs TICK: UI-thread frame cadence
/// failed. Slint 1.18 timers stop when dropped, and Timer is !Send (UI // plus the tick's total cost. A slow in-place apply logs APLY (texture
/// thread only): we leak the one handle for the process lifetime (8 bytes). // upload cost. All logging goes through the non-blocking logger (nblog)
// so logd backpressure can never stall a UI frame.
static LAST_SCR_Y: std::sync::atomic::AtomicI64 =
std::sync::atomic::AtomicI64::new(i64::MIN);
static LAST_TICK_T: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Monotonic ms since process start (wall clock could jump on NTP and be
/// mistaken for jank).
static NOW_T0: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
#[inline]
fn now_ms() -> f64 {
let t0 = NOW_T0.get_or_init(std::time::Instant::now);
t0.elapsed().as_secs_f64() * 1000.0
}
#[cfg(target_os = "android")]
mod nblog {
//! Non-blocking logger. `log` calls on the UI thread must NEVER block:
//! logd-pipe backpressure during log bursts was blocking the UI thread
//! for 10–40 ms, polluting (and causing) exactly the scroll frames we
//! try to measure. Records are formatted at the call site and pushed
//! into a bounded channel; a drain thread does the real
//! `__android_log_write` and DROPS records when saturated.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc;
use log::{Level, Log, Metadata, Record};
extern "C" {
fn __android_log_write(prio: i32, tag: *const u8, msg: *const u8) -> i32;
}
const TAG: &[u8] = b"gallery\0";
pub struct Logger {
tx: mpsc::SyncSender<(i32, String)>,
dropped: AtomicUsize,
}
pub fn init() {
let (tx, rx) = mpsc::sync_channel::<(i32, String)>(4096);
std::thread::Builder::new()
.name("log-drain".into())
.spawn(move || {
for (prio, msg) in rx {
let b = msg.into_bytes();
let end = b.iter().position(|&c| c == 0).unwrap_or(b.len());
let mut safe: Vec<u8> =
b[..end].iter().copied().filter(|&c| c != b'\n').collect();
safe.push(0);
unsafe {
__android_log_write(prio, TAG.as_ptr(), safe.as_ptr());
}
}
})
.ok();
let logger = Logger { tx, dropped: AtomicUsize::new(0) };
let _ = log::set_boxed_logger(Box::new(logger)).map(|()| log::set_max_level(log::LevelFilter::Info));
}
impl Logger {
fn prio(l: Level) -> i32 {
match l {
Level::Error => 6,
Level::Warn => 5,
Level::Info => 4,
_ => 3,
}
}
}
impl Log for Logger {
fn enabled(&self, m: &Metadata) -> bool {
m.level() <= Level::Info
}
fn log(&self, record: &Record) {
if !self.enabled(record.metadata()) {
return;
}
let mut s = String::with_capacity(96);
use std::fmt::Write as _;
let _ = write!(&mut s, "{}", record.args());
if self.tx.try_send((Self::prio(record.level()), s)).is_err() {
self.dropped.fetch_add(1, Ordering::Relaxed);
}
}
fn flush(&self) {}
}
}
/// Scroll event (main.slint `scrolled` → `scroll-changed(y, height, width)`).
/// Fast-path hint for direct drags: it ONLY moves the viewport target, no
/// work. The tick also POLLS the list's real position every tick, which is
/// what keeps this correct during flings (the callback is silent during the
/// momentum animation — see main.slint header).
fn on_scroll(app: &AppState, y: f32, height: f32, width: f32) {
let n = app.entries.lock().unwrap().len();
if let Some(vp) = viewport_for(y, height, width, n) {
*app.viewport.lock().unwrap() = vp;
}
// SCR (jank hunt): the processed offset stream.
let y100 = (y * 100.0) as i64;
let prev = LAST_SCR_Y.swap(y100, Ordering::Relaxed);
let dy = if prev == i64::MIN { 0.0 } else { (y100 - prev) as f64 / 100.0 };
log::info!("SCR {:11.2} y={:8.1} dy={:7.1}", now_ms(), y, dy);
}
/// The reconcile tick, at display rate (~120 Hz; VSync-quantized on
/// Android): consume finished results (paint the viewport ones, ≤3 rows),
/// evict far pixels (≤2 rows), and fill the viewport (submit what's missing,
/// center-first). This is the ONLY place jobs are submitted. Each tick is
/// tiny and idempotent — work is spread across frames, never batched — so
/// content updates land every frame (the old 100 ms/16-row batch showed as
/// a visible content pulse during slow scroll).
///
/// Step 0 POLLS the list's actual geometry (main.slint `list-y` etc.): the
/// `scrolled` callback is silent during momentum scrolling (Slint 1.18
/// Flickable fires `flicked` once at fling start; the deceleration animation
/// updates content-y per frame without re-firing it), so polling is the
/// ground truth that keeps the viewport correct through flings.
///
/// After the fill step, the invariant holds: every viewport cell is painted,
/// in flight, or failed. Slint 1.18 timers stop when dropped, and Timer is
/// !Send (UI thread only): we leak the one handle for the process lifetime
/// (8 bytes).
/// Tick counter for rate-limiting the debug coverage log (debug level: the
/// lines are only visible when the android_logger filter is Debug).
static DBG_TICKS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn start_drain_timer(weak: &slint::Weak<Main>) { fn start_drain_timer(weak: &slint::Weak<Main>) {
let weak = weak.clone(); let weak = weak.clone();
let timer = Timer::default(); let timer = Timer::default();
@ -388,7 +585,123 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
if entries.is_empty() { if entries.is_empty() {
return; return;
} }
let viewport = app.viewport.lock().unwrap().clone(); // Jank hunt (REMOVE after): tick cadence + cost.
let tick_t0 = Instant::now();
// DEBUG-SCROLL TEST HOOK (REMOVE after the hunt): see DbgScroll.
{
let now = Instant::now();
let mut dbg = app.dbg.lock().unwrap();
if dbg.last_check.elapsed() > std::time::Duration::from_millis(150) {
dbg.last_check = now;
let path = "/sdcard/Download/.gallery_cmd";
if let Ok(md) = std::fs::metadata(path) {
let mt = md.modified().ok();
if mt != dbg.cmd_mtime {
dbg.cmd_mtime = mt;
if let Ok(s) = std::fs::read_to_string(path) {
let t = s.trim();
if t.starts_with("scroll") {
let p: Vec<&str> = t.split_whitespace().collect();
if p.len() == 3 {
if let (Ok(v), Ok(d)) =
(p[1].parse::<f32>(), p[2].parse::<u64>())
{
dbg.vel = v;
dbg.active_until =
Some(now + std::time::Duration::from_millis(d));
dbg.last_t = now;
log::info!("DBGCMD scroll v={v} d={d}");
}
}
} else if t == "stop" {
dbg.active_until = None;
log::info!("DBGCMD stop");
}
}
}
}
}
if let Some(end) = dbg.active_until {
if now < end {
let dt = ((now.duration_since(dbg.last_t).as_millis()) as f32)
.clamp(1.0, 25.0);
dbg.last_t = now;
// Clamp to the list's real range: y in [-(n*row_h-h), 0].
let width = w.get_list_width() as f32;
let height = w.get_list_height() as f32;
let cell = (width - 2.0 * GRID_PADDING - 2.0 * GRID_GAP)
/ GRID_COLS as f32;
let row_h = cell + ROW_SPACING;
let y_min = (-(entries.len() as f32 * row_h) + height).min(0.0);
let y = (w.get_list_y() as f32 + dbg.vel * dt).clamp(y_min, 0.0);
let dy = y - dbg.last_y;
dbg.last_y = y;
w.set_list_y(y);
log::info!("SCRDBG {:?} y={:.1} dy={:.1}", now, y, dy);
} else {
dbg.active_until = None;
}
}
}
// 0. POLL: the list's ACTUAL scroll position (momentum-aware).
// The scroll callback (on_scroll) is a fast-path hint for
// drags; this is the ground truth.
let viewport = match viewport_for(
w.get_list_y(),
w.get_list_height(),
w.get_list_width(),
entries.len(),
) {
Some(vp) => {
let prev = app.viewport.lock().unwrap().clone();
// DBG (debug level): viewport trajectory — proves the
// poll tracks momentum flings (callback-only froze).
if vp != prev {
log::debug!(
"DBG vp y={:.0} {}..{} -> {}..{}",
w.get_list_y(),
prev.start,
prev.end,
vp.start,
vp.end
);
}
*app.viewport.lock().unwrap() = vp.clone();
vp
}
None => app.viewport.lock().unwrap().clone(),
};
// DBG: coverage at ~5 Hz (debug level: silent in production).
if DBG_TICKS.fetch_add(1, Ordering::Relaxed) % 15 == 0 {
let painted = app.painted.lock().unwrap();
let inflight = app.inflight.lock().unwrap();
let failed = app.failed.lock().unwrap();
let (mut p, mut i, mut f) = (0usize, 0usize, 0usize);
for id in viewport.clone() {
let e = &entries[id];
if painted.contains(&id) {
p += 1;
} else if inflight.contains_key(&e.path) {
i += 1;
} else if failed.contains(&(e.path.clone(), e.mtime, e.size)) {
f += 1;
}
}
let now = Instant::now();
let oldest = inflight
.values()
.map(|t| now.duration_since(*t))
.max()
.map(|d| d.as_millis())
.unwrap_or(0);
log::debug!(
"DBG tick vp={}..{} p={p} i={i} f={f} oldest={oldest}ms {}",
viewport.start,
viewport.end,
app.engine.log_summary()
);
}
// 1. CONSUME: every finished result leaves `inflight` (its job is // 1. CONSUME: every finished result leaves `inflight` (its job is
// done). Paint viewport results (capped); the disk cache owns // done). Paint viewport results (capped); the disk cache owns
@ -416,23 +729,24 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
} }
continue; continue;
} }
if viewport.contains(&r.id) && paints.len() < MAX_CELLS_PER_TICK { if viewport.contains(&r.id)
&& paints.len() < MAX_PAINT_CELLS_PER_TICK
{
paints.push(r); paints.push(r);
} }
} }
} }
// 2. EVICT: release pixels for painted cells outside the viewport // 2. EVICT: release pixels for painted cells outside the
// ± MARGIN_ROWS, farthest first. Budget = the rows the paints // RETENTION band (viewport ± RETENTION_ROWS — much wider than
// didn't use; if the hard cap is exceeded, evict extra (still // the submit margin: pixels within ~2 screens are kept for
// bounded per tick) so pathological flicking converges. // instant back-scrolls), farthest first. ≤4 cells/tick so
// evictions also fit the frame budget.
let to_clear: Vec<usize> = { let to_clear: Vec<usize> = {
let painted = app.painted.lock().unwrap(); let painted = app.painted.lock().unwrap();
let rows_used = (paints.len() + GRID_COLS - 1) / GRID_COLS; let overflow = painted.len().saturating_sub(MAX_PAINTED_LIVE.load(Ordering::Relaxed));
let budget = (MAX_ROWS_PER_TICK - rows_used.min(MAX_ROWS_PER_TICK)) * GRID_COLS; let lo = viewport.start.saturating_sub(RETENTION_ROWS * GRID_COLS);
let overflow = painted.len().saturating_sub(MAX_PAINTED); let hi = (viewport.end + RETENTION_ROWS * GRID_COLS).min(entries.len());
let lo = viewport.start.saturating_sub(MARGIN_ROWS * GRID_COLS);
let hi = (viewport.end + MARGIN_ROWS * GRID_COLS).min(entries.len());
let center = (viewport.start + viewport.end) / 2; let center = (viewport.start + viewport.end) / 2;
let mut far: Vec<usize> = painted let mut far: Vec<usize> = painted
.iter() .iter()
@ -441,13 +755,19 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
.collect(); .collect();
far.sort_by_key(|id| center.abs_diff(*id)); // nearest first far.sort_by_key(|id| center.abs_diff(*id)); // nearest first
far.reverse(); // farthest first far.reverse(); // farthest first
let n = far.len().min(budget.max(overflow.min(2 * MAX_CELLS_PER_TICK))); let budget = MAX_EVICT_CELLS_PER_TICK;
let n = far.len().min(budget.max(overflow.min(2 * budget)));
far.into_iter().take(n).collect() far.into_iter().take(n).collect()
}; };
// 3. APPLY: each affected outer row replaced exactly once // 3. APPLY: in-place nested cell updates (≤4 paints + ≤4
// (≤ MAX_ROWS_PER_TICK rows). // evictions; each = one cell property change).
let painted_now = apply_row_batch(app, m, &entries, &paints, &to_clear); let apply_t0 = Instant::now(); // jank hunt (REMOVE after)
let painted_now = apply_cell_updates(app, m, &entries, &paints, &to_clear);
let apply_us = apply_t0.elapsed().as_micros(); // jank hunt
if apply_us > 1500 { // jank hunt (REMOVE after)
log::info!("APLY {}us p={} c={}", apply_us, painted_now, to_clear.len());
}
// 4. FILL: every viewport cell without pixels and without an // 4. FILL: every viewport cell without pixels and without an
// in-flight job gets submitted now, center-first so the eye is // in-flight job gets submitted now, center-first so the eye is
@ -565,6 +885,18 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
); );
} }
} }
// Jank hunt (REMOVE after): stall-only tick log. Log when the
// gap since the previous tick exceeds one 60 Hz frame (a dropped
// 120 Hz frame) or the tick itself was slow — full-cadence
// logging at 125 lines/s risks blocking in log_write and
// masquerading as jank.
let tick_us = tick_t0.elapsed().as_micros();
let t_now = now_ms();
let last = f64::from_bits(LAST_TICK_T.swap(t_now.to_bits(), Ordering::Relaxed));
let gap = t_now - last;
if tick_us > 500 || gap > 16.0 {
log::info!("TICK {:11.2} gap={:.1} d={}us p={}", t_now, gap, tick_us, painted_now);
}
}, },
); );
std::mem::forget(timer); std::mem::forget(timer);
@ -653,9 +985,7 @@ pub fn android_app() -> Option<&'static slint::android::AndroidApp> {
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
fn android_main(app: slint::android::AndroidApp) { fn android_main(app: slint::android::AndroidApp) {
android_logger::init_once( nblog::init();
android_logger::Config::default().with_max_level(log::LevelFilter::Info),
);
let _ = ANDROID_APP.set(app.clone()); let _ = ANDROID_APP.set(app.clone());
// `init_with_event_listener` gives us the raw MainEvent stream // `init_with_event_listener` gives us the raw MainEvent stream

View File

@ -1,9 +1,17 @@
// Gallery — M3: 3-column thumbnail grid with shimmer placeholders. // Gallery — M3: 3-column thumbnail grid with shimmer placeholders.
// //
// The ListView virtualizes ROWS; each row holds up to 3 square cells. // Each row holds up to 3 square cells. (Slint's ListView renders every row
// `scrolled` drives Rust-side prefetch (lib.rs::on_scroll), which also owns // as a live element — off-screen rows are culled, not recycled — so cell
// the memory bound: cells scrolled far off-screen are dropped back to // updates mutate the row's nested model IN PLACE; see lib.rs.
// shimmer (their pixels live in the disk cache, repaint is instant). // apply_cell_updates.)
// The Rust side reconciles the viewport at ~120 Hz (lib.rs tick). The tick
// POLLS `list-y`/`list-height`/`list-width` as ground truth: the `scrolled`
// callback alone is NOT reliable — Slint 1.18's Flickable fires `flicked`
// on pointer moves and once at fling start, but the momentum (deceleration)
// animation updates content-y every frame WITHOUT re-firing it, so the tick
// polls the actual position and stays correct through flings. (See
// i-slint-core items/flickable.rs: `animate()` sets a physics animation on
// content-y and calls `flicked` exactly once.)
import { ListView, VerticalBox } from "std-widgets.slint"; import { ListView, VerticalBox } from "std-widgets.slint";
export struct Cell { export struct Cell {
@ -25,15 +33,17 @@ component CellView inherits Rectangle {
border-radius: 4px; border-radius: 4px;
clip: true; clip: true;
// Shimmer: time-driven expression (1.18 style, same pattern as Slint's // Placeholder while the thumbnail is in flight. STATIC on purpose:
// own spinner widgets) — animation-tick() marks the binding as // animation-tick() in a per-cell binding is O(ALL CELLS) every frame
// time-dependent, so it re-evaluates every frame while visible. // (ListView keeps every row alive; bindings are not visibility-gated),
// which burned ~70 % of a core at idle with 8.5k cells and stole CPU
// from the render pipeline during scroll (jank). A proper shimmer
// needs an O(visible) architecture — M6.
Rectangle { Rectangle {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: #242424; background: #242424;
visible: !cell.loaded && cell.path != ""; visible: !cell.loaded && cell.path != "";
opacity: 0.45 + 0.55 * Math.abs(Math.sin(360deg * animation-tick() / 1300ms));
} }
Image { Image {
@ -78,6 +88,14 @@ export component Main inherits Window {
/// delivers results. /// delivers results.
in property <[Item]> items; in property <[Item]> items;
// Rust-readable mirrors of the list's ACTUAL geometry (content-y is
// negative while scrolled down). The reconcile tick polls these every
// tick — including during momentum scrolling, where the `scrolled`
// callback is silent (see file header).
in-out property <length> list-y <=> listview.content-y;
out property <length> list-height <=> listview.visible-height;
in-out property <length> list-width <=> listview.width;
VerticalBox { VerticalBox {
width: 100%; width: 100%;
spacing: 8px; spacing: 8px;
@ -94,10 +112,11 @@ export component Main inherits Window {
overflow: elide; overflow: elide;
} }
ListView { listview := ListView {
width: 100%; width: 100%;
vertical-stretch: 1; vertical-stretch: 1;
// Scroll-driven prefetch + memory bound (see lib.rs::on_scroll). // Fast-path hint for drags (the tick also polls list-y every
// tick, which covers flings where this callback is silent).
scrolled => { scrolled => {
root.scroll-changed(self.content-y, self.visible-height, self.width); root.scroll-changed(self.content-y, self.visible-height, self.width);
} }

View File

@ -46,7 +46,7 @@ const JPEG_QUALITY: u8 = 85;
/// than the viewport (~45 cells), so fresh viewport work ALWAYS survives the /// than the viewport (~45 cells), so fresh viewport work ALWAYS survives the
/// cap; overflow is dropped from the TAIL (oldest transit work). 96 ≈ two /// cap; overflow is dropped from the TAIL (oldest transit work). 96 ≈ two
/// screens per worker of headroom. /// screens per worker of headroom.
const MAX_WORKER_QUEUE: usize = 96; pub const MAX_WORKER_QUEUE: usize = 96;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Job { pub struct Job {