mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 22:49:48 -06:00
refactor(ui-egui): pass related arguments as small structs
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user