diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e42ed5ea..128f3c03 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,8 @@ mod tests_curve; #[cfg(test)] mod tests_masking; #[cfg(test)] +mod tests_panels; +#[cfg(test)] mod tests_scroll; use std::sync::mpsc::{Receiver, Sender}; diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 28cee606..a904c87c 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -17,179 +17,199 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left: 12, right: 12, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); - ui.horizontal_centered(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; - for (id, icon, mode, tip) in [ - ("photoGrid", Icon::GridPhoto, ViewMode::PhotoGrid, "Photo Grid (G)"), - ("squareGrid", Icon::GridSquare, ViewMode::SquareGrid, "Square Grid (G toggles)"), - ("detail", Icon::Single, ViewMode::Detail, "Detail (D)"), - ("compare", Icon::Compare, ViewMode::Compare, "Compare (Shift+C)"), - ("survey", Icon::Survey, ViewMode::Survey, "Survey (N)"), - ] { - if icon_button(ui, id, icon, vec2(32.0, 32.0), app.ui.view == mode, true, tip).clicked() { - let _ = app.run(&format!("view.{id}"), json!({})); - } - } - ui.add_space(10.0); - let (sep, _) = ui.allocate_exact_size(vec2(1.0, 22.0), Sense::hover()); - ui.painter().rect_filled(sep, 0.0, t.button_border); - ui.add_space(10.0); - let sort = icon_button(ui, "sort", Icon::Sort, vec2(30.0, 30.0), false, true, "Sort"); - egui::Popup::menu(&sort).show(|ui| sort_menu(app, ui)); - }); - - // centre: rating/flag pill + copy/paste settings - let active = app.session.active().and_then(|id| app.session.catalog.photo(id).cloned()); - let pill = Rect::from_center_size(pos2(full.center().x - 60.0, full.center().y), vec2(196.0, 30.0)); - ui.painter().rect_filled(pill, 15.0, t.canvas); - let mut pill_ui = - ui.new_child(egui::UiBuilder::new().max_rect(pill.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); - pill_ui.spacing_mut().item_spacing.x = 3.0; - let rating = active.as_ref().map(|p| p.rating).unwrap_or(0); - if let Some(r) = stars(&mut pill_ui, "bottom", rating, 19.0) { - let mut params = json!({"rating": r}); - crate::panels::compare::target_active(app, &mut params); - let _ = app.run("photo.rate", params); - } - pill_ui.add_space(6.0); - let flag = active.as_ref().map(|p| p.flag).unwrap_or_default(); - for (id, icon, f) in - [("unflag", Icon::Circle, Flag::None), ("pick", Icon::FlagPick, Flag::Pick), ("reject", Icon::FlagReject, Flag::Reject)] - { - let (r, resp) = pill_ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); - register(pill_ui.ctx(), format!("flag:{id}"), r); - let on = flag == f && f != Flag::None; - let c = if on { - if f == Flag::Reject { t.reject } else { t.pick } - } else if resp.hovered() { - t.text - } else { - t.icon - }; - paint(pill_ui.painter(), r.shrink(3.0), icon, c); - if resp.clicked() { - let mut params = json!({"flag": match f { Flag::Pick => "pick", Flag::Reject => "reject", Flag::None => "none" }}); - crate::panels::compare::target_active(app, &mut params); - let _ = app.run("photo.flag", params); - } - } - // copy / paste settings - let copy_r = Rect::from_min_size(pos2(pill.right() + 10.0, pill.top()), vec2(136.0, 30.0)); - let has_clip = app.session.clipboard.is_some(); - let label = if has_clip { "Paste Edit Settings" } else { "Copy Edit Settings" }; - let cresp = ui.interact(copy_r, egui::Id::new("copy-settings"), Sense::click()); - register(ui.ctx(), "button:copySettings", copy_r); - ui.painter().rect_filled(copy_r, 15.0, if cresp.hovered() { t.hover } else { t.canvas }); - ui.painter().text( - copy_r.center(), - Align2::CENTER_CENTER, - label, - t.font(13.0), - if active.is_some() { t.text_label } else { t.text_disabled }, - ); - if cresp.clicked() && active.is_some() { - let _ = if has_clip { app.run("develop.paste", json!({})) } else { app.run("develop.copy", json!({})) }; - let msg = if has_clip { "Settings pasted" } else { "Edit settings copied" }; - app.toast(ui.ctx(), msg); - } - let gear_r = Rect::from_min_size(pos2(copy_r.right() + 4.0, pill.top()), vec2(30.0, 30.0)); - let gresp = ui.interact(gear_r, egui::Id::new("copy-gear"), Sense::click()); - register(ui.ctx(), "icon:copyGear", gear_r); - ui.painter().rect_filled(gear_r, 15.0, if gresp.hovered() { t.hover } else { t.canvas }); - paint(ui.painter(), gear_r.shrink(7.0), Icon::Gear, t.icon); - if gresp.clicked() { - let _ = app.run("dialog.copySettings", json!({})); - } - - // right side - let mut child = ui.new_child( - egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(full.right() - 360.0, full.top()), full.right_bottom())) - .layout(egui::Layout::right_to_left(egui::Align::Center)), - ); - child.spacing_mut().item_spacing.x = 6.0; - if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) - && icon_button( - &mut child, - "autoAdvance", - Icon::ChevronRight, - vec2(30.0, 30.0), - app.ui.auto_advance, - true, - "Auto Advance after rating or flagging", - ) - .clicked() - { - let _ = app.run("view.autoAdvance", json!({})); - } - if app.ui.view == ViewMode::Survey { - // no zoom or thumbnail size in Survey - } else if matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) { - if icon_button( - &mut child, - "beforeAfter", - Icon::BeforeAfter, - vec2(30.0, 30.0), - app.ui.before_after != BeforeAfter::Off, - true, - "Before/After (Y)", - ) - .clicked() - { - let _ = app.run("view.beforeAfter", json!({})); - } - if icon_button(&mut child, "filmstrip", Icon::Filmstrip, vec2(30.0, 30.0), app.ui.filmstrip, true, "Filmstrip (/)").clicked() { - let _ = app.run("view.filmstrip", json!({})); - } - child.add_space(10.0); - let zoom_label = match app.ui.zoom { - Zoom::Fit => "Fit".to_string(), - Zoom::Fill => "Fill".to_string(), - Zoom::Percent(p) => format!("{p}%"), - }; - let zr = crate::widgets::dropdown(&mut child, "zoom", &zoom_label, t.font(13.0), t.text_label); - egui::Popup::menu(&zr).show(|ui| { - for (label, z) in [ - ("Fit", Zoom::Fit), - ("Fill", Zoom::Fill), - ("50%", Zoom::Percent(50)), - ("100%", Zoom::Percent(100)), - ("200%", Zoom::Percent(200)), - ("400%", Zoom::Percent(400)), + let left_end = ui + .horizontal_centered(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + for (id, icon, mode, tip) in [ + ("photoGrid", Icon::GridPhoto, ViewMode::PhotoGrid, "Photo Grid (G)"), + ("squareGrid", Icon::GridSquare, ViewMode::SquareGrid, "Square Grid (G toggles)"), + ("detail", Icon::Single, ViewMode::Detail, "Detail (D)"), + ("compare", Icon::Compare, ViewMode::Compare, "Compare (Shift+C)"), + ("survey", Icon::Survey, ViewMode::Survey, "Survey (N)"), ] { - if ui.selectable_label(app.ui.zoom == z, label).clicked() { - app.ui.zoom = z; - app.ui.pan = (0.5, 0.5); + if icon_button(ui, id, icon, vec2(32.0, 32.0), app.ui.view == mode, true, tip).clicked() { + let _ = app.run(&format!("view.{id}"), json!({})); } } - }); - child.add_space(6.0); - let cz = - crate::widgets::dropdown(&mut child, "clickZoom", &format!("Click {}:1", app.ui.click_zoom / 100), t.font(13.0), t.text_label); - egui::Popup::menu(&cz).show(|ui| { - for pct in crate::state::CLICK_ZOOMS { - if ui.selectable_label(app.ui.click_zoom == pct, format!("{}:1", pct / 100)).clicked() { - let _ = app.run("view.clickZoom", json!({"ratio": pct / 100})); - } - } - }); - } else { - // thumbnail size slider - let (r, resp) = child.allocate_exact_size(vec2(110.0, 20.0), Sense::click_and_drag()); - register(child.ctx(), "slider:thumbSize", r); - let p = child.painter(); - let y = r.center().y; - p.line_segment([pos2(r.left(), y), pos2(r.right(), y)], egui::Stroke::new(2.0, t.track)); - let f = (app.ui.thumb_size - 90.0) / (480.0 - 90.0); - let x = r.left() + f * r.width(); - p.rect_filled(Rect::from_center_size(pos2(x, y), vec2(3.0, 14.0)), 1.0, t.thumb_hover); - if let Some(pp) = resp.interact_pointer_pos() - && (resp.dragged() || resp.clicked()) - { - app.ui.thumb_size = 90.0 + ((pp.x - r.left()) / r.width()).clamp(0.0, 1.0) * (480.0 - 90.0); + ui.add_space(10.0); + let (sep, _) = ui.allocate_exact_size(vec2(1.0, 22.0), Sense::hover()); + ui.painter().rect_filled(sep, 0.0, t.button_border); + ui.add_space(10.0); + let sort = icon_button(ui, "sort", Icon::Sort, vec2(30.0, 30.0), false, true, "Sort"); + egui::Popup::menu(&sort).show(|ui| sort_menu(app, ui)); + }) + .response + .rect + .right(); + let right_start = right_side(app, ui, full); + centre(app, ui, full, left_end + 12.0, right_start - 12.0); + }); +} + +/// Width of the centre group: the rating/flag pill, then Copy/Paste Edit Settings and its gear. +const PILL_W: f32 = 196.0; +const CENTRE_W: f32 = PILL_W + 10.0 + 136.0 + 4.0 + 30.0; + +/// The rating/flag pill and copy/paste settings, between `from` and `to` (the side groups): the +/// copy buttons go first when there is no room (they are in the Edit menu too), then the pill. +fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: f32) { + let t = Tokens::get(ui.ctx()); + let room = to - from; + if room < PILL_W { + return; + } + let with_copy = room >= CENTRE_W; + let w = if with_copy { CENTRE_W } else { PILL_W }; + // where it sits with room to spare (slightly left of centre), kept between the side groups + let left = (full.center().x - 60.0 - PILL_W / 2.0).clamp(from, to - w); + let active = app.session.active().and_then(|id| app.session.catalog.photo(id).cloned()); + let pill = Rect::from_min_size(pos2(left, full.center().y - 15.0), vec2(PILL_W, 30.0)); + ui.painter().rect_filled(pill, 15.0, t.canvas); + let mut pill_ui = + ui.new_child(egui::UiBuilder::new().max_rect(pill.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + pill_ui.spacing_mut().item_spacing.x = 3.0; + let rating = active.as_ref().map(|p| p.rating).unwrap_or(0); + if let Some(r) = stars(&mut pill_ui, "bottom", rating, 19.0) { + let mut params = json!({"rating": r}); + crate::panels::compare::target_active(app, &mut params); + let _ = app.run("photo.rate", params); + } + pill_ui.add_space(6.0); + let flag = active.as_ref().map(|p| p.flag).unwrap_or_default(); + for (id, icon, f) in [("unflag", Icon::Circle, Flag::None), ("pick", Icon::FlagPick, Flag::Pick), ("reject", Icon::FlagReject, Flag::Reject)] { + let (r, resp) = pill_ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + register(pill_ui.ctx(), format!("flag:{id}"), r); + let on = flag == f && f != Flag::None; + let c = if on { + if f == Flag::Reject { t.reject } else { t.pick } + } else if resp.hovered() { + t.text + } else { + t.icon + }; + paint(pill_ui.painter(), r.shrink(3.0), icon, c); + if resp.clicked() { + let mut params = json!({"flag": match f { Flag::Pick => "pick", Flag::Reject => "reject", Flag::None => "none" }}); + crate::panels::compare::target_active(app, &mut params); + let _ = app.run("photo.flag", params); + } + } + // copy / paste settings + if !with_copy { + return; + } + let copy_r = Rect::from_min_size(pos2(pill.right() + 10.0, pill.top()), vec2(136.0, 30.0)); + let has_clip = app.session.clipboard.is_some(); + let label = if has_clip { "Paste Edit Settings" } else { "Copy Edit Settings" }; + let cresp = ui.interact(copy_r, egui::Id::new("copy-settings"), Sense::click()); + register(ui.ctx(), "button:copySettings", copy_r); + ui.painter().rect_filled(copy_r, 15.0, if cresp.hovered() { t.hover } else { t.canvas }); + ui.painter().text(copy_r.center(), Align2::CENTER_CENTER, label, t.font(13.0), if active.is_some() { t.text_label } else { t.text_disabled }); + if cresp.clicked() && active.is_some() { + let _ = if has_clip { app.run("develop.paste", json!({})) } else { app.run("develop.copy", json!({})) }; + let msg = if has_clip { "Settings pasted" } else { "Edit settings copied" }; + app.toast(ui.ctx(), msg); + } + let gear_r = Rect::from_min_size(pos2(copy_r.right() + 4.0, pill.top()), vec2(30.0, 30.0)); + let gresp = ui.interact(gear_r, egui::Id::new("copy-gear"), Sense::click()); + register(ui.ctx(), "icon:copyGear", gear_r); + ui.painter().rect_filled(gear_r, 15.0, if gresp.hovered() { t.hover } else { t.canvas }); + paint(ui.painter(), gear_r.shrink(7.0), Icon::Gear, t.icon); + if gresp.clicked() { + let _ = app.run("dialog.copySettings", json!({})); + } +} + +/// The right-hand group (zoom, view toggles or thumbnail size); returns its left edge. +fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 { + let t = Tokens::get(ui.ctx()); + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(full.right() - 360.0, full.top()), full.right_bottom())) + .layout(egui::Layout::right_to_left(egui::Align::Center)), + ); + child.spacing_mut().item_spacing.x = 6.0; + if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) + && icon_button( + &mut child, + "autoAdvance", + Icon::ChevronRight, + vec2(30.0, 30.0), + app.ui.auto_advance, + true, + "Auto Advance after rating or flagging", + ) + .clicked() + { + let _ = app.run("view.autoAdvance", json!({})); + } + if app.ui.view == ViewMode::Survey { + // no zoom or thumbnail size in Survey + } else if matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) { + if icon_button( + &mut child, + "beforeAfter", + Icon::BeforeAfter, + vec2(30.0, 30.0), + app.ui.before_after != BeforeAfter::Off, + true, + "Before/After (Y)", + ) + .clicked() + { + let _ = app.run("view.beforeAfter", json!({})); + } + if icon_button(&mut child, "filmstrip", Icon::Filmstrip, vec2(30.0, 30.0), app.ui.filmstrip, true, "Filmstrip (/)").clicked() { + let _ = app.run("view.filmstrip", json!({})); + } + child.add_space(10.0); + let zoom_label = match app.ui.zoom { + Zoom::Fit => "Fit".to_string(), + Zoom::Fill => "Fill".to_string(), + Zoom::Percent(p) => format!("{p}%"), + }; + let zr = crate::widgets::dropdown(&mut child, "zoom", &zoom_label, t.font(13.0), t.text_label); + egui::Popup::menu(&zr).show(|ui| { + for (label, z) in [ + ("Fit", Zoom::Fit), + ("Fill", Zoom::Fill), + ("50%", Zoom::Percent(50)), + ("100%", Zoom::Percent(100)), + ("200%", Zoom::Percent(200)), + ("400%", Zoom::Percent(400)), + ] { + if ui.selectable_label(app.ui.zoom == z, label).clicked() { + app.ui.zoom = z; + app.ui.pan = (0.5, 0.5); } } }); + child.add_space(6.0); + let cz = crate::widgets::dropdown(&mut child, "clickZoom", &format!("Click {}:1", app.ui.click_zoom / 100), t.font(13.0), t.text_label); + egui::Popup::menu(&cz).show(|ui| { + for pct in crate::state::CLICK_ZOOMS { + if ui.selectable_label(app.ui.click_zoom == pct, format!("{}:1", pct / 100)).clicked() { + let _ = app.run("view.clickZoom", json!({"ratio": pct / 100})); + } + } + }); + } else { + // thumbnail size slider + let (r, resp) = child.allocate_exact_size(vec2(110.0, 20.0), Sense::click_and_drag()); + register(child.ctx(), "slider:thumbSize", r); + let p = child.painter(); + let y = r.center().y; + p.line_segment([pos2(r.left(), y), pos2(r.right(), y)], egui::Stroke::new(2.0, t.track)); + let f = (app.ui.thumb_size - 90.0) / (480.0 - 90.0); + let x = r.left() + f * r.width(); + p.rect_filled(Rect::from_center_size(pos2(x, y), vec2(3.0, 14.0)), 1.0, t.thumb_hover); + if let Some(pp) = resp.interact_pointer_pos() + && (resp.dragged() || resp.clicked()) + { + app.ui.thumb_size = 90.0 + ((pp.x - r.left()) / r.width()).clamp(0.0, 1.0) * (480.0 - 90.0); + } + } + if child.min_rect().width() > 0.0 { child.min_rect().left() } else { full.right() } } fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 72516322..60ef6004 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -56,94 +56,93 @@ fn row( pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - egui::Panel::left("left_panel") - .exact_size(t.left_w) - .resizable(false) - .frame(egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider))) - .show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 0.0; - let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); - ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, "My Photos", t.semibold(15.0), t.text); - let counts = app.caches.counts(&app.session.catalog); - let (total, picks, deleted) = (counts.total, counts.picks, counts.deleted); - egui::ScrollArea::vertical().id_salt("left-scroll").auto_shrink([false, false]).show(ui, |ui| { - let src = app.session.source; - for (id, icon, label, count, s) in [ - ("all", Icon::Photos, "All Photos", Some(total), LibrarySource::All), - ("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() { - let _ = app.run("library.source", json!({"kind": id})); - } + let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)); + let width = app.ui.left_width; + let resized = super::resizable_side(ui, true, "left_panel", frame, width, crate::state::LEFT_WIDTH, 0.0, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); + ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, "My Photos", t.semibold(15.0), t.text); + let counts = app.caches.counts(&app.session.catalog); + let (total, picks, deleted) = (counts.total, counts.picks, counts.deleted); + egui::ScrollArea::vertical().id_salt("left-scroll").auto_shrink([false, false]).show(ui, |ui| { + let src = app.session.source; + for (id, icon, label, count, s) in [ + ("all", Icon::Photos, "All Photos", Some(total), LibrarySource::All), + ("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() { + 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() - { - let _ = app.run("library.source", json!({"kind": "missing"})); + } + // 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() + { + let _ = app.run("library.source", json!({"kind": "missing"})); + } + ui.add_space(10.0); + // Albums header + let (ar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); + ui.painter().text(pos2(ar.left() + 18.0, ar.center().y), Align2::LEFT_CENTER, "Albums", t.semibold(13.5), t.text_label); + let mut hdr = ui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(ar.right() - 50.0, ar.top()), ar.right_bottom())) + .layout(egui::Layout::right_to_left(egui::Align::Center)), + ); + let plus = icon_button(&mut hdr, "albumNew", Icon::Plus, vec2(26.0, 26.0), false, true, "Create Album"); + egui::Popup::menu(&plus).show(|ui| { + if ui.button("Create Album…").clicked() { + app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false }); } - ui.add_space(10.0); - // Albums header - let (ar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); - ui.painter().text(pos2(ar.left() + 18.0, ar.center().y), Align2::LEFT_CENTER, "Albums", t.semibold(13.5), t.text_label); - let mut hdr = ui.new_child( - egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(ar.right() - 50.0, ar.top()), ar.right_bottom())) - .layout(egui::Layout::right_to_left(egui::Align::Center)), - ); - let plus = icon_button(&mut hdr, "albumNew", Icon::Plus, vec2(26.0, 26.0), false, true, "Create Album"); - egui::Popup::menu(&plus).show(|ui| { - if ui.button("Create Album…").clicked() { - app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false }); - } - if ui.button("Create Smart Album…").clicked() { - app.ui.dialog = Some(crate::state::Dialog::SmartRules { - id: None, - name: String::new(), - rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, - }); - } - if ui.button("Create Smart Album from Filter…").clicked() { - app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new() }); - } - if ui.button("Create Folder…").clicked() { - app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true }); - } - }); - let albums: Vec = app.session.catalog.albums().cloned().collect(); - albums_tree(app, ui, &albums, None, 0.0); - ui.add_space(10.0); - local_section(app, ui); - // By date - let (dr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); - ui.painter().text(pos2(dr.left() + 18.0, dr.center().y), Align2::LEFT_CENTER, "By Date", t.semibold(13.5), t.text_label); - for g in app.caches.date_groups(&app.session.catalog).iter() { - // year → month → day; a click filters by that prefix, the triangle opens a level - if date_row(app, ui, &g.year, &g.year, g.count, 0.0) { - for (m, n) in &g.months { - let label = lightcraft_catalog::dates::group_label(m).split(' ').next().unwrap_or(m).to_string(); - if date_row(app, ui, m, &label, *n, 16.0) { - for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) { - let label = lightcraft_catalog::dates::group_label(d); - // "Sunday, 20 September 2026" → "Sunday, 20" - let label = label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string(); - date_row(app, ui, d, &label, *n, 32.0); - } + if ui.button("Create Smart Album…").clicked() { + app.ui.dialog = Some(crate::state::Dialog::SmartRules { + id: None, + name: String::new(), + rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, + }); + } + if ui.button("Create Smart Album from Filter…").clicked() { + app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new() }); + } + if ui.button("Create Folder…").clicked() { + app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true }); + } + }); + let albums: Vec = app.session.catalog.albums().cloned().collect(); + albums_tree(app, ui, &albums, None, 0.0); + ui.add_space(10.0); + local_section(app, ui); + // By date + let (dr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); + ui.painter().text(pos2(dr.left() + 18.0, dr.center().y), Align2::LEFT_CENTER, "By Date", t.semibold(13.5), t.text_label); + for g in app.caches.date_groups(&app.session.catalog).iter() { + // year → month → day; a click filters by that prefix, the triangle opens a level + if date_row(app, ui, &g.year, &g.year, g.count, 0.0) { + for (m, n) in &g.months { + let label = lightcraft_catalog::dates::group_label(m).split(' ').next().unwrap_or(m).to_string(); + if date_row(app, ui, m, &label, *n, 16.0) { + for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) { + let label = lightcraft_catalog::dates::group_label(d); + // "Sunday, 20 September 2026" → "Sunday, 20" + let label = label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string(); + date_row(app, ui, d, &label, *n, 32.0); } } } } - keywords_section(app, ui); - ui.add_space(10.0); - if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0) - .clicked() - { - let _ = app.run("library.source", json!({"kind": "recentlyDeleted"})); - } - }); + } + keywords_section(app, ui); + ui.add_space(10.0); + if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0).clicked() { + let _ = app.run("library.source", json!({"kind": "recentlyDeleted"})); + } }); + }); + if let Some(w) = resized { + app.ui.left_width = w; + } } /// How many library photos have no file. Checking stats every file, which on a network share diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 06f1bf90..726dc599 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -25,6 +25,38 @@ use egui::{Align2, Rect, pos2, vec2}; use crate::LightcraftApp; use crate::theme::Tokens; +/// 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 { + let pid = egui::Id::new(id); + ui.ctx().data_mut(|d| d.remove::(pid)); + let avail = ui.available_rect_before_wrap(); + let max = (avail.width() - reserve - crate::state::MIN_PHOTO_WIDTH).clamp(limits.min, limits.max); + let panel = if left { egui::Panel::left(pid) } else { egui::Panel::right(pid) }; + let r = panel.frame(frame).resizable(true).size_range(limits.min..=max).default_size(limits.clamp(width).min(max)).show(ui, add); + crate::widgets::register(ui.ctx(), format!("panel:{id}"), r.response.rect); + // egui names the edge's drag widget `.with("__resize")`; the width is measured from + // the panel's fixed edge to the pointer (contents wider than the panel don't count) + if !ui.ctx().is_being_dragged(pid.with("__resize")) { + return None; + } + let x = ui.ctx().pointer_interact_pos()?.x; + let w = if left { x - avail.left() } else { avail.right() - x }; + Some(w.round().clamp(limits.min, max)) +} + /// The HUD toast at the bottom centre of the canvas. pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { let now = ctx.input(|i| i.time); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index ab31da25..b8aedb96 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -13,33 +13,36 @@ use crate::widgets::{divider, register, slider, text_button}; pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - egui::Panel::right("right_panel") - .exact_size(t.panel_w) - .resizable(false) - .frame(egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider))) - .show(ui, |ui| { - let Some(id) = app.session.active() else { - let r = ui.max_rect(); - super::empty_message(ui, r, "No photo selected", "Select a photo to edit"); - return; - }; - egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 0.0; - match app.ui.right { - RightPanel::Edit => super::edit::show(app, ui, id), - RightPanel::Profiles => super::profiles::show(app, ui, id), - RightPanel::Crop => crop(app, ui, id), - RightPanel::Remove => remove(app, ui, id), - RightPanel::Masking => super::masking::show(app, ui, id), - RightPanel::RedEye => red_eye(app, ui, id), - RightPanel::Info => info(app, ui, id), - RightPanel::Keywords => keywords(app, ui, id), - RightPanel::Versions => versions(app, ui, id), - RightPanel::Activity => activity(app, ui, id), - RightPanel::None => {} - } - }); + let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)); + // the presets column and the left sidebar are laid out after this panel: leave them their room + let reserve = if app.ui.presets { t.panel_w } else { 0.0 } + if app.ui.left_panel { crate::state::LEFT_WIDTH.min } else { 0.0 }; + let width = app.ui.right_width; + let resized = super::resizable_side(ui, false, "right_panel", frame, width, crate::state::RIGHT_WIDTH, reserve, |ui| { + let Some(id) = app.session.active() else { + let r = ui.max_rect(); + super::empty_message(ui, r, "No photo selected", "Select a photo to edit"); + return; + }; + egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + match app.ui.right { + RightPanel::Edit => super::edit::show(app, ui, id), + RightPanel::Profiles => super::profiles::show(app, ui, id), + RightPanel::Crop => crop(app, ui, id), + RightPanel::Remove => remove(app, ui, id), + RightPanel::Masking => super::masking::show(app, ui, id), + RightPanel::RedEye => red_eye(app, ui, id), + RightPanel::Info => info(app, ui, id), + RightPanel::Keywords => keywords(app, ui, id), + RightPanel::Versions => versions(app, ui, id), + RightPanel::Activity => activity(app, ui, id), + RightPanel::None => {} + } }); + }); + if let Some(w) = resized { + app.ui.right_width = w; + } } pub fn header(ui: &mut egui::Ui, title: &str) { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index a9749f89..116c6c6b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -175,6 +175,28 @@ pub const PREVIEW_EDGES: [u32; 4] = [1600, 2560, 3840, 5120]; /// Click-zoom ratios offered (percent): 1:1, 2:1, 3:1, 4:1, 8:1. pub const CLICK_ZOOMS: [u32; 5] = [100, 200, 300, 400, 800]; +/// Width limits of a side panel the user resizes (points). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PanelWidth { + pub min: f32, + pub default: f32, + pub max: f32, +} + +impl PanelWidth { + /// `w` within the limits (the default when it isn't a number). + pub fn clamp(&self, w: f32) -> f32 { + if w.is_finite() { w.clamp(self.min, self.max) } else { self.default } + } +} + +/// The left sidebar (sources, albums, folders). +pub const LEFT_WIDTH: PanelWidth = PanelWidth { min: 200.0, default: 268.0, max: 480.0 }; +/// The right panel (Edit, Masking, Info, …). +pub const RIGHT_WIDTH: PanelWidth = PanelWidth { min: 250.0, default: 270.0, max: 520.0 }; +/// The photo area the side panels always leave free (as far as their minimum widths allow). +pub const MIN_PHOTO_WIDTH: f32 = 360.0; + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { @@ -184,6 +206,10 @@ pub struct UiState { pub view: ViewMode, pub left_panel: bool, pub right: RightPanel, + /// Widths of the left sidebar and of the right panel in points (dragging their inner edge + /// resizes them; within [`LEFT_WIDTH`] / [`RIGHT_WIDTH`]). + pub left_width: f32, + pub right_width: f32, /// Presets column open (opens to the left of the Edit panel). pub presets: bool, /// Presets column: show a live thumbnail of the photo with each preset. @@ -477,6 +503,8 @@ impl Default for UiState { luminance_map_restore: None, view: ViewMode::Detail, left_panel: false, + left_width: LEFT_WIDTH.default, + right_width: RIGHT_WIDTH.default, right: RightPanel::Edit, presets: false, preset_thumbs: false, @@ -580,6 +608,8 @@ impl UiState { /// Clamp values restored from disk. pub fn sanitized(mut self) -> Self { self.thumb_size = self.thumb_size.clamp(90.0, 480.0); + self.left_width = LEFT_WIDTH.clamp(self.left_width); + self.right_width = RIGHT_WIDTH.clamp(self.right_width); self.brush_size = self.brush_size.clamp(0.002, 0.5); self.dialog = None; self.fullscreen = false; diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs new file mode 100644 index 00000000..2f6873ed --- /dev/null +++ b/crates/ui-egui/src/tests_panels.rs @@ -0,0 +1,95 @@ +//! Headless tests of the side panels: dragging their inner edge resizes them within limits, and +//! the chosen widths survive switching panels and a save/load of the UI state (issue #20). + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::{LEFT_WIDTH, MIN_PHOTO_WIDTH, RIGHT_WIDTH}; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn demo(size: [f32; 2], ui: serde_json::Value) -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, size, 1.0); + let r = h.request("ui.set", ui, T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +fn widget(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +/// Drag horizontally from `x` by `dx` (within the 1400 pt window) at mid height. +fn drag(h: &mut Headless, x: f32, dx: f32) { + let to = (x + dx).clamp(1.0, 1399.0); + let r = h.request("ui.drag", json!({"x": x, "y": 500.0, "toX": to, "toY": 500.0, "steps": 12}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); +} + +#[test] +fn side_panels_resize_by_their_inner_edge_and_remember_it() { + let mut h = demo([1400.0, 900.0], json!({"view": "detail", "right": "edit", "leftPanel": true})); + let right = widget(&h, "panel:right_panel"); + let left = widget(&h, "panel:left_panel"); + assert_eq!((left.width(), right.width()), (LEFT_WIDTH.default, RIGHT_WIDTH.default)); + // the right panel's left edge: 120 pt wider + drag(&mut h, right.left() + 2.0, -120.0); + assert!((h.app.ui.right_width - (RIGHT_WIDTH.default + 120.0)).abs() <= 2.0, "{}", h.app.ui.right_width); + assert!((widget(&h, "panel:right_panel").width() - h.app.ui.right_width).abs() <= 1.0); + // the left sidebar's right edge: 70 pt wider + drag(&mut h, left.right() - 2.0, 70.0); + assert!((h.app.ui.left_width - (LEFT_WIDTH.default + 70.0)).abs() <= 2.0, "{}", h.app.ui.left_width); + // kept across panel switches and view changes + let (lw, rw) = (h.app.ui.left_width, h.app.ui.right_width); + for set in [json!({"right": "info"}), json!({"view": "photoGrid"}), json!({"right": "masking", "view": "detail"})] { + h.request("ui.set", set, T); + h.step(); + assert_eq!((widget(&h, "panel:left_panel").width(), widget(&h, "panel:right_panel").width()), (lw, rw)); + } + // and across a save/load of the UI state (ui.json) + let saved = serde_json::to_string(&h.app.ui).unwrap(); + let back = serde_json::from_str::(&saved).unwrap().sanitized(); + assert_eq!((back.left_width, back.right_width), (lw, rw)); + // limits: the photo area stays usable however far the edges are dragged… + let right = widget(&h, "panel:right_panel"); + drag(&mut h, right.left() + 2.0, -1400.0); + let left = widget(&h, "panel:left_panel"); + drag(&mut h, left.right() - 2.0, 1400.0); + h.step(); + let (left, right) = (widget(&h, "panel:left_panel"), widget(&h, "panel:right_panel")); + assert!(right.width() <= RIGHT_WIDTH.max && left.width() <= LEFT_WIDTH.max, "{left:?} {right:?}"); + assert!(right.left() - left.right() >= MIN_PHOTO_WIDTH - 1.0, "photo area {} wide", right.left() - left.right()); + // …and neither panel gets narrower than its minimum + drag(&mut h, right.left() + 2.0, 1400.0); + let left = widget(&h, "panel:left_panel"); + drag(&mut h, left.right() - 2.0, -1400.0); + assert_eq!((h.app.ui.left_width, h.app.ui.right_width), (LEFT_WIDTH.min, RIGHT_WIDTH.min)); + // out-of-range saved widths are clamped on load + let mut u = crate::UiState { left_width: 5.0, right_width: f32::NAN, ..Default::default() }.sanitized(); + assert_eq!((u.left_width, u.right_width), (LEFT_WIDTH.min, RIGHT_WIDTH.default)); + u.right_width = 9000.0; + assert_eq!(u.sanitized().right_width, RIGHT_WIDTH.max); +} + +#[test] +fn a_narrow_window_shrinks_the_panels_without_forgetting_their_width() { + let mut h = demo([1400.0, 900.0], json!({"view": "detail", "right": "edit", "leftPanel": true, "rightWidth": 480.0, "leftWidth": 400.0})); + assert_eq!(widget(&h, "panel:right_panel").width(), 480.0); + h.request("ui.resize", json!({"width": 1000.0, "height": 800.0}), T); + h.settle(SETTLE); + let (left, right) = (widget(&h, "panel:left_panel"), widget(&h, "panel:right_panel")); + assert!(right.left() - left.right() >= MIN_PHOTO_WIDTH - 1.0, "{left:?} {right:?}"); + // the chosen widths come back when the window does + assert_eq!((h.app.ui.left_width, h.app.ui.right_width), (400.0, 480.0)); + h.request("ui.resize", json!({"width": 1400.0, "height": 900.0}), T); + h.settle(SETTLE); + assert_eq!(widget(&h, "panel:right_panel").width(), 480.0); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index c3f0b322..65e9c3ef 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -47,7 +47,6 @@ pub struct Tokens { pub bottom_bar_h: f32, pub panel_w: f32, pub strip_w: f32, - pub left_w: f32, pub slider_row_h: f32, pub section_h: f32, pub film_h: f32, @@ -88,7 +87,6 @@ impl Default for Tokens { bottom_bar_h: 48.0, panel_w: 270.0, strip_w: 48.0, - left_w: 268.0, slider_row_h: 45.0, section_h: 57.0, film_h: 142.0, diff --git a/docs/parity.md b/docs/parity.md index a33caa46..55cf2f73 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 14 | 1 | 2 | 1 | 8/8 (100%) | 5/5 (100%) | +| X. Cross-cutting behaviours (BEHAV) | 15 | 1 | 2 | 1 | 8/8 (100%) | 6/6 (100%) | | Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | | Lightroom Classic extras | 29 | 9 | 41 | 9 | — | 20/21 (95%) | -| **Total** | 383 | 25 | 92 | 37 | 194/198 (98%) | 136/144 (94%) | +| **Total** | 384 | 25 | 92 | 37 | 194/198 (98%) | 137/145 (94%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.1%** of 500 in-scope rows — P0 99.0% of 198 · P1 96.2% of 144 · P2 38.6% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.1%** of 501 in-scope rows — P0 99.0% of 198 · P1 96.2% of 145 · P2 38.6% of 158. ## Top gaps @@ -482,6 +482,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback | | LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging) | | LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | | +| LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`resizable_side`), `crates/ui-egui/src/state.rs` (`LEFT_WIDTH`, `RIGHT_WIDTH`), `crates/ui-egui/src/tests_panels.rs` | drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (`leftWidth` / `rightWidth` in the UI state); the Presets column stays fixed | | LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | | | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | | LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader |