Skip to main content

max / audiofiles

Put the footer's status timings on makeover-timing The footer wrote 5s and 30s as literals and the search field wrote 150ms as another. All three are the time axis now. A status holds for Intent::Clear, then leaves over Motion::Fade: it recedes to muted for the length of the departure and is then gone. Both stages survive, mapped onto the two axes that name them, but a message a user glanced away from is gone in 2.3s rather than 30. An error still never expires. status_lifetime says that the way notice_lifetime(false) does, and a test pins the two together so a divergence is caught rather than needing both numbers read side by side. Intent::Clear rather than Intent::Dismiss because this is a status line, not a stacked notice. makeover-timing comes in by git URL with a version requirement: it is the one suite crate not on crates.io yet, so that line becomes a plain version the day it is released.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 02:59 UTC
Signed with PGP, not checked
Commit: 9ae825ca92633c4c611a3454f50fccb7fd92590b
Parent: b6e9077
6 files changed, +86 insertions, -48 deletions
M Cargo.lock +28 -26
@@ -453,6 +453,7 @@
453 453 "makeover-geometry",
454 454 "makeover-immediate",
455 455 "makeover-layout",
456 + "makeover-timing",
456 457 "objc2 0.6.4",
457 458 "objc2-app-kit 0.3.2",
458 459 "objc2-foundation 0.3.2",
@@ -3057,8 +3058,6 @@
3057 3058 [[package]]
3058 3059 name = "makeover"
3059 3060 version = "3.0.0"
3060 - source = "registry+https://github.com/rust-lang/crates.io-index"
3061 - checksum = "3e6da78c376074e48eb2324394004afc847e674e05ed2ea4bec1331073990b3f"
3062 3061 dependencies = [
3063 3062 "include_dir",
3064 3063 "serde",
@@ -3068,14 +3067,10 @@
3068 3067 [[package]]
3069 3068 name = "makeover-geometry"
3070 3069 version = "0.7.0"
3071 - source = "registry+https://github.com/rust-lang/crates.io-index"
3072 - checksum = "91a82b16c4f5b66dd7e87ed92fff7d22185e4d88e5355de6166dd64450cf6a16"
3073 3070
3074 3071 [[package]]
3075 3072 name = "makeover-immediate"
3076 - version = "0.32.0"
3077 - source = "registry+https://github.com/rust-lang/crates.io-index"
3078 - checksum = "437b1e68f0d1ce824f458e623fc901e81f961b97f167c58c9400453f976fc175"
3073 + version = "0.33.0"
3079 3074 dependencies = [
3080 3075 "egui",
3081 3076 "egui_extras",
@@ -3084,9 +3079,14 @@
3084 3079
3085 3080 [[package]]
3086 3081 name = "makeover-layout"
3087 - version = "0.33.0"
3088 - source = "registry+https://github.com/rust-lang/crates.io-index"
3089 - checksum = "7ba9bae28e348ef6df04ddf04222703b5ea680ac2c978f0dce8679e2acbdc5a0"
3082 + version = "0.35.0"
3083 +
3084 + [[package]]
3085 + name = "makeover-timing"
3086 + version = "0.1.0"
3087 + dependencies = [
3088 + "makeover-geometry",
3089 + ]
3090 3090
3091 3091 [[package]]
3092 3092 name = "maplit"
@@ -4245,8 +4245,7 @@
4245 4245
4246 4246 [[package]]
4247 4247 name = "quasi-immediate"
4248 - version = "0.48.0"
4249 - source = "git+https://makenot.work/git/max/quasi.git#b23c0c0f34323a65a611dc5fda74533b0ec5c3c9"
4248 + version = "0.49.0"
4250 4249 dependencies = [
4251 4250 "docengine",
4252 4251 "egui",
@@ -4256,8 +4255,7 @@
4256 4255
4257 4256 [[package]]
4258 4257 name = "quasi-router"
4259 - version = "0.48.0"
4260 - source = "git+https://makenot.work/git/max/quasi.git#b23c0c0f34323a65a611dc5fda74533b0ec5c3c9"
4258 + version = "0.49.0"
4261 4259 dependencies = [
4262 4260 "makeover-layout",
4263 4261 ]
@@ -7546,10 +7544,6 @@
7546 7544 "winnow 1.0.4",
7547 7545 ]
7548 7546
7549 - [[patch.unused]]
7550 - name = "quasi-type"
7551 - version = "0.1.0"
7552 -
7553 7547 [[patch.unused]]
7554 7548 name = "kberg"
7555 7549 version = "0.1.0"
@@ -7562,6 +7556,22 @@
7562 7556 name = "painhours"
7563 7557 version = "0.1.0"
7564 7558
7559 + [[patch.unused]]
7560 + name = "makeover-touch"
7561 + version = "0.24.0"
7562 +
7563 + [[patch.unused]]
7564 + name = "makeover-tui"
7565 + version = "0.33.0"
7566 +
7567 + [[patch.unused]]
7568 + name = "makeover-webview"
7569 + version = "0.56.0"
7570 +
7571 + [[patch.unused]]
7572 + name = "quasi-type"
7573 + version = "0.1.0"
7574 +
7565 7575 [[patch.unused]]
7566 7576 name = "quasi-axum"
7567 7577 version = "0.49.0"
@@ -7574,18 +7584,10 @@
7574 7584 name = "quasi-http"
7575 7585 version = "0.49.0"
7576 7586
7577 - [[patch.unused]]
7578 - name = "quasi-immediate"
7579 - version = "0.49.0"
7580 -
7581 7587 [[patch.unused]]
7582 7588 name = "quasi-notifs"
7583 7589 version = "0.49.0"
7584 7590
7585 - [[patch.unused]]
7586 - name = "quasi-router"
7587 - version = "0.49.0"
7588 -
7589 7591 [[patch.unused]]
7590 7592 name = "quasi-store"
7591 7593 version = "0.1.0"
M Cargo.toml +5 -1
@@ -17,8 +17,12 @@
17 17 # to compile against an API added in a later one. The two move together --
18 18 # makeover-immediate re-exports nothing, so the `Column` the app describes and
19 19 # the `Column` the renderer matches on have to be the same type.
20 - makeover-layout = "0.34.0"
20 + makeover-layout = "0.35.0"
21 21 makeover-immediate = "0.33.0"
22 + # The time axis. By git URL with a version requirement, per the tree's rule for
23 + # cross-repo deps: makeover-timing is the one suite crate not yet on crates.io,
24 + # so this line becomes a plain version the day it is released.
25 + makeover-timing = { git = "https://makenot.work/git/max/makeover-timing.git", version = "0.1" }
22 26 # The described screens, behind audiofiles-browser's `quasi` feature. By git URL
23 27 # with a version requirement, per the tree's rule for cross-repo deps.
24 28 quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.49" }
@@ -21,6 +21,7 @@
21 21 egui = { workspace = true }
22 22 makeover-layout = { workspace = true }
23 23 makeover-immediate = { workspace = true }
24 + makeover-timing = { workspace = true }
24 25 egui_extras = { workspace = true }
25 26 symphonia = { workspace = true }
26 27 hound = { workspace = true }
@@ -7661,7 +7661,7 @@
7661 7661 });
7662 7662 let response = forged(&forge, Request::get("/forge")).unwrap();
7663 7663
7664 - let threshold = deep_fields(&screen_of(&response))
7664 + let threshold = deep_fields(screen_of(&response))
7665 7665 .into_iter()
7666 7666 .find(|field| field.name == "threshold")
7667 7667 .expect("the threshold is described");
@@ -3,14 +3,25 @@
3 3 use std::time::{Duration, Instant};
4 4
5 5 use egui;
6 + use makeover_timing::{Intent, Motion};
6 7
7 8 use super::theme;
8 9 use crate::state::BrowserState;
9 10
10 - /// Status message fade threshold, after this, status renders in `content_muted`.
11 - const STATUS_FADE_AFTER: Duration = Duration::from_secs(5);
12 - /// Status message hide threshold, after this, the status disappears entirely.
13 - const STATUS_HIDE_AFTER: Duration = Duration::from_secs(30);
11 + /// How long the footer holds a status, given whether it reports a failure.
12 + ///
13 + /// The same statement [`makeover_timing::notice_lifetime`] makes about a
14 + /// notice, against the intent that names a status line rather than a toast:
15 + /// a message the user must not miss does not go away on its own, so an error
16 + /// gets no lifetime at all and stays until something replaces it.
17 + ///
18 + /// `Intent::Clear` rather than `Intent::Dismiss` because this is a status
19 + /// line, not a stacked notice. The footer is always on screen; the message in
20 + /// it is a receipt for what the user just did, so a glance that arrives late
21 + /// should find the line empty rather than reporting stale news.
22 + fn status_lifetime(is_error: bool) -> Option<Duration> {
23 + (!is_error).then(|| Intent::Clear.duration())
24 + }
14 25
15 26 /// Heuristic: does this status line report a failure? Error statuses are kept
16 27 /// visible (never auto-hidden) and rendered in `danger`, since a silently
@@ -163,7 +174,9 @@
163 174 draw_analysis_coverage(ui, state);
164 175 }
165 176
166 - // Status message, m-6: fade to muted after 5s, hide after 30s.
177 + // Status message: hold for `Intent::Clear`, then leave over
178 + // `Motion::Fade` — the message recedes to muted for the length of the
179 + // departure and is then gone. An error has no lifetime and stays.
167 180 // Stamp `status_set_at` lazily for any caller that wrote `state.status`
168 181 // directly (legacy path); `post_status` callers stamp at write time.
169 182 // Detect changes since last frame via egui memory so the timer resets
@@ -181,28 +194,30 @@
181 194 }
182 195
183 196 let elapsed = state.status_set_at.map(|t| t.elapsed()).unwrap_or_default();
184 - // Errors stay up until replaced; informational/success messages
185 - // fade after 5s and hide after 30s.
186 197 let is_error = is_error_status(&state.status);
187 - if is_error || elapsed < STATUS_HIDE_AFTER {
198 + let hold = status_lifetime(is_error);
199 + let gone_at = hold.map(|d| d + Motion::Fade.duration());
200 + let leaving = hold.is_some_and(|d| elapsed >= d);
201 + let gone = gone_at.is_some_and(|d| elapsed >= d);
202 + if !gone {
188 203 dot(ui);
189 204 let color = if is_error {
190 205 theme::danger()
191 - } else if elapsed >= STATUS_FADE_AFTER {
206 + } else if leaving {
192 207 theme::content_muted()
193 208 } else {
194 209 theme::content_secondary()
195 210 };
196 211 ui.label(egui::RichText::new(&state.status).color(color));
197 212
198 - // Request a repaint at the next fade/hide transition so they
199 - // land on time even when the UI is idle. Errors don't expire,
200 - // so they need no scheduled repaint.
201 - if !is_error {
202 - let next_threshold = if elapsed < STATUS_FADE_AFTER {
203 - STATUS_FADE_AFTER.saturating_sub(elapsed)
213 + // Request a repaint at the next transition so the departure
214 + // lands on time even when the UI is idle. A message with no
215 + // lifetime never transitions, so it needs no scheduled repaint.
216 + if let Some(hold) = hold {
217 + let next_threshold = if leaving {
218 + gone_at.unwrap_or(hold).saturating_sub(elapsed)
204 219 } else {
205 - STATUS_HIDE_AFTER.saturating_sub(elapsed)
220 + hold.saturating_sub(elapsed)
206 221 };
207 222 ui.ctx().request_repaint_after(next_threshold);
208 223 }
@@ -351,6 +366,21 @@
351 366 mod tests {
352 367 use super::*;
353 368
369 + #[test]
370 + fn an_error_status_has_no_lifetime_and_an_ordinary_one_clears() {
371 + // A message the user must not miss does not go away on its own. This
372 + // is `notice_lifetime(false)`'s statement, against the intent that
373 + // names a status line.
374 + assert_eq!(status_lifetime(true), None);
375 + assert_eq!(status_lifetime(false), Some(Intent::Clear.duration()));
376 + // And it agrees with the crate, so a divergence is one edit away from
377 + // being caught rather than needing both numbers read side by side.
378 + assert_eq!(
379 + status_lifetime(true),
380 + makeover_timing::notice_lifetime(false)
381 + );
382 + }
383 +
354 384 #[test]
355 385 fn error_statuses_are_detected_case_insensitively() {
356 386 assert!(is_error_status("Failed to import sample"));
@@ -54,14 +54,15 @@
54 54 // Debounce typing: each keystroke would otherwise run a blocking DB
55 55 // query + re-sort on the GUI thread. Re-arm a short timer on change and
56 56 // request a repaint at its deadline so the search fires once typing
57 - // pauses, even without further input.
58 - const SEARCH_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(150);
57 + // pauses, even without further input. How long "settled" is belongs to
58 + // the time axis, not to this field.
59 + let search_debounce = makeover_timing::Intent::Debounce.duration();
59 60 if resp.changed() {
60 61 state.search.search_debounce_at = Some(std::time::Instant::now());
61 - ui.ctx().request_repaint_after(SEARCH_DEBOUNCE);
62 + ui.ctx().request_repaint_after(search_debounce);
62 63 }
63 64 if let Some(t) = state.search.search_debounce_at
64 - && t.elapsed() >= SEARCH_DEBOUNCE
65 + && t.elapsed() >= search_debounce
65 66 {
66 67 state.search.search_debounce_at = None;
67 68 state.apply_search();