Issue #20: Panels: resize the left sidebar and the right panel by dragging their edge

Both side panels had an exact width. They are now resizable egui panels
whose width lives in UiState (leftWidth / rightWidth, serde, sanitized on
load) so it survives panel switches, view changes and restarts; egui's
own remembered size is dropped each frame and only a drag of the edge
writes back (measured from the fixed edge to the pointer, so content
wider than the panel can't creep it). Limits: left 200–480 pt, right
250–520 pt, and the photo area keeps at least 360 pt; a narrower window
shrinks the panels without forgetting the chosen width. The bottom bar,
which sits between the panels, now drops Copy/Paste Edit Settings and
then the rating pill when its centre has no room instead of overlapping
the zoom controls. The unused left_w theme token is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-04 11:18:04 -04:00
parent fb8e1e93aa
commit d6476e7497
9 changed files with 459 additions and 279 deletions

View File

@ -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};

View File

@ -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) {

View File

@ -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<Album> = 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<Album> = 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

View File

@ -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<f32> {
let pid = egui::Id::new(id);
ui.ctx().data_mut(|d| d.remove::<egui::containers::panel::PanelState>(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 `<panel id>.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);

View File

@ -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) {

View File

@ -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;

View File

@ -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::<crate::UiState>(&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);
}

View File

@ -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,

View File

@ -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.
<!-- /parity:summary -->
## 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 |