This commit is contained in:
bflatastic 2026-10-13 12:28:31 +07:00
commit 5f44b7f4b3
5 changed files with 496 additions and 22 deletions

View File

@ -62,6 +62,8 @@ mod tests_date_picker;
#[cfg(test)]
mod tests_dialog_fit;
#[cfg(test)]
mod tests_dialog_settings;
#[cfg(test)]
mod tests_dialogs;
#[cfg(test)]
mod tests_filmstrip;

View File

@ -987,7 +987,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
Dialog::Merge { opts } => crate::merge::body(app, ui, opts),
Dialog::Import { opts } => crate::import::body(app, ui, opts),
Dialog::Settings { tab } => crate::panels::settings::body(app, ui, tab),
// (Settings is never taller than `body_max`, so the scroll area round it never
// scrolls: its tab bar stays put and its pages scroll by themselves)
Dialog::Settings { tab } => crate::panels::settings::window(app, ui, tab, body_max),
Dialog::SamModel { error, .. } => sam_model_body(app, ui, error.as_deref()),
Dialog::KeywordTag {
editing,

View File

@ -33,21 +33,10 @@ const CACHE_SIZES: [u32; 5] = [512, 1024, 2048, 4096, 8192];
const LABEL_W: f32 = 150.0;
/// The dialog body for `tab` (the tab bar switches `tab`).
pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) {
/// The page of `tab` (the tab bar in [`window`] switches `tab`).
pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &str) {
let t = Tokens::get(ui.ctx());
ui.set_width((ui.ctx().content_rect().width() - 64.0).clamp(240.0, 560.0));
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
for (id, label) in TABS {
if crate::widgets::text_button(ui, &format!("settingsTab-{id}"), label, tab == id).clicked() {
*tab = id.to_string();
}
}
});
ui.separator();
ui.add_space(2.0);
match tab.as_str() {
match tab {
"import" => import_tab(app, ui, &t),
"performance" => performance_tab(app, ui, &t),
"display" => display_tab(app, ui, &t),
@ -58,6 +47,95 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) {
}
}
/// The widest the Settings window's content gets, and the tallest its pages get: the window is
/// one size on every tab, so it stays where it is as the tabs are gone through.
const PAGE_SIZE: egui::Vec2 = egui::vec2(560.0, 640.0);
/// The least room in which a page scrolls by itself. In a window with less (far below the
/// desktop minimum), the tabs and the page scroll together in the dialog's own scroll area.
const PAGE_MIN_HEIGHT: f32 = 120.0;
/// The Settings window's content: the tab bar, which stays put, above the page of `tab`
/// ([`body`]), which scrolls when it is taller than the room for it, with a scroll handle that
/// shows without the pointer on it. `max_height` is the room the app window leaves for both: the
/// two together never take more, so the scroll area every dialog is drawn in has nothing to
/// scroll (but for a window with less than [`PAGE_MIN_HEIGHT`] for the page, where that area
/// scrolls the two together). Every page gets the same room whatever it holds, and each keeps a
/// scroll position of its own. Nothing here is wider than the window from 640 pt of app window
/// up: a row's controls wrap ([`row`]) and paths are shortened ([`path_label`]).
pub fn window(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String, max_height: f32) {
ui.set_width((ui.ctx().content_rect().width() - 64.0).clamp(240.0, PAGE_SIZE.x));
let top = ui.cursor().top();
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(4.0, 4.0);
for (id, label) in TABS {
if crate::widgets::text_button(ui, &format!("settingsTab-{id}"), label, tab == id).clicked() {
*tab = id.to_string();
}
}
});
ui.separator();
// (a point less than the room left, so rounding never makes the dialog's own scroll area scroll)
let room = max_height - (ui.cursor().top() - top) - 1.0;
if room < PAGE_MIN_HEIGHT {
body(app, ui, tab.as_str());
register(ui.ctx(), "settings:page", ui.min_rect());
return;
}
let height = room.min(PAGE_SIZE.y);
// A page that goes on below shows its scroll handle all the time: without it, a page a line
// or two too long looks cut off, not scrollable.
ui.spacing_mut().scroll.dormant_handle_opacity = 0.6;
let page = egui::ScrollArea::vertical()
.id_salt(("settings-page", tab.as_str()))
.max_height(height)
.min_scrolled_height(height)
.auto_shrink([false, false])
.show(ui, |ui| {
ui.add_space(2.0);
body(app, ui, tab.as_str());
// (what the page took: wider than the room for it would mean a control is cut off)
register(ui.ctx(), "settings:page", ui.min_rect());
});
register(ui.ctx(), "settings:pageRoom", page.inner_rect);
}
/// `path` shortened to fit `max` (as `width` measures it) by dropping leading folders, after
/// either kind of separator, so that its end stays (`…/Pictures/Library`,
/// `…\Pictures\Library`): the end says which folder it is. A last folder too long by itself
/// loses its end ([`crate::widgets::elide_head`]).
fn elide_path(path: &str, max: f32, width: impl Fn(&str) -> f32) -> String {
if width(path) <= max {
return path.to_string();
}
// (a separator at the very end is not where a name starts: `…/` alone would say nothing)
let end = path.trim_end_matches(['/', '\\']).len();
let mut rest = path;
while let Some(i) = rest.find(['/', '\\']).filter(|i| path.len() - rest.len() + i + 1 < end) {
let separator = if rest.get(i..).is_some_and(|r| r.starts_with('\\')) { '\\' } else { '/' };
rest = rest.get(i + 1..).unwrap_or("");
let shorter = format!("…{separator}{rest}");
if width(&shorter) <= max {
return shorter;
}
}
let name = rest.trim_end_matches(['/', '\\']);
// (nothing but separators: never an empty label)
if name.is_empty() { "…".to_string() } else { crate::widgets::elide_head(name, max, width) }
}
/// A folder or file path on one line in the room left in its row (`text:{id}`): shortened from
/// the front when it is too long ([`elide_path`]), with all of it in a tooltip then.
fn path_label(ui: &mut egui::Ui, id: &str, path: &str, font: egui::FontId, color: egui::Color32) {
let shown = elide_path(path, ui.available_width(), |s| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x);
// (`truncate` is the last resort, for a room narrower than "…")
let r = ui.add(egui::Label::new(RichText::new(&shown).font(font).color(color)).truncate().show_tooltip_when_elided(false));
register(ui.ctx(), format!("text:{id}"), r.rect);
if shown != path {
r.on_hover_text(path);
}
}
pub(super) fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) {
ui.add_space(4.0);
ui.label(RichText::new(crate::i18n::tr(text)).font(t.semibold(12.5)).color(t.text));
@ -67,9 +145,17 @@ fn row<R>(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(LABEL_W);
ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_label));
// (a label longer than its column wraps in it; it does not push the controls along)
ui.add(egui::Label::new(RichText::new(crate::i18n::tr(label)).color(t.text_label)).wrap());
});
add(ui)
// The controls wrap in the room beside the label, each further line starting under the
// first control, so a row with many choices grows down and never widens the window.
let wrapped = egui::Layout::left_to_right(egui::Align::Center).with_main_wrap(true);
ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 24.0), wrapped, |ui| {
ui.spacing_mut().item_spacing.y = 4.0;
add(ui)
})
.inner
})
.inner
}
@ -112,7 +198,7 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
None => crate::i18n::tr("In memory — nothing is saved").to_string(),
};
row(ui, t, crate::i18n::tr("Location"), |ui| {
ui.label(RichText::new(location).color(t.text));
path_label(ui, "settingsLocation", &location, egui::TextStyle::Body.resolve(ui.style()), t.text);
});
row(ui, t, "", |ui| {
let can = app.services.pick_folder.is_some();
@ -370,7 +456,10 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
heading(ui, t, crate::i18n::tr("Auto Import"));
hint(ui, t, crate::i18n::tr("Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete."));
row(ui, t, crate::i18n::tr("Watched folder"), |ui| {
ui.label(RichText::new(d.auto_folder.clone().unwrap_or_else(|| crate::i18n::tr("Off").into())).color(t.text));
match &d.auto_folder {
Some(folder) => path_label(ui, "settingsAutoFolder", folder, egui::TextStyle::Body.resolve(ui.style()), t.text),
None => drop(ui.label(RichText::new(crate::i18n::tr("Off")).color(t.text))),
}
let can = app.services.pick_folder.is_some();
let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Choose…")));
register(ui.ctx(), "button:settingsAutoFolder", r.rect);
@ -510,8 +599,10 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
let available = loc["available"].as_bool().unwrap_or(true);
let custom = loc["custom"].as_bool().unwrap_or(false);
row(ui, t, crate::i18n::tr("Folder"), |ui| {
let text = format!("{path}{}", if custom { "" } else { " (library default)" });
ui.add(egui::Label::new(RichText::new(text.clone()).color(if available { t.text } else { t.reject })).truncate()).on_hover_text(text);
path_label(ui, "settingsSmartFolder", &path, egui::TextStyle::Body.resolve(ui.style()), if available { t.text } else { t.reject });
if !custom {
ui.label(RichText::new("(library default)").color(t.text));
}
});
let (count, bytes) = (loc["count"].as_u64().unwrap_or(0), loc["bytes"].as_u64().unwrap_or(0));
if available {
@ -627,7 +718,7 @@ fn display_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
let info = d.describe();
if !screen {
row(ui, t, crate::i18n::tr("File"), |ui| {
ui.label(RichText::new(&d.path).size(11.0).color(t.text_dim));
path_label(ui, "settingsDisplayFile", &d.path, egui::FontId::proportional(11.0), t.text_dim);
});
}
row(ui, t, crate::i18n::tr("Type"), |ui| {
@ -1140,3 +1231,48 @@ pub fn display_profile(app: &mut LightcraftApp, p: &Value) -> Result<Value, Stri
None => json!({"path": null, "description": "sRGB (no display profile)"}),
})
}
#[cfg(test)]
mod path_tests {
use super::elide_path;
/// Width = characters, so a limit of 20 is "20 characters".
fn fit(path: &str, max: usize) -> String {
elide_path(path, max as f32, |s| s.chars().count() as f32)
}
#[test]
fn a_path_that_fits_is_left_alone() {
assert_eq!(fit("/Users/me/Pictures", 18), "/Users/me/Pictures");
assert_eq!(fit("", 5), "");
}
#[test]
fn leading_folders_go_first_and_the_end_stays() {
assert_eq!(fit("/Users/me/Pictures/Library", 20), "…/Pictures/Library");
assert_eq!(fit("/Users/me/Pictures/Library", 10), "…/Library");
}
/// Windows paths: `Path::display` gives backslashes there.
#[test]
fn a_path_with_backslashes_keeps_its_end_too() {
assert_eq!(fit(r"C:\Users\me\Pictures\Library", 20), r"…\Pictures\Library");
assert_eq!(fit(r"C:\Users\me/Pictures\Library", 10), r"…\Library");
}
#[test]
fn a_last_folder_too_long_by_itself_loses_its_end() {
assert_eq!(fit("/Users/me/a-very-long-folder-name", 8), "a-very-…");
assert_eq!(fit("no-separator-at-all", 8), "no-sepa…");
assert_eq!(fit("/Users/Фотографии-из-отпуска", 8), "Фотогра…");
}
#[test]
fn a_separator_at_the_end_is_not_where_the_name_starts() {
assert_eq!(fit("/Users/me/Pictures/Library/", 11), "…/Library/");
assert_eq!(fit("/Users/me/a-very-long-folder-name/", 8), "a-very-…");
assert_eq!(fit(r"C:\Users\me\Library\", 11), r"…\Library\");
assert_eq!(fit("///", 2), "…");
assert_eq!(fit(r"\\server\share\folder", 15), r"…\share\folder");
}
}

View File

@ -0,0 +1,323 @@
//! The Settings window is one size in one place whatever tab is open, and lies inside the app
//! window: a row with more choices than fit wraps, a label longer than its column wraps, a long
//! folder path is cut short (its tooltip has all of it), and the tabs stay put while a page scrolls.
//!
//! Before, a wide row widened the whole window: General was 970 points wide and the other tabs
//! about 600, the window moved up and down between tabs, and in a 900 point wide app window the
//! row labels and the Close button of General were cut off at the left.
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::panels::settings::TABS;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
/// The app window sizes tried: roomy, the desktop minimum, and smaller than that.
const SIZES: [[f32; 2]; 3] = [[1600.0, 1000.0], [900.0, 560.0], [640.0, 480.0]];
fn headless(session: lightcraft_engine::Session, size: [f32; 2]) -> Headless {
let mut h = Headless::new(LightcraftApp::new(session, Services::default()), size, 1.0);
// let the first renders finish (a render still running at exit can crash in GPU teardown)
h.settle(T);
h
}
fn demo(size: [f32; 2]) -> Headless {
headless(lightcraft_engine::Session::with_demo(), size)
}
/// Given Settings open on `tab`, drawn until its layout has settled.
fn show(h: &mut Headless, tab: &str) {
let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": tab}}), T);
assert_eq!(r["ok"], true, "app.settings {tab}: {r}");
h.settle(T);
for _ in 0..4 {
h.step();
}
}
/// A folder under the system's temporary one, removed again when the test ends, passed or not.
struct TempFolder(std::path::PathBuf);
impl TempFolder {
fn new(tag: &str) -> Self {
let dir = std::env::temp_dir().join(format!("lc-ui-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
Self(dir)
}
}
impl Drop for TempFolder {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn rect(h: &Headless, id: &str) -> egui::Rect {
h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}"))
}
/// The controls the Settings window registers on the tab that is open (and its tab buttons).
fn controls(h: &Headless) -> Vec<(String, egui::Rect)> {
let named = |id: &str| id.split_once(':').is_some_and(|(_, name)| name.starts_with("settings") || name.starts_with("theme"));
h.app.widgets.iter().filter(|(id, _)| named(id)).cloned().collect()
}
fn same(a: egui::Rect, b: egui::Rect) -> bool {
(a.min - b.min).abs().max_elem() < 0.5 && (a.max - b.max).abs().max_elem() < 0.5
}
#[test]
fn the_window_keeps_its_size_and_place_from_tab_to_tab() {
for size in SIZES {
let mut h = demo(size);
// Given the first tab, when every other tab is opened in turn
show(&mut h, "general");
let first = rect(&h, "dialog:window");
for (tab, _) in TABS {
show(&mut h, tab);
// Then the window has not moved or changed size
let now = rect(&h, "dialog:window");
assert!(same(first, now), "{size:?}: Settings is {now:?} on {tab}, and was {first:?} on general");
}
// And going back to the first tab finds it as it was
show(&mut h, "general");
assert!(same(first, rect(&h, "dialog:window")), "{size:?}: general again");
}
}
#[test]
fn every_tab_keeps_its_controls_inside_the_window() {
for size in SIZES {
let mut h = demo(size);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size);
for (tab, _) in TABS {
// When a tab is opened
show(&mut h, tab);
// Then the Settings window lies inside the app window
let window = rect(&h, "dialog:window");
assert!(screen.contains_rect(window), "{size:?} {tab}: Settings {window:?} runs off the {screen:?} window");
// And no control reaches past its left or right edge
// (the page is everything the tab drew, whether or not it has a name of its own; a
// control past the room for it would be clipped there, inside the window's margin)
let (page, room) = (rect(&h, "settings:page"), rect(&h, "settings:pageRoom"));
assert!(
page.left() >= room.left() - 0.5 && page.right() <= room.right() + 0.5,
"{size:?} {tab}: the page {page:?} is wider than the room for it {room:?}"
);
assert!(window.contains_rect(room), "{size:?} {tab}: the page's room {room:?} is inside Settings {window:?}");
for (id, r) in controls(&h) {
let within = if id.contains("settingsTab-") { window } else { room };
assert!(r.left() >= within.left() - 0.5 && r.right() <= within.right() + 0.5, "{size:?} {tab}: {id} {r:?} is outside {within:?}");
}
}
}
}
#[test]
fn a_row_of_many_choices_wraps_under_itself() {
// Given an app window too narrow for the languages in one line
let mut h = demo([900.0, 560.0]);
show(&mut h, "general");
let languages: Vec<egui::Rect> = (0..crate::i18n::Locale::ALL.len()).map(|i| rect(&h, &format!("button:settingsLanguage-{i}"))).collect();
// Then they take more than one line
let first = languages[0];
assert!(languages.iter().any(|r| r.top() > first.bottom()), "the languages wrap: {languages:?}");
// And each further line starts under the first choice, not under the row's label
for r in languages.iter().filter(|r| r.top() > first.bottom()) {
assert!(r.left() >= first.left() - 0.5, "{r:?} starts left of the first language {first:?}");
}
assert!(
languages.iter().filter(|r| r.top() > first.bottom()).any(|r| (r.left() - first.left()).abs() < 0.5),
"a wrapped line starts at the first choice"
);
}
#[test]
fn a_label_longer_than_its_column_leaves_the_controls_in_line() {
// Given Performance, whose "Forget unchanged photos" label is wider than the label column
let mut h = demo([1600.0, 1000.0]);
show(&mut h, "performance");
// Then its choices start where the choices of a short label do
let (short, long) = (rect(&h, "button:settingsPreview-0"), rect(&h, "button:settingsForgetLocal-0"));
assert!((short.left() - long.left()).abs() < 0.5, "Preview size starts at {} and Forget unchanged photos at {}", short.left(), long.left());
}
#[test]
fn a_long_library_folder_is_cut_short_and_its_tooltip_has_all_of_it() {
// Given a library in a folder with a very long path
let base = TempFolder::new("settings-window");
let dir =
base.0.join("photographs-from-2019-to-2026-kept-on-the-second-external-drive-that-nobody-wants-to-type-twice").join("the-master-library");
std::fs::create_dir_all(&dir).unwrap();
let mut session = lightcraft_engine::Session::new().with_fs();
session.open_library(&dir, true).unwrap();
let path = dir.display().to_string();
let mut h = headless(session, [1600.0, 1000.0]);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size);
// When Settings opens on General, which shows where the library is
show(&mut h, "general");
let general = rect(&h, "dialog:window");
show(&mut h, "display");
// Then the window is as wide as on a tab without a path
assert!(same(general, rect(&h, "dialog:window")), "General is {general:?} with a long path, Display is {:?}", rect(&h, "dialog:window"));
show(&mut h, "general");
assert!(screen.contains_rect(general), "Settings {general:?} runs off the {screen:?} window");
// And the path is cut short, inside the window
let location = rect(&h, "text:settingsLocation");
let room = rect(&h, "settings:pageRoom");
assert!(location.right() <= room.right() + 0.5, "the path {location:?} stays inside the room for the page {room:?}");
let texts = h.painted_text();
assert!(!texts.iter().any(|text| text.contains(&path)), "the whole path is not drawn in the row");
// And what is left of it is its end, which says which library this is
let name = "the-master-library";
assert!(path.ends_with(name));
assert!(
texts.iter().any(|text| text.starts_with('…') && text.ends_with(name)),
"the row keeps the end of the path: {:?}",
texts.iter().filter(|t| t.contains("library")).collect::<Vec<_>>()
);
// And resting the pointer on it shows the whole path
assert_eq!(h.request("ui.hoverWidget", json!({"id": "text:settingsLocation"}), T)["ok"], true);
assert!(h.step_until(T, |h| h.painted_text().iter().any(|text| text.contains(&path))), "the tooltip has the whole path");
// And the same holds for the folder Auto Import watches, which stays on one line
let watched = h.app.run("library.autoImport", json!({"folder": path}));
assert!(watched.is_ok(), "{watched:?}");
show(&mut h, "import");
// (the row is at the foot of the page: scrolled to, so that it is drawn however long the page is)
let window = rect(&h, "dialog:window");
h.request("ui.move", json!({"x": window.center().x, "y": window.center().y}), T);
for _ in 0..6 {
h.request("ui.scroll", json!({"dy": -400.0}), T);
h.step();
}
h.settle(T);
let (folder, room) = (rect(&h, "text:settingsAutoFolder"), rect(&h, "settings:pageRoom"));
assert!(room.intersects(folder), "the watched folder {folder:?} is in view in {room:?}");
assert!(folder.height() < 24.0 && folder.right() <= room.right(), "the watched folder {folder:?} is one line inside {room:?}");
assert!(h.painted_text().iter().any(|text| text.starts_with('…') && text.ends_with(name)), "and keeps its end");
drop(h);
}
#[test]
fn a_short_path_is_shown_whole_and_has_no_tooltip() {
// Given the in-memory library, whose Location is a short sentence
let mut h = demo([1600.0, 1000.0]);
show(&mut h, "general");
assert!(h.painted_text().iter().any(|text| text == "In memory — nothing is saved"), "it is all there");
let before = h.painted_text().len();
// When the pointer rests on it
assert_eq!(h.request("ui.hoverWidget", json!({"id": "text:settingsLocation"}), T)["ok"], true);
let _ = h.step_until(Duration::from_secs(2), |h| h.painted_text().len() > before);
// Then nothing more is drawn: there is nothing a tooltip could add
assert_eq!(h.painted_text().len(), before, "no tooltip for a path that is all there");
}
/// How many pixels of `rect` are not `colour` (one pixel per point here).
fn other_than(image: &egui::ColorImage, rect: egui::Rect, colour: egui::Color32) -> usize {
let [w, h] = image.size;
let (x0, x1) = (rect.left().round().max(0.0) as usize, (rect.right().round().max(0.0) as usize).min(w));
let (y0, y1) = (rect.top().round().max(0.0) as usize, (rect.bottom().round().max(0.0) as usize).min(h));
(y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|(x, y)| image.pixels[y * w + x] != colour).count()
}
#[test]
fn a_page_that_goes_on_below_shows_a_scroll_handle_without_the_pointer_on_it() {
let mut h = demo([900.0, 560.0]);
let chrome = crate::theme::Tokens::get(&h.view.ctx).chrome;
// (the pointer is nowhere near the window)
h.request("ui.move", json!({"x": 5.0, "y": 5.0}), T);
let handle = |h: &Headless| {
let room = rect(h, "settings:pageRoom");
other_than(&h.paint(), egui::Rect::from_min_max(egui::pos2(room.right() - 6.0, room.top()), room.max), chrome)
};
// Given Import, which is taller than the room a 560 pt tall app window has for it
show(&mut h, "import");
assert!(rect(&h, "settings:page").height() > rect(&h, "settings:pageRoom").height());
// Then a handle is drawn at the page's right edge
assert!(handle(&h) > 40, "Import shows a scroll handle ({} px)", handle(&h));
// And Display, which fits, has none
show(&mut h, "display");
assert!(rect(&h, "settings:page").height() <= rect(&h, "settings:pageRoom").height());
assert_eq!(handle(&h), 0, "Display has nothing to scroll");
}
#[test]
fn a_window_too_short_for_a_page_of_its_own_scrolls_tabs_and_page_together() {
// Given an app window far below the desktop minimum
let mut h = demo([900.0, 220.0]);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size);
show(&mut h, "import");
let window = rect(&h, "dialog:window");
assert!(screen.contains_rect(window), "Settings {window:?} is inside the {screen:?} window");
let (tab, page) = (rect(&h, "button:settingsTab-import"), rect(&h, "check:settings.perCamera"));
// When it is scrolled
h.request("ui.move", json!({"x": window.center().x, "y": window.center().y}), T);
for _ in 0..3 {
h.request("ui.scroll", json!({"dy": -120.0}), T);
h.step();
}
h.settle(T);
// Then the tabs and the page move as one, so every setting can still be reached
let (tab_now, page_now) = (rect(&h, "button:settingsTab-import"), rect(&h, "check:settings.perCamera"));
assert!(page_now.top() < page.top() - 50.0, "the page scrolled: {page:?} -> {page_now:?}");
assert!(((tab.top() - tab_now.top()) - (page.top() - page_now.top())).abs() < 0.5, "tabs and page moved together");
assert!(same(window, rect(&h, "dialog:window")), "the window did not move");
}
#[test]
fn the_tabs_stay_put_while_a_page_scrolls() {
// Given a page taller than the window has room for
let mut h = demo([900.0, 560.0]);
show(&mut h, "import");
let window = rect(&h, "dialog:window");
let tabs: Vec<egui::Rect> = TABS.iter().map(|(id, _)| rect(&h, &format!("button:settingsTab-{id}"))).collect();
let page_before = rect(&h, "check:settings.perCamera");
// When the page is scrolled
h.request("ui.move", json!({"x": window.center().x, "y": window.center().y}), T);
for _ in 0..3 {
h.request("ui.scroll", json!({"dy": -120.0}), T);
h.step();
}
h.settle(T);
// Then the page moved
let page_after = rect(&h, "check:settings.perCamera");
assert!(page_after.top() < page_before.top() - 50.0, "the page scrolled: {page_before:?} -> {page_after:?}");
// And the tabs and the window did not
for ((id, _), before) in TABS.iter().zip(tabs) {
assert!(same(before, rect(&h, &format!("button:settingsTab-{id}"))), "tab {id} moved");
}
assert!(same(window, rect(&h, "dialog:window")), "the window moved");
}
#[test]
fn each_tab_remembers_how_far_it_was_scrolled() {
let mut h = demo([900.0, 560.0]);
show(&mut h, "interface");
let fresh = rect(&h, "button:settingsAppearance-0");
// Given Import scrolled down
show(&mut h, "import");
let window = rect(&h, "dialog:window");
let top = rect(&h, "check:settings.perCamera").top();
h.request("ui.move", json!({"x": window.center().x, "y": window.center().y}), T);
for _ in 0..3 {
h.request("ui.scroll", json!({"dy": -120.0}), T);
h.step();
}
h.settle(T);
let scrolled = rect(&h, "check:settings.perCamera").top();
assert!(scrolled < top - 50.0);
// When another tab is opened, it starts at its top
show(&mut h, "interface");
let mode = rect(&h, "button:settingsAppearance-0");
assert!(same(fresh, mode), "Interface is at its top, as when first opened: {fresh:?} -> {mode:?}");
// And Import is where it was left
show(&mut h, "import");
assert!((rect(&h, "check:settings.perCamera").top() - scrolled).abs() < 0.5, "Import is where it was left");
}

View File

@ -129,6 +129,17 @@
scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos
imported earlier switch over with Photo ▸ Reload from Disk.
### Settings window
- Settings is one size on every tab and stays where it is as you go through them; before, each tab made its own
size and the tabs moved under the pointer. The tabs stay in place while a long page scrolls, a page that goes on
below shows a scroll handle without the pointer on it, and each page remembers where it was scrolled to.
- Nothing in Settings runs off the side any more. A row with more choices than fit (the languages on General) wraps
onto further lines and a long label wraps in its column, where both used to widen the window: in a small
LightCraft window the labels and the Close button of General ended up off screen.
- A long folder (General ▸ Library ▸ Location, the folder Auto Import watches, the smart previews folder, a monitor
profile's file) is shortened to its last folders ("…/Pictures/Library") on one line; rest the pointer on it for
the whole path.
### Switching libraries
- File ▸ New Library… asks for a name, then where to put it, and opens the new library (issue #902).
- File ▸ Open Recent Library lists the last 8 libraries; ones that are gone (moved, drive disconnected) are greyed