max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+86 insertions,
-48 deletions
| @@ -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" |
| @@ -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(); |