From ee2559c60f38eaeac808cedb916aebaefc33d8ef Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 21 Oct 2026 15:00:31 -0400 Subject: [PATCH] LR-BEHAV-BG-TASKS: unknown totals sweep, denoise bars keep their widths, Enter waits for the segmenter --- crates/ui-egui/src/panels/denoise.rs | 30 ++++++++++++++++++---------- crates/ui-egui/src/panels/dialogs.rs | 3 +-- crates/ui-egui/src/panels/faces.rs | 3 +-- crates/ui-egui/src/panels/masking.rs | 21 ++++++++++++++++--- crates/ui-egui/src/progress.rs | 6 ++++++ crates/ui-egui/src/tests_progress.rs | 23 ++++++++++++++++++++- 6 files changed, 67 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/panels/denoise.rs b/crates/ui-egui/src/panels/denoise.rs index 38ef6a78c..69e31dead 100644 --- a/crates/ui-egui/src/panels/denoise.rs +++ b/crates/ui-egui/src/panels/denoise.rs @@ -141,6 +141,22 @@ pub fn detail_toggle(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, on }); } +/// The bar under the Denoise slider while a photo's picture is made: at most 240 px wide, and +/// sweeping while the number of tiles is not known yet. +pub(crate) fn detail_bar(ui: &mut egui::Ui, done: u64, total: u64) -> egui::Response { + let fraction = crate::progress::fraction_of(done, total); + let text = match fraction { + Some(f) => tr_format!("Making the denoised picture… {}%", (f * 100.0).round() as u32), + None => tr("Starting…").to_string(), + }; + crate::progress::Progress::new(fraction).label(text).width(ui.available_width().min(240.0)).widget("denoise:progress").show(ui) +} + +/// The bar in Settings, AI Denoise: 380 px wide however wide the tab is. +pub(crate) fn settings_bar(ui: &mut egui::Ui, done: u64, total: u64, text: String) -> egui::Response { + crate::progress::Progress::new(crate::progress::fraction_of(done, total)).label(text).width(380.0).widget("denoise:settingsProgress").show(ui) +} + /// The line under the Denoise slider for the photo `id`, whose Amount is `amount`. pub fn detail_status(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, amount: f64) { let t = Tokens::get(ui.ctx()); @@ -198,13 +214,7 @@ pub fn detail_status(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, am } PhotoState::Running { done, total } => { egui::Frame::NONE.inner_margin(pad).show(ui, |ui| { - let frac = if total > 0 { (done as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 }; - let text = if total > 0 { - tr_format!("Making the denoised picture… {}%", (frac * 100.0).round() as u32) - } else { - tr("Starting…").to_string() - }; - crate::progress::Progress::new(if total > 0 { Some(frac) } else { None }).label(text).widget("denoise:progress").show(ui); + detail_bar(ui, done as u64, total as u64); }); } PhotoState::Failed { why, unsupported } => { @@ -329,7 +339,6 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V let (running, queued) = (app.caches.denoise.running, app.caches.denoise.queued); match running { Some((done, total)) => { - let frac = if total > 0 { (done as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 }; let name = status["running"]["photo"] .as_u64() .and_then(|p| app.session.catalog.photo(PhotoId(p)).map(|p| p.file_name.clone())) @@ -341,7 +350,7 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V done = done, total = total ); - crate::progress::Progress::new(Some(frac)).label(text).widget("denoise:settingsProgress").show(ui); + settings_bar(ui, done, total, text); } None if queued > 0 => hint(ui, t, &count(queued, "photo waiting", "photos waiting")), None => { @@ -445,13 +454,12 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, match (dl_state, dl) { (Some("running"), Some(d)) => { let (got, total) = (d["bytes"].as_u64().unwrap_or(0), d["total"].as_u64().unwrap_or(0)); - let frac = if total > 0 { (got as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 }; let text = if total > 0 && got >= total { tr("Checking…").to_string() } else { tr_format!("{} of {} from {host}", mb(Some(got)), mb(Some(total)), host = host) }; - crate::progress::Progress::new(Some(frac)) + crate::progress::Progress::new(crate::progress::fraction_of(got, total)) .label(text) .width(crate::progress::ROW_WIDTH.min(ui.available_width())) .widget(format!("denoise:progress:{id}")) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 3ad982cf5..833523ec7 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -1674,9 +1674,8 @@ fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str let seg = &app.session.segmenter; if d.running { ui.add_space(4.0); - let frac = if d.total > 0 { d.done as f64 / d.total as f64 } else { 0.0 }; let text = format!("{:.2} / {:.2} GB · {}", gb(d.done), gb(d.total), d.file); - crate::progress::Progress::new(Some(frac as f32)).label(text).widget("progress:samDownload").show(ui); + crate::progress::Progress::new(crate::progress::fraction_of(d.done, d.total)).label(text).widget("progress:samDownload").show(ui); let r = ui.button(crate::i18n::tr("Cancel Download")); crate::widgets::register(ui.ctx(), "button:samCancel", r.rect); if r.clicked() { diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/panels/faces.rs index 9bc39a4b0..902c0fd84 100644 --- a/crates/ui-egui/src/panels/faces.rs +++ b/crates/ui-egui/src/panels/faces.rs @@ -361,13 +361,12 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, match (dl_state, dl) { (Some("running"), Some(d)) => { let (got, total) = (d["bytes"].as_u64().unwrap_or(0), d["total"].as_u64().unwrap_or(0)); - let frac = if total > 0 { (got as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 }; let text = if total > 0 && got >= total { "Checking…".to_string() } else { format!("{} of {} from {host}", mb(Some(got)), mb(Some(total))) }; - crate::progress::Progress::new(Some(frac)) + crate::progress::Progress::new(crate::progress::fraction_of(got, total)) .label(text) .width(crate::progress::ROW_WIDTH.min(ui.available_width())) .widget(format!("faces:progress:{id}")) diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 20e15e3a3..cab821913 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -183,8 +183,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let pct = if download.total > 0 { download.done as f64 / download.total as f64 } else { 0.0 }; // the bar takes what the Details button leaves let details = ui.spacing().interact_size.x + 30.0; - crate::progress::Progress::new(Some(pct as f32)) - .label(format!("SAM 3: {} %", (pct * 100.0).floor())) + crate::progress::Progress::new(crate::progress::fraction_of(download.done, download.total)) + // no percentage to say while the size is unknown + .label(if download.total > 0 { format!("SAM 3: {} %", (pct * 100.0).floor()) } else { String::new() }) .width((ui.available_width() - details).max(96.0)) .widget("maskSamProgress") .show(ui); @@ -767,6 +768,11 @@ pub(crate) fn start_describe(app: &mut LightcraftApp, op: &str) { } /// The Describe field: type what to select ("sky", "the red car") and press Return. +/// Enter in the Describe field is the Select button: it starts nothing while the segmenter works. +fn enter_submits(working: bool, lost_focus: bool, enter_pressed: bool) -> bool { + !working && lost_focus && enter_pressed +} + fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) { let Some((op, mut text)) = app.ui.describe.clone() else { return }; // a new mask's field sits under the tiles; one that combines, under the selected mask @@ -796,7 +802,8 @@ fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) { if !r.has_focus() && !r.lost_focus() && text.is_empty() { r.request_focus(); } - if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + // Enter is the Select button: nothing starts while the segmenter is working + if enter_submits(working, r.lost_focus(), ui.input(|i| i.key_pressed(egui::Key::Enter))) { submit = true; } }); @@ -904,6 +911,14 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + #[test] + fn enter_in_the_describe_field_starts_nothing_while_the_segmenter_works() { + assert!(enter_submits(false, true, true)); + assert!(!enter_submits(true, true, true)); + assert!(!enter_submits(false, false, true)); + assert!(!enter_submits(false, true, false)); + } + #[test] fn masking_context_actions_use_catalog_translations() { let saved = crate::i18n::language(); diff --git a/crates/ui-egui/src/progress.rs b/crates/ui-egui/src/progress.rs index ed2964395..f80f81783 100644 --- a/crates/ui-egui/src/progress.rs +++ b/crates/ui-egui/src/progress.rs @@ -39,6 +39,12 @@ pub fn clamp_fraction(fraction: f32) -> f32 { if fraction.is_nan() { 0.0 } else { fraction.clamp(0.0, 1.0) } } +/// `done` of `total` as a fraction, or `None` while the total is not known (0): an unknown total +/// is a bar that sweeps, not one stuck at 0 %. +pub fn fraction_of(done: u64, total: u64) -> Option { + (total > 0).then(|| clamp_fraction(done as f32 / total as f32)) +} + /// A progress bar, built and then shown with [`Progress::show`]. #[derive(Clone, Debug)] pub struct Progress { diff --git a/crates/ui-egui/src/tests_progress.rs b/crates/ui-egui/src/tests_progress.rs index 22eb3a70a..8ceb61109 100644 --- a/crates/ui-egui/src/tests_progress.rs +++ b/crates/ui-egui/src/tests_progress.rs @@ -7,7 +7,7 @@ use egui::{Pos2, Rect, pos2, vec2}; use crate::button::{Button, contrast}; use crate::icons::Icon; -use crate::progress::{Progress, clamp_fraction, spinner}; +use crate::progress::{Progress, clamp_fraction, fraction_of, spinner}; use crate::theme::{ThemeKind, Tokens}; /// What a screen reader gets for one node. @@ -65,6 +65,15 @@ fn the_fraction_is_always_between_nothing_and_all() { assert_eq!(Progress::new(None).fraction(), None); } +#[test] +fn an_unknown_total_is_an_indeterminate_bar_not_an_empty_one() { + assert_eq!(fraction_of(0, 0), None); + assert_eq!(fraction_of(5, 0), None); + assert_eq!(fraction_of(0, 10), Some(0.0)); + assert_eq!(fraction_of(5, 10), Some(0.5)); + assert_eq!(fraction_of(20, 10), Some(1.0)); +} + #[test] fn a_bar_is_progress_with_a_value_and_reachable_by_its_id() { let (nodes, ids, _) = frames(Vec::new(), |ui| { @@ -197,3 +206,15 @@ fn a_failed_filmstrip_thumbnail_is_announced_with_its_reason() { assert_eq!(spoken(false).as_deref(), Some("IMG_0001.jpg")); assert_eq!(spoken(true).as_deref(), Some("IMG_0001.jpg, This photo can't be opened")); } + +#[test] +fn the_denoise_bars_keep_their_widths_in_a_wide_container() { + let (_, ids, (detail, settings, unknown)) = frames(Vec::new(), |ui| { + let detail = crate::panels::denoise::detail_bar(ui, 3, 10).rect.width(); + let settings = crate::panels::denoise::settings_bar(ui, 3, 10, "Denoising a.jpg: 3 of 10 tiles".into()).rect.width(); + let unknown = crate::panels::denoise::detail_bar(ui, 0, 0).rect.width(); + (detail, settings, unknown) + }); + assert_eq!((detail, settings, unknown), (240.0, 380.0, 240.0)); + assert!(ids.iter().any(|(id, _)| id == "denoise:settingsProgress")); +}