refactor(ui-egui): pass related arguments as small structs

This commit is contained in:
Dylan M. Taylor
2026-10-28 03:25:18 +00:00
parent b74612513c
commit 7b4236bc8b
14 changed files with 233 additions and 118 deletions
@@ -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<V: PartialEq + Copy>(
ui: &mut egui::Ui,
t: &Tokens,
title: &str,
active: bool,
options: &[(V, crate::theme::ThemeKind)],
value: &mut V,
favorites: &mut Vec<String>,
width: f32,
) {
fn theme_card<V: PartialEq + Copy>(ui: &mut egui::Ui, t: &Tokens, card: ThemeCard<V>, value: &mut V, favorites: &mut Vec<String>) {
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 }))
@@ -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;
+15 -10
View File
@@ -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<f32> {
pub fn resizable_side(ui: &mut egui::Ui, side: SidePanel, add: impl FnOnce(&mut egui::Ui)) -> Option<f32> {
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.)
+13 -9
View File
@@ -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, &regions, &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()
+13 -5
View File
@@ -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<crate::state::NameEdit>,
setup: crate::panels::faces::Setup,
) -> Option<RegionEdit> {
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());
+13 -4
View File
@@ -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)
+8 -4
View File
@@ -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);
}
+2 -2
View File
@@ -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}"));
}
+4 -4
View File
@@ -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() {
+2 -2
View File
@@ -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<egui::Id>, 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));
}
+5 -5
View File
@@ -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);
+54 -38
View File
@@ -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<usize>,
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<usize>,
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<usize>,
selected: bool,
indent: f32,
/// rows that can be dragged add `drag`
sense: Sense,
/// a colour label's dot, before the count
mark: Option<egui::Color32>,
) -> 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<usize>) -> 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<egui::Color32>) -> 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);
+21 -3
View File
@@ -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());
+14 -3
View File
@@ -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!(