UI: align Local folder rows, paint dock gaps, bottom-align the tool strip

- Local folders in the sidebar are drawn at their tree indent like Albums and
  Folders: the icon lines up with the rows above and the disclosure triangle
  sits in the gutter left of it; children nest by 16 pt (#1097).
- The central panel that hosts the dock is filled with chrome, so the gaps the
  dock leaves between panes no longer show the dark clear colour (#1098).
- The tool strip's bottom group is two buttons and one gap tall, so Info ends at
  the bottom margin as Presets starts at the top one (#1099).

Each fix has a headless regression test that fails without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Matthew James Sivak
2026-10-13 09:00:28 +02:00
co-authored by Claude Opus 5.5
parent b3d392b499
commit aeb21ac813
4 changed files with 88 additions and 12 deletions
+3 -1
View File
@@ -1322,7 +1322,9 @@ impl LightcraftApp {
panels::topbar::show(self, ui);
panels::library_problem::banner(self, ui);
panels::strip::show(self, ui);
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| docking::show(self, ui));
// filled: the gaps the dock leaves between its panes show chrome, not the dark clear colour
let chrome = theme::Tokens::get(&ctx).chrome;
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(chrome)).show(ui, |ui| docking::show(self, ui));
self.lights_out(&ctx);
panels::second::show(self, &ctx);
panels::notices::show(self, &ctx);
+29 -3
View File
@@ -1059,7 +1059,9 @@ fn folder_tree(
ui.data_mut(|d| d.insert_temp(revealed_id, target));
}
}
let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent + 12.0).on_hover_text(path);
// 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 tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("folderToggle:{path}"));
if !tr.clicked()
&& resp.clicked()
@@ -1112,9 +1114,9 @@ fn folder_tree(
let _ = f(path);
}
});
if open && indent < 12.0 * 8.0 {
if open && indent < 16.0 * 8.0 {
for (n, p) in subfolders(ui, path) {
folder_tree(app, ui, place, &n, &p, indent + 12.0, current, reveal, false);
folder_tree(app, ui, place, &n, &p, indent + 16.0, current, reveal, false);
}
}
}
@@ -2093,4 +2095,28 @@ mod tests {
let l = local_places(builtin(), &[], Some("/home/example/Pictures/Trip"), None, &["/home/example".into()]);
assert_eq!((names(&l.places), l.owner), (vec!["Pictures"], Some(0)));
}
#[test]
fn a_local_folder_lines_up_with_the_rows_above_and_its_triangle_stays_left_of_the_icon() {
use crate::Services;
use crate::headless::HeadlessView;
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::new(), Services::default());
let mut view = HeadlessView::new();
let path = "/lightcraft-test/Pictures";
let mut widgets = vec![];
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::folder_tree(&mut app, ui, path, "Pictures", path, 0.0, None, false, false);
});
widgets = crate::widgets::take_registry(&view.ctx);
}
let rect = |id: &str| widgets.iter().find(|(w, _)| w == id).unwrap_or_else(|| panic!("{id} not registered")).1;
let (all, local) = (rect("label:all"), rect(&format!("label:local:{path}")));
assert_eq!(local.left(), all.left(), "a top-level Local folder's name starts where All Photos' does");
let triangle = rect(&format!("folderToggle:{path}"));
// the icon is 16 points wide and ends 8 points before the name
assert!(triangle.right() <= local.left() - 24.0, "triangle {triangle:?} overlaps the icon before {local:?}");
}
}
+40 -8
View File
@@ -9,6 +9,11 @@ use crate::state::RightPanel;
use crate::theme::Tokens;
use crate::widgets::icon_button;
/// Height of one tool button.
const BUTTON_H: f32 = 40.0;
/// Space between tool buttons.
const GAP: f32 = 6.0;
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::right("tool_strip")
@@ -23,8 +28,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
.show(ui, |ui| {
let has_photo = app.session.active().is_some();
ui.vertical_centered(|ui| {
ui.spacing_mut().item_spacing.y = 6.0;
let sz = vec2(t.strip_w, 40.0);
ui.spacing_mut().item_spacing.y = GAP;
let sz = vec2(t.strip_w, BUTTON_H);
if icon_button(ui, "presets", Icon::Presets, sz, app.ui.presets, has_photo, "Presets (Shift+P)").clicked() {
let _ = app.run("panel.presets", json!({}));
}
@@ -54,14 +59,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
app.ui.dialog = Some(crate::state::Dialog::About);
}
});
// bottom icons
// bottom icons: two buttons and one gap tall, so Info ends at the bottom margin as
// Presets starts at the top one
let bottom = ui.max_rect().bottom();
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(egui::Rect::from_min_max(egui::pos2(ui.max_rect().left(), bottom - 96.0), ui.max_rect().right_bottom())),
);
let mut child =
ui.new_child(egui::UiBuilder::new().max_rect(egui::Rect::from_min_max(
egui::pos2(ui.max_rect().left(), bottom - (2.0 * BUTTON_H + GAP)),
ui.max_rect().right_bottom(),
)));
child.vertical_centered(|ui| {
let sz = vec2(t.strip_w, 40.0);
ui.spacing_mut().item_spacing.y = GAP;
let sz = vec2(t.strip_w, BUTTON_H);
if icon_button(ui, "keywords", Icon::Tag, sz, app.ui.right == RightPanel::Keywords, has_photo, "Keywords (K)").clicked() {
let _ = app.run("panel.keywords", json!({}));
}
@@ -76,3 +84,27 @@ fn separator(ui: &mut egui::Ui, t: &Tokens) {
let (r, _) = ui.allocate_exact_size(vec2(t.strip_w - 16.0, 1.0), egui::Sense::hover());
ui.painter().rect_filled(r, 0.0, t.button_border.gamma_multiply(0.7));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Services;
use crate::headless::HeadlessView;
#[test]
fn info_ends_as_far_above_the_bottom_as_presets_starts_below_the_top() {
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default());
let mut view = HeadlessView::new();
let mut widgets = vec![];
for frame in 0..3 {
let raw = HeadlessView::raw_input(vec2(300.0, 700.0), 1.0, frame as f64 / 60.0, vec![]);
view.run(raw, |ui| show(&mut app, ui));
widgets = crate::widgets::take_registry(&view.ctx);
}
let rect = |id: &str| widgets.iter().find(|(w, _)| w == id).unwrap_or_else(|| panic!("{id} not registered")).1;
let (presets, keywords, info) = (rect("icon:presets"), rect("icon:keywords"), rect("icon:info"));
let (top, bottom) = (presets.top(), 700.0 - info.bottom());
assert!((top - bottom).abs() < 0.5, "Presets starts {top} below the top, Info ends {bottom} above the bottom");
assert_eq!(info.top() - keywords.bottom(), GAP, "the bottom buttons are spaced like the top ones");
}
}
@@ -21,6 +21,22 @@ fn dock(h: &mut Headless, p: serde_json::Value) {
fn rect(h: &Headless, id: &str) -> egui::Rect {
h.app.widgets.iter().find(|(key, _)| key == id).unwrap().1
}
#[test]
fn the_gaps_between_dock_panes_show_chrome_not_the_dark_clear_colour() {
let mut h = app();
let image = h.snapshot(std::time::Duration::from_secs(5));
let pixel = |p: egui::Pos2| image.pixels[p.y as usize * image.size[0] + p.x as usize];
// each panel's own chrome just inside its edge stroke, and the dock's gap just outside it
let right = rect(&h, "panel:right_panel");
let left = rect(&h, "panel:left_panel");
for (panel, inside, gap) in [
("Tools", egui::pos2(right.left() + 3.0, right.center().y), egui::pos2(right.left() - 2.0, right.center().y)),
("Library", egui::pos2(left.right() - 3.0, left.center().y), egui::pos2(left.right() + 2.0, left.center().y)),
] {
assert_eq!(pixel(gap), pixel(inside), "the gap beside {panel} at {gap:?} is not chrome");
}
}
fn release(h: &mut Headless, pos: egui::Pos2) {
h.app.synthetic.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();