From 7b4236bc8b5e00ed081e15bfea9241c299e28723 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 20:40:33 +0000 Subject: [PATCH] refactor(ui-egui): pass related arguments as small structs --- .../src/dialogs/settings/appearance.rs | 31 ++++--- .../src/dialogs/settings/slide_overlays.rs | 67 ++++++++++---- crates/ui-egui/src/panels.rs | 25 +++-- crates/ui-egui/src/panels/detail.rs | 22 +++-- crates/ui-egui/src/panels/detail/masks.rs | 18 +++- crates/ui-egui/src/panels/left.rs | 17 +++- crates/ui-egui/src/panels/left/albums.rs | 12 ++- crates/ui-egui/src/panels/left/dates.rs | 4 +- crates/ui-egui/src/panels/left/folders.rs | 8 +- crates/ui-egui/src/panels/left/keywords.rs | 4 +- crates/ui-egui/src/panels/left/local.rs | 10 +- crates/ui-egui/src/panels/left/rows.rs | 92 +++++++++++-------- crates/ui-egui/src/panels/person.rs | 24 ++++- crates/ui-egui/src/tests/lifecycle_037.rs | 17 +++- 14 files changed, 233 insertions(+), 118 deletions(-) diff --git a/crates/ui-egui/src/dialogs/settings/appearance.rs b/crates/ui-egui/src/dialogs/settings/appearance.rs index 571db16fc..f714a97e6 100644 --- a/crates/ui-egui/src/dialogs/settings/appearance.rs +++ b/crates/ui-egui/src/dialogs/settings/appearance.rs @@ -17,9 +17,15 @@ pub(super) fn appearance(ui: &mut egui::Ui, t: &Tokens, s: &mut crate::state::Ap let mut cards = |ui: &mut egui::Ui, width: f32| { ui.spacing_mut().item_spacing.x = 12.0; let light: Vec<_> = LightTheme::ALL.iter().map(|l| (*l, l.kind())).collect(); - theme_card(ui, t, "Light Theme", light_active, &light, &mut s.light_theme, &mut s.favorite_themes, width); + theme_card( + ui, + t, + ThemeCard { title: "Light Theme", active: light_active, options: &light, width }, + &mut s.light_theme, + &mut s.favorite_themes, + ); let dark: Vec<_> = DarkTheme::ALL.iter().map(|d| (*d, d.kind())).collect(); - theme_card(ui, t, "Dark Theme", !light_active, &dark, &mut s.dark_theme, &mut s.favorite_themes, width); + theme_card(ui, t, ThemeCard { title: "Dark Theme", active: !light_active, options: &dark, width }, &mut s.dark_theme, &mut s.favorite_themes); }; if available < 440.0 { ui.vertical(|ui| cards(ui, available)); @@ -29,18 +35,19 @@ pub(super) fn appearance(ui: &mut egui::Ui, t: &Tokens, s: &mut crate::state::Ap ui.add_space(2.0); } +/// What a theme family's card shows. +struct ThemeCard<'a, V> { + title: &'a str, + /// the family in use + active: bool, + options: &'a [(V, crate::theme::ThemeKind)], + width: f32, +} + /// One family's card: its title (and "Active" when it is the one shown), a preview of the /// selected theme and a radio button per theme. -fn theme_card( - ui: &mut egui::Ui, - t: &Tokens, - title: &str, - active: bool, - options: &[(V, crate::theme::ThemeKind)], - value: &mut V, - favorites: &mut Vec, - width: f32, -) { +fn theme_card(ui: &mut egui::Ui, t: &Tokens, card: ThemeCard, value: &mut V, favorites: &mut Vec) { + let ThemeCard { title, active, options, width } = card; egui::Frame::new() .fill(t.inset) .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border })) diff --git a/crates/ui-egui/src/dialogs/settings/slide_overlays.rs b/crates/ui-egui/src/dialogs/settings/slide_overlays.rs index 3d7412b58..fe72ed6a6 100644 --- a/crates/ui-egui/src/dialogs/settings/slide_overlays.rs +++ b/crates/ui-egui/src/dialogs/settings/slide_overlays.rs @@ -30,14 +30,21 @@ fn set(app: &mut LightcraftApp, p: Value) { let _ = app.run("slideshow.overlays", p); } -/// A slider (`slider:{id}`) over `lo..=hi`; `wrap(value)` makes the `slideshow.overlays` request. -fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: f64, lo: f64, hi: f64, unit: Unit, wrap: &dyn Fn(f64) -> Value) { - let (k, suffix) = match unit { +/// The span of a slider and how its value is shown. +struct Range { + lo: f64, + hi: f64, + unit: Unit, +} + +/// A slider (`slider:{id}`) over `range`; `wrap(value)` makes the `slideshow.overlays` request. +fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: f64, range: Range, wrap: &dyn Fn(f64) -> Value) { + let (k, suffix) = match range.unit { Unit::Percent => (100.0, " %"), Unit::Degrees => (1.0, "°"), }; let mut v = value * k; - let r = ui.add(egui::Slider::new(&mut v, lo * k..=hi * k).suffix(suffix).max_decimals(1)); + let r = ui.add(egui::Slider::new(&mut v, range.lo * k..=range.hi * k).suffix(suffix).max_decimals(1)); register(ui, format!("slider:{id}"), r.rect); let p = wrap(v / k); if r.changed() @@ -125,10 +132,14 @@ fn placement(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, id: &str, p }); } row(ui, t, "Offset X", |ui| { - slider(app, ui, &format!("{id}OffsetX"), p.offset_x, -OFFSET_MAX / 2.0, OFFSET_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"offsetX": v}))) + slider(app, ui, &format!("{id}OffsetX"), p.offset_x, Range { lo: -OFFSET_MAX / 2.0, hi: OFFSET_MAX / 2.0, unit: Unit::Percent }, &|v| { + wrap(json!({"offsetX": v})) + }) }); row(ui, t, "Offset Y", |ui| { - slider(app, ui, &format!("{id}OffsetY"), p.offset_y, -OFFSET_MAX / 2.0, OFFSET_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"offsetY": v}))) + slider(app, ui, &format!("{id}OffsetY"), p.offset_y, Range { lo: -OFFSET_MAX / 2.0, hi: OFFSET_MAX / 2.0, unit: Unit::Percent }, &|v| { + wrap(json!({"offsetY": v})) + }) }); } @@ -171,8 +182,12 @@ pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { row(ui, t, "Text", |ui| text(app, ui, "slideshowPlateText", &p.text, "Your name or studio", &|v| plate(json!({"text": v})))); row(ui, t, "Colour", |ui| colour(app, ui, "slideshowPlateColor", p.color, &|c| plate(json!({"color": c})))); } - row(ui, t, "Size", |ui| slider(app, ui, "slideshowPlateSize", p.size, 0.01, PLATE_SIZE_MAX, Unit::Percent, &|v| plate(json!({"size": v})))); - row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowPlateOpacity", p.opacity, 0.0, 1.0, Unit::Percent, &|v| plate(json!({"opacity": v})))); + row(ui, t, "Size", |ui| { + slider(app, ui, "slideshowPlateSize", p.size, Range { lo: 0.01, hi: PLATE_SIZE_MAX, unit: Unit::Percent }, &|v| plate(json!({"size": v}))) + }); + row(ui, t, "Opacity", |ui| { + slider(app, ui, "slideshowPlateOpacity", p.opacity, Range { lo: 0.0, hi: 1.0, unit: Unit::Percent }, &|v| plate(json!({"opacity": v}))) + }); placement(app, ui, t, "slideshowPlate", &p.place, true, &plate); toggle(app, ui, "slideshowPlateBehind", p.behind, "Render behind image", &|v| plate(json!({"behind": v}))); } @@ -183,7 +198,9 @@ pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let stars = |v: Value| json!({ "ratingStars": v }); if toggle(app, ui, "slideshowStars", r.enabled, "Rating stars", &|v| stars(json!({"enabled": v}))) { row(ui, t, "Colour", |ui| colour(app, ui, "slideshowStarsColor", r.color, &|c| stars(json!({"color": c})))); - row(ui, t, "Size", |ui| slider(app, ui, "slideshowStarsSize", r.size, 0.01, STAR_SIZE_MAX, Unit::Percent, &|v| stars(json!({"size": v})))); + row(ui, t, "Size", |ui| { + slider(app, ui, "slideshowStarsSize", r.size, Range { lo: 0.01, hi: STAR_SIZE_MAX, unit: Unit::Percent }, &|v| stars(json!({"size": v}))) + }); placement(app, ui, t, "slideshowStars", &r.place, true, &stars); } @@ -205,24 +222,36 @@ pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { return; // removed: the indices moved; drawn again next frame } row(ui, t, "Size", |ui| { - slider(app, ui, &format!("{id}Size"), o.size, 0.01, TEXT_SIZE_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"size": v}))) + slider(app, ui, &format!("{id}Size"), o.size, Range { lo: 0.01, hi: TEXT_SIZE_MAX / 2.0, unit: Unit::Percent }, &|v| { + wrap(json!({"size": v})) + }) }); row(ui, t, "Colour", |ui| colour(app, ui, &format!("{id}Color"), o.color, &|c| wrap(json!({"color": c})))); - row(ui, t, "Opacity", |ui| slider(app, ui, &format!("{id}Opacity"), o.opacity, 0.0, 1.0, Unit::Percent, &|v| wrap(json!({"opacity": v})))); + row(ui, t, "Opacity", |ui| { + slider(app, ui, &format!("{id}Opacity"), o.opacity, Range { lo: 0.0, hi: 1.0, unit: Unit::Percent }, &|v| wrap(json!({"opacity": v}))) + }); placement(app, ui, t, &id, &o.place, true, &wrap); let sh = o.shadow; if toggle(app, ui, &format!("{id}Shadow"), sh.on, "Shadow", &|v| wrap(json!({"shadow": v}))) { row(ui, t, "Opacity", |ui| { - slider(app, ui, &format!("{id}ShadowOpacity"), sh.opacity, 0.0, 1.0, Unit::Percent, &|v| wrap(json!({"shadowOpacity": v}))) + slider(app, ui, &format!("{id}ShadowOpacity"), sh.opacity, Range { lo: 0.0, hi: 1.0, unit: Unit::Percent }, &|v| { + wrap(json!({"shadowOpacity": v})) + }) }); row(ui, t, "Offset", |ui| { - slider(app, ui, &format!("{id}ShadowOffset"), sh.offset, 0.0, SHADOW_OFFSET_MAX, Unit::Percent, &|v| wrap(json!({"shadowOffset": v}))) + slider(app, ui, &format!("{id}ShadowOffset"), sh.offset, Range { lo: 0.0, hi: SHADOW_OFFSET_MAX, unit: Unit::Percent }, &|v| { + wrap(json!({"shadowOffset": v})) + }) }); row(ui, t, "Radius", |ui| { - slider(app, ui, &format!("{id}ShadowRadius"), sh.radius, 0.0, SHADOW_RADIUS_MAX, Unit::Percent, &|v| wrap(json!({"shadowRadius": v}))) + slider(app, ui, &format!("{id}ShadowRadius"), sh.radius, Range { lo: 0.0, hi: SHADOW_RADIUS_MAX, unit: Unit::Percent }, &|v| { + wrap(json!({"shadowRadius": v})) + }) }); row(ui, t, "Angle", |ui| { - slider(app, ui, &format!("{id}ShadowAngle"), sh.angle, -180.0, 180.0, Unit::Degrees, &|v| wrap(json!({"shadowAngle": v}))) + slider(app, ui, &format!("{id}ShadowAngle"), sh.angle, Range { lo: -180.0, hi: 180.0, unit: Unit::Degrees }, &|v| { + wrap(json!({"shadowAngle": v})) + }) }); } } @@ -241,9 +270,13 @@ pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let wm = |v: Value| json!({ "watermark": v }); if toggle(app, ui, "slideshowWatermark", s.show_watermark, "Watermark", &|v| json!({"showWatermark": v})) { row(ui, t, "Text", |ui| text(app, ui, "slideshowWatermarkText", &w.text, "© Your Name", &|v| wm(json!({"text": v})))); - row(ui, t, "Size", |ui| slider(app, ui, "slideshowWatermarkSize", w.size as f64, 0.01, 0.15, Unit::Percent, &|v| wm(json!({"size": v})))); + row(ui, t, "Size", |ui| { + slider(app, ui, "slideshowWatermarkSize", w.size as f64, Range { lo: 0.01, hi: 0.15, unit: Unit::Percent }, &|v| wm(json!({"size": v}))) + }); row(ui, t, "Opacity", |ui| { - slider(app, ui, "slideshowWatermarkOpacity", w.opacity as f64, 0.05, 1.0, Unit::Percent, &|v| wm(json!({"opacity": v}))) + slider(app, ui, "slideshowWatermarkOpacity", w.opacity as f64, Range { lo: 0.05, hi: 1.0, unit: Unit::Percent }, &|v| { + wm(json!({"opacity": v})) + }) }); row(ui, t, "Position", |ui| { let mut a = w.anchor; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 4c2bed935..ed9c45fb5 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -46,21 +46,26 @@ pub use crate::dialogs::settings::slide_templates; pub use crate::dialogs::settings::slide_titles; use crate::theme::Tokens; +/// A side panel the user resizes: which side, how it is drawn and how wide it may be. +pub struct SidePanel<'a> { + /// the left side (else the right) + pub left: bool, + pub id: &'a str, + pub frame: egui::Frame, + /// the width the user chose + pub width: f32, + pub limits: crate::state::PanelWidth, + /// room to leave for what is beside the photo + pub reserve: f32, +} + /// Show a side panel the user resizes by dragging its inner edge. `width` (in [`crate::UiState`], /// so it survives navigation and restarts) is the source of truth: egui's own remembered size is /// dropped every frame, and a drag of the edge returns the new width to store. The panel never /// takes more than `available − reserve − MIN_PHOTO_WIDTH` (its minimum permitting), so the photo /// stays usable; a narrower window shrinks it without forgetting the chosen width. -pub fn resizable_side( - ui: &mut egui::Ui, - left: bool, - id: &str, - frame: egui::Frame, - width: f32, - limits: crate::state::PanelWidth, - reserve: f32, - add: impl FnOnce(&mut egui::Ui), -) -> Option { +pub fn resizable_side(ui: &mut egui::Ui, side: SidePanel, add: impl FnOnce(&mut egui::Ui)) -> Option { + let SidePanel { left, id, frame, width, limits, reserve } = side; // `Panel::new` stores `IdSalt::new(id)`; the panel's persisted key is // `parent_ui.scope_id().with_salt(that_salt)`, which is exactly `scope_id().with(id)`. // (Passing an already-built `Id` through `Panel::left` would salt it a second time.) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index eee9d7d0c..3eb94779c 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -46,7 +46,7 @@ use info::{ preview_only_pill, quick_name, }; pub(crate) use info::{draw_focus_point, failed_pill, loupe_pill, rendering_overdue}; -use masks::{RegionEdit, color_range_pick, mask_overlay, region_overlay}; +use masks::{RegionContext, RegionEdit, color_range_pick, mask_overlay, region_overlay}; use retouch::{eye_overlay, remove_overlay}; pub(crate) use window::overlay_supports_window; use window::{WindowCtx, WindowView, draw_window, request_window}; @@ -541,7 +541,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let people = if app.ui.name_edit.is_some() { app.caches.person_names(&app.session.catalog) } else { Default::default() }; // while the name box is open and recognition is not set up, the box offers the next step let setup = if app.ui.name_edit.is_some() { super::faces::setup(app, ui.ctx()) } else { super::faces::Setup::Running }; - match region_overlay(ui, &p, &map, &photo, d.orientation, hints.as_deref(), &people, &mut app.ui.name_edit, setup) { + let regions = RegionContext { photo: &photo, orient: d.orientation, hints: hints.as_deref(), people: &people, setup }; + match region_overlay(ui, &p, &map, ®ions, &mut app.ui.name_edit) { Some(RegionEdit::SetUp) => { app.ui.name_edit = None; super::faces::take_step(app, ui.ctx(), setup); @@ -596,7 +597,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { (true, Some(before), Some(q)) if before.contains(q) => (CanvasMap::new(&frame, before_img), before_img, *before), _ => (map, img_rect, main_area), }; - general_interaction(app, ui, &resp, &map, img, pane, native, aspect) + general_interaction(app, ui, &resp, &map, &PhotoPlacement { img, canvas: pane, native, aspect }) } } } @@ -761,16 +762,19 @@ fn general_cursor(app: &LightcraftApp, ui: &egui::Ui, resp: &egui::Response, img ui.ctx().set_cursor_icon(cursor); } -fn general_interaction( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - resp: &egui::Response, - map: &CanvasMap, +/// Where the photo is on the canvas. +struct PhotoPlacement { + /// the photo's rectangle img: Rect, + /// the canvas it is shown on canvas: Rect, + /// the photo's size in pixels native: [usize; 2], aspect: f32, -) { +} + +fn general_interaction(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, placement: &PhotoPlacement) { + let PhotoPlacement { img, canvas, native, aspect } = *placement; if app.ui.tool == "wbPicker" { if resp.clicked() && let Some(q) = resp.interact_pointer_pos() diff --git a/crates/ui-egui/src/panels/detail/masks.rs b/crates/ui-egui/src/panels/detail/masks.rs index 50424f6b3..c150eac53 100644 --- a/crates/ui-egui/src/panels/detail/masks.rs +++ b/crates/ui-egui/src/panels/detail/masks.rs @@ -33,17 +33,25 @@ const REGION_HANDLES: [(f32, f32, egui::CursorIcon); 8] = [ /// on release (one undo step); the × reports the region to remove. Both are catalog-only edits: /// LightCraft doesn't write regions to XMP. Regions are stored on the upright (EXIF-oriented) photo; /// `orient` is the user's Rotate / Flip on top of it, which the loupe's normalized frame includes. +/// What the face/pet/focus regions of a photo are drawn from. +pub(super) struct RegionContext<'a> { + pub photo: &'a lightcraft_catalog::Photo, + /// the user's Rotate / Flip on top of the upright photo + pub orient: lightcraft_geom::Orientation, + pub hints: Option<&'a crate::panels::faces::Hints>, + /// the names to offer when a region is named + pub people: &'a [String], + pub setup: crate::panels::faces::Setup, +} + pub(super) fn region_overlay( ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, - photo: &lightcraft_catalog::Photo, - orient: lightcraft_geom::Orientation, - hints: Option<&crate::panels::faces::Hints>, - people: &[String], + regions: &RegionContext, editing: &mut Option, - setup: crate::panels::faces::Setup, ) -> Option { + let RegionContext { photo, orient, hints, people, setup } = *regions; let t = Tokens::get(p.ctx()); let clip = p.clip_rect(); let pointer = ui.input(|i| i.pointer.hover_pos()); diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 522afe084..27b86aa0d 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -30,7 +30,7 @@ pub(crate) use local::{fs_cached, fs_cached_running, same_folder}; use local::{local_now, local_rows}; #[cfg(test)] pub(crate) use rows::{INDENT_STEP, MIN_INDENT_STEP}; -use rows::{LIBFOLDER_REVEALED, SCROLL_TO_ROW, row, set_tree_open}; +use rows::{LIBFOLDER_REVEALED, Row, SCROLL_TO_ROW, set_tree_open}; pub(crate) use rows::{content_width, indent_step, step_for}; pub(crate) use sections::PlacedSection; pub use sections::section_drag_feedback; @@ -90,18 +90,27 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ("recentlyAdded", Icon::Clock, "Recently Added", None, LibrarySource::RecentlyAdded), ("picks", Icon::FlagPick, "Picks", Some(picks), LibrarySource::Picks), ] { - if row(app, ui, id, icon, label, count, src == s, 0.0).clicked() { + if Row::new(id, icon, label).count(count).selected(src == s).show(app, ui).clicked() { let _ = app.run("library.source", json!({"kind": id})); } } // photos whose files can't be found (checked every few seconds, not every frame) let missing = missing_count(app, ui); if (missing > 0 || src == LibrarySource::Missing) - && row(app, ui, "missing", Icon::Folder, "Missing Photos", Some(missing), src == LibrarySource::Missing, 0.0).clicked() + && Row::new("missing", Icon::Folder, "Missing Photos") + .count(Some(missing)) + .selected(src == LibrarySource::Missing) + .show(app, ui) + .clicked() { let _ = app.run("library.source", json!({"kind": "missing"})); } - if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0).clicked() { + if Row::new("recentlyDeleted", Icon::Trash, "Recently Deleted") + .count(Some(deleted)) + .selected(src == LibrarySource::RecentlyDeleted) + .show(app, ui) + .clicked() + { let _ = app.run("library.source", json!({"kind": "recentlyDeleted"})); } // the album just made: the folders down to it open, once (also when the section is shut) diff --git a/crates/ui-egui/src/panels/left/albums.rs b/crates/ui-egui/src/panels/left/albums.rs index bfcc6a219..5237f69b8 100644 --- a/crates/ui-egui/src/panels/left/albums.rs +++ b/crates/ui-egui/src/panels/left/albums.rs @@ -1,5 +1,5 @@ use super::folders::{folder_menu, is_within}; -use super::rows::{INDENT_STEP, indent_x, row_sensed, set_tree_open, tree_open, tree_toggle}; +use super::rows::{INDENT_STEP, Row, indent_x, set_tree_open, tree_open, tree_toggle}; use super::{Album, AlbumId, Align2, Icon, LibrarySource, LightcraftApp, Rect, Sense, Tokens, json, pos2, register, vec2}; /// Albums: the tree of albums and the folders they are sorted into. `reveal`: an album just made, @@ -34,8 +34,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare open = true; set_tree_open(ui, open_id, true); } - let resp = - row_sensed(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, None, false, indent, Sense::click_and_drag(), None); + let resp = Row::new(&format!("folder:{}", a.id.0), Icon::Folder, &a.name).indent(indent).sense(Sense::click_and_drag()).show(app, ui); // a folder is no source, so its row folds it too; the triangle is the same click, aimed let has_children = all.get(&Some(a.id)).is_some_and(|v| !v.is_empty()); if resp.drag_started() { @@ -74,7 +73,12 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare // a smart album whose rules no longer check (an album they test was deleted) says so let problems = app.caches.smart_album_problems(&app.session.catalog).get(&a.id).cloned().unwrap_or_default(); let label = if problems.is_empty() { label } else { format!("⚠ {label}") }; - let mut resp = row_sensed(app, ui, &format!("album:{}", a.id.0), icon, &label, None, Some(n), sel, indent, Sense::click_and_drag(), None); + let mut resp = Row::new(&format!("album:{}", a.id.0), icon, &label) + .count(Some(n)) + .selected(sel) + .indent(indent) + .sense(Sense::click_and_drag()) + .show(app, ui); if !problems.is_empty() { crate::widgets::register(ui, format!("albumProblem:{}", a.id.0), resp.rect); } diff --git a/crates/ui-egui/src/panels/left/dates.rs b/crates/ui-egui/src/panels/left/dates.rs index 9b822c6e5..2971cb2f4 100644 --- a/crates/ui-egui/src/panels/left/dates.rs +++ b/crates/ui-egui/src/panels/left/dates.rs @@ -1,4 +1,4 @@ -use super::rows::{INDENT_STEP, row, tree_open, tree_toggle}; +use super::rows::{INDENT_STEP, Row, tree_open, tree_toggle}; use super::{Icon, LibrarySource, LightcraftApp, json}; /// By Date: year → month → day; a click filters by that prefix, the triangle opens a level. @@ -24,7 +24,7 @@ fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str, let open_id = egui::Id::unique(("date-open", key.to_string())); let mut open = tree_open(ui, open_id, false); let sel = app.session.filter.date.as_deref() == Some(key); - let resp = row(app, ui, &format!("date:{key}"), Icon::Clock, label, Some(count), sel, indent); + let resp = Row::new(&format!("date:{key}"), Icon::Clock, label).count(Some(count)).selected(sel).indent(indent).show(app, ui); if key.len() < 10 { tree_toggle(ui, &resp, indent, open_id, &mut open, format!("dateToggle:{key}")); } diff --git a/crates/ui-egui/src/panels/left/folders.rs b/crates/ui-egui/src/panels/left/folders.rs index 1ded12f07..20c1bbce1 100644 --- a/crates/ui-egui/src/panels/left/folders.rs +++ b/crates/ui-egui/src/panels/left/folders.rs @@ -1,7 +1,7 @@ use super::albums::pointer_over_row; use super::local::{same_folder, subfolders}; -use super::rows::{INDENT_STEP, LIBFOLDER_REVEALED, out_of_sight, row, row_sensed, set_tree_open, tree_open, tree_toggle}; -use super::{Album, FolderNode, Icon, LibrarySource, LightcraftApp, Sense, Tokens, json, register, vec2}; +use super::rows::{INDENT_STEP, LIBFOLDER_REVEALED, Row, out_of_sight, set_tree_open, tree_open, tree_toggle}; +use super::{Album, FolderNode, Icon, LibrarySource, LightcraftApp, Tokens, json, register, vec2}; /// A folder on disk with a disclosure triangle: click browses it, the triangle lists its /// subfolders. In the tree that holds the folder being browsed (`reveal`), the folders on the @@ -39,7 +39,7 @@ pub(super) fn folder_tree( if !out_of_sight(app, ui, &format!("local:{path}"), name, None, indent, false) { // at the tree's own indent, as in Albums and Folders: the triangle sits in the gutter and the // icon lines up with the rows above - let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent).on_hover_text(path); + let resp = Row::new(&format!("local:{path}"), Icon::Folder, name).selected(sel).indent(indent).show(app, ui).on_hover_text(path); let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("folderToggle:{path}")); if !tr.clicked() && resp.clicked() @@ -341,7 +341,7 @@ fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &s None => name.to_string(), }; let mark = n.label.map(crate::theme::label_color); - let resp = row_sensed(app, ui, id, Icon::Folder, name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark); + let resp = Row::new(id, Icon::Folder, name).spoken(&spoken).count(Some(n.count)).selected(sel).indent(indent).mark(mark).show(app, ui); folder_drop_target(app, ui, &resp, n, open_id, open); let mut toggled = false; if !n.children.is_empty() { diff --git a/crates/ui-egui/src/panels/left/keywords.rs b/crates/ui-egui/src/panels/left/keywords.rs index 4342efcc6..d8e1720a9 100644 --- a/crates/ui-egui/src/panels/left/keywords.rs +++ b/crates/ui-egui/src/panels/left/keywords.rs @@ -1,5 +1,5 @@ use super::dates::browse_all_photos; -use super::rows::{INDENT_STEP, out_of_sight, row, tree_open, tree_toggle}; +use super::rows::{INDENT_STEP, Row, out_of_sight, tree_open, tree_toggle}; use super::{Icon, KeywordNode, LightcraftApp, json}; /// "Keywords": the library's keyword tree with photo counts (`a|b|c` keywords nest). A click @@ -23,7 +23,7 @@ pub(super) fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[ /// One drawn row of [`keyword_rows`]; `open_id` and `open` are its triangle's when it has rows inside it. fn keyword_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &KeywordNode, id: &str, indent: f32, open_id: Option, open: &mut bool) { let sel = app.session.filter.keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path)); - let resp = row(app, ui, id, Icon::Tag, &n.name, Some(n.count), sel, indent); + let resp = Row::new(id, Icon::Tag, &n.name).count(Some(n.count)).selected(sel).indent(indent).show(app, ui); if let Some(k) = open_id { tree_toggle(ui, &resp, indent, k, open, format!("keywordToggle:{}", n.path)); } diff --git a/crates/ui-egui/src/panels/left/local.rs b/crates/ui-egui/src/panels/left/local.rs index 6d3549570..1308059c8 100644 --- a/crates/ui-egui/src/panels/left/local.rs +++ b/crates/ui-egui/src/panels/left/local.rs @@ -1,5 +1,5 @@ use super::folders::folder_tree; -use super::rows::row; +use super::rows::Row; use super::{Icon, LibrarySource, LightcraftApp, SidebarSection, json, register}; /// Local's folders as they stand this frame, and the folder being browsed among them. @@ -61,8 +61,7 @@ pub(super) fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &Local let reveal = local.owner == Some(i); folder_tree(app, ui, path, name, path, 0.0, current, reveal, transient); } - if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, crate::i18n::tr("Browse Folder…"), None, false, 0.0).clicked() - { + if app.services.pick_folder.is_some() && Row::new("local:browse", Icon::Plus, crate::i18n::tr("Browse Folder…")).show(app, ui).clicked() { let picked = app.services.pick_folder.as_mut().and_then(|f| f()); if let Some(path) = picked { match app.run("library.browse", json!({"path": path})) { @@ -80,7 +79,8 @@ pub(super) fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &Local let hidden = now.hidden.len(); if hidden > 0 { let label = crate::i18n::tr_format!("Show {hidden} hidden location{}", if hidden == 1 { "" } else { "s" }, hidden = hidden); - let resp = row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0) + let resp = Row::new("local:restoreHidden", Icon::Folder, &label) + .show(app, ui) .on_hover_text(crate::i18n::tr("Put the locations you removed from Local back (no files change)")); if let [(_, only)] = now.hidden.as_slice() { if resp.clicked() { @@ -505,7 +505,7 @@ mod tests { for frame in 0..3 { let raw = HeadlessView::raw_input(egui::vec2(300.0, 200.0), 1.0, frame as f64 / 60.0, vec![]); view.run(raw, |ui| { - super::row(&mut app, ui, "all", super::Icon::Photos, "All Photos", None, false, 0.0); + super::Row::new("all", super::Icon::Photos, "All Photos").show(&mut app, ui); super::folder_tree(&mut app, ui, path, "Pictures", path, 0.0, None, false, false); }); widgets = crate::widgets::take_registry(&view.ctx); diff --git a/crates/ui-egui/src/panels/left/rows.rs b/crates/ui-egui/src/panels/left/rows.rs index edfdff167..ac364aa36 100644 --- a/crates/ui-egui/src/panels/left/rows.rs +++ b/crates/ui-egui/src/panels/left/rows.rs @@ -77,50 +77,66 @@ pub(crate) fn step_for(panel_w: f32, deepest: f32) -> f32 { [16.0, 14.0, 12.0, 10.0].into_iter().find(|step| levels * step <= room).unwrap_or(MIN_INDENT_STEP) } -pub(super) fn row( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - id: &str, +/// One row of the sidebar, as it is drawn: build it with [`Row::new`] and the setters, then [`Row::show`] it. +pub(super) struct Row<'a> { + id: &'a str, icon: Icon, - label: &str, - count: Option, - selected: bool, - indent: f32, -) -> egui::Response { - row_named(app, ui, id, icon, label, None, count, selected, indent) -} - -/// [`row`] whose spoken name is `spoken` when the painted `label` is a shortened form of it. -fn row_named( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - id: &str, - icon: Icon, - label: &str, - spoken: Option<&str>, - count: Option, - selected: bool, - indent: f32, -) -> egui::Response { - row_sensed(app, ui, id, icon, label, spoken, count, selected, indent, Sense::click(), None) -} - -/// [`row_named`] with the given `sense` (rows that can be dragged add `drag`) and a colour -/// label's dot (`mark`) before the count. -#[allow(clippy::too_many_arguments)] -pub(super) fn row_sensed( - app: &mut LightcraftApp, - ui: &mut egui::Ui, - id: &str, - icon: Icon, - label: &str, - spoken: Option<&str>, + label: &'a str, + /// the spoken name when the painted `label` is a shortened form of it + spoken: Option<&'a str>, count: Option, selected: bool, indent: f32, + /// rows that can be dragged add `drag` sense: Sense, + /// a colour label's dot, before the count mark: Option, -) -> egui::Response { +} + +impl<'a> Row<'a> { + pub(super) fn new(id: &'a str, icon: Icon, label: &'a str) -> Self { + Row { id, icon, label, spoken: None, count: None, selected: false, indent: 0.0, sense: Sense::click(), mark: None } + } + + pub(super) fn spoken(mut self, spoken: &'a str) -> Self { + self.spoken = Some(spoken); + self + } + + pub(super) fn count(mut self, count: Option) -> Self { + self.count = count; + self + } + + pub(super) fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + pub(super) fn indent(mut self, indent: f32) -> Self { + self.indent = indent; + self + } + + pub(super) fn sense(mut self, sense: Sense) -> Self { + self.sense = sense; + self + } + + pub(super) fn mark(mut self, mark: Option) -> Self { + self.mark = mark; + self + } + + /// Draw the row; the response is the row's. + pub(super) fn show(self, app: &mut LightcraftApp, ui: &mut egui::Ui) -> egui::Response { + draw_row(app, ui, self) + } +} + +/// Draw a [`Row`]. +fn draw_row(app: &mut LightcraftApp, ui: &mut egui::Ui, row: Row) -> egui::Response { + let Row { id, icon, label, spoken, count, selected, indent, sense, mark } = row; let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label }; let t = Tokens::get(ui.ctx()); let indent = drawn_indent(ui, indent); diff --git a/crates/ui-egui/src/panels/person.rs b/crates/ui-egui/src/panels/person.rs index 98b738cf2..0b6b946aa 100644 --- a/crates/ui-egui/src/panels/person.rs +++ b/crates/ui-egui/src/panels/person.rs @@ -221,7 +221,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { let Some(f) = page.confirmed.get(row * cols + col) else { break }; shown += 1; let min = area.min + vec2(PAD + col as f32 * (edge + super::people::TILE_GAP), PAD + row as f32 * row_h); - if let Hit::Open = tile(app, ui, Rect::from_min_size(min, vec2(edge, edge)), f, ppp, false, &shown_name, 0) { + if let Hit::Open = + tile(app, ui, &TileSpec { r: Rect::from_min_size(min, vec2(edge, edge)), f, ppp, more: false, name: &shown_name, above_count: 0 }) + { hit = Some((Hit::Open, f.clone())); } } @@ -254,7 +256,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { shown += 1; let min = area.min + vec2(PAD + col as f32 * (edge + super::people::TILE_GAP), grid_top + row as f32 * row_h); let above_count = more.iter().filter(|candidate| candidate.score >= f.score).count(); - match tile(app, ui, Rect::from_min_size(min, vec2(edge, edge)), f, ppp, true, &shown_name, above_count) { + match tile(app, ui, &TileSpec { r: Rect::from_min_size(min, vec2(edge, edge)), f, ppp, more: true, name: &shown_name, above_count }) { Hit::Confirm => hit = Some((Hit::Confirm, (*f).clone())), Hit::ConfirmAbove => hit = Some((Hit::ConfirmAbove, (*f).clone())), Hit::Dismiss => hit = Some((Hit::Dismiss, (*f).clone())), @@ -287,9 +289,25 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { } } +/// What one face tile shows. +struct TileSpec<'a> { + /// where it is drawn + r: Rect, + f: &'a Face, + /// pixels per point, for the size of the face picture + ppp: f32, + /// a "More" tile (a face that looks like the person) rather than a confirmed one + more: bool, + /// the person's name + name: &'a str, + /// how many shown faces look at least as much like the person (for a "More" tile) + above_count: usize, +} + /// One face tile. A confirmed one opens its photo when clicked; a "More" one is confirmed when clicked, and has a × that /// hides it. -fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, f: &Face, ppp: f32, more: bool, name: &str, above_count: usize) -> Hit { +fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, spec: &TileSpec) -> Hit { + let TileSpec { r, f, ppp, more, name, above_count } = *spec; let t = Tokens::get(ui.ctx()); let salt = if more { "more-face" } else { "person-face" }; let resp = ui.interact(r, egui::Id::unique((salt, f.photo, f.index)), Sense::click()); diff --git a/crates/ui-egui/src/tests/lifecycle_037.rs b/crates/ui-egui/src/tests/lifecycle_037.rs index 3c4e8009b..bb6f9d860 100644 --- a/crates/ui-egui/src/tests/lifecycle_037.rs +++ b/crates/ui-egui/src/tests/lifecycle_037.rs @@ -121,9 +121,20 @@ fn resizable_side_stores_panel_state_under_the_parent_salted_key() { let ctx = egui::Context::default(); let raw = raw(egui::vec2(1000.0, 700.0)); frame(&ctx, raw, |ui| { - crate::panels::resizable_side(ui, true, "side-test", egui::Frame::NONE, 260.0, crate::state::LEFT_WIDTH, 300.0, |ui| { - ui.label("side"); - }); + crate::panels::resizable_side( + ui, + crate::panels::SidePanel { + left: true, + id: "side-test", + frame: egui::Frame::NONE, + width: 260.0, + limits: crate::state::LEFT_WIDTH, + reserve: 300.0, + }, + |ui| { + ui.label("side"); + }, + ); }); let modern = ctx.viewport_id().root_ui_id().with("side-test"); assert!(