Merge pull request from storytold/claude/integrate-batch-32 (batch 32a)

Integration batch 32a: 8 reviewed contributor PRs
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-13 00:27:00 -04:00
commit b709180587
19 files changed
+326 -50

No files matched your search

+43 -14
View File
@@ -303,16 +303,16 @@ fn mcp(args: &[String]) -> Result<(), String> {
while i < args.len() {
match args[i].as_str() {
"--connect" => {
// Optional address argument.
match args.get(i + 1).filter(|a| !a.starts_with("--") && a.contains(':')) {
Some(a) => {
connect = Some(a.clone());
i += 1;
}
None => connect = Some(DEFAULT_ADDR.to_string()),
let (addr, took) = connect_value(args.get(i + 1).map(String::as_str));
connect = Some(addr);
if took {
i += 1;
}
}
a if a.starts_with("--connect=") => connect = Some(a["--connect=".len()..].to_string()),
a if a.starts_with("--connect=") => {
let (addr, _) = connect_value(Some(&a["--connect=".len()..]));
connect = Some(addr);
}
"--headless" => connect = None,
"--demo" => demo = true,
"--compact" => compact = true,
@@ -525,14 +525,17 @@ fn run(args: &[String]) -> Result<(), String> {
i += 1;
break;
}
"--connect" => match args.get(i + 1).filter(|a| a.contains(':') && !a.contains('=')) {
Some(a) => {
connect = Some(a.clone());
"--connect" => {
let (addr, took) = connect_value(args.get(i + 1).map(String::as_str));
connect = Some(addr);
if took {
i += 1;
}
None => connect = Some(DEFAULT_ADDR.to_string()),
},
a if a.starts_with("--connect=") => connect = Some(a["--connect=".len()..].to_string()),
}
a if a.starts_with("--connect=") => {
let (addr, _) = connect_value(Some(&a["--connect=".len()..]));
connect = Some(addr);
}
"--demo" => demo = true,
"--library" => library = Some(take_value(args, &mut i, "--library")?.to_string()),
"--import" => imports.push(take_value(args, &mut i, "--import")?.to_string()),
@@ -627,6 +630,18 @@ fn take_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a s
/// The port an `--connect` address names, for the recovery hint. The whole address when it has
/// no port, so a hint is never built from a guess.
/// `--connect` takes an address, or a bare port (`7980` → `127.0.0.1:7980`). A following
/// command id is left alone (#1178).
fn connect_value(next: Option<&str>) -> (String, bool) {
match next {
Some(a) if !a.starts_with('-') && a.contains(':') && !a.contains('=') => (a.to_string(), true),
Some(a) if !a.is_empty() && a.bytes().all(|c| c.is_ascii_digit()) && a.parse::<u16>().ok().is_some_and(|p| p > 0) => {
(format!("127.0.0.1:{a}"), true)
}
_ => (DEFAULT_ADDR.to_string(), false),
}
}
fn connect_port(addr: &str) -> &str {
addr.rsplit_once(':').map_or(addr, |(_, port)| port)
}
@@ -965,6 +980,20 @@ fn denoise_serve(args: &[String]) -> Result<(), String> {
serve(listener, s.model, s.tile, s.device, s.run, opts, std::sync::Arc::new(|m: &str| eprintln!("lightcraft-cli denoise-serve: {m}")))
}
#[cfg(test)]
mod connect_tests {
use super::connect_value;
#[test]
fn a_bare_port_is_loopback_and_a_command_is_not_consumed() {
assert_eq!(connect_value(Some("7980")), ("127.0.0.1:7980".into(), true));
assert_eq!(connect_value(Some("127.0.0.1:7980")), ("127.0.0.1:7980".into(), true));
assert_eq!(connect_value(Some("photo.rate")), ("127.0.0.1:7980".into(), false));
assert_eq!(connect_value(Some("0")), ("127.0.0.1:7980".into(), false));
assert_eq!(connect_value(None), ("127.0.0.1:7980".into(), false));
}
}
#[cfg(not(feature = "denoise"))]
fn denoise_serve(_: &[String]) -> Result<(), String> {
Err("denoise-serve: this build has no denoise support (build with the `denoise` feature)".into())
+5
View File
@@ -27,3 +27,8 @@ sync_github_name = true
[people.sanyamsingh001]
display_name = "sanyamsingh001"
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
sync_github_name = false
+52 -4
View File
@@ -245,11 +245,25 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R
/// Convert a decode result to linear Rec.2020 D65 (Bradford-adapted), the develop working space.
pub fn to_working(d: &Decoded) -> Rgb32f {
if d.space.is_working() {
return d.image.clone();
let mut image = if d.space.is_working() {
d.image.clone()
} else {
let m = d.space.to_working().to_f32();
d.image.map(|p| mat_apply(&m, p))
};
// Straight alpha onto white. JPEG and contact sheets have no alpha, and a transparent
// pixel is usually stored as black (#1212).
if let Some(alpha) = &d.alpha {
let w = image.width;
lightcraft_raster::par_rows(&mut image.data, w, |y, row| {
let a = alpha.row(y);
for (p, &alpha) in row.iter_mut().zip(a) {
let a = alpha.clamp(0.0, 1.0);
*p = p.map(|c| c * a + (1.0 - a));
}
});
}
let m = d.space.to_working().to_f32();
d.image.map(|p| mat_apply(&m, p))
image
}
/// Where a thumbnail came from.
@@ -323,3 +337,37 @@ pub fn decode_thumbnail_with(bytes: &[u8], opts: &ThumbnailOptions) -> Result<Th
source_height: d.source_height,
})
}
#[cfg(test)]
mod alpha_tests {
use super::*;
use lightcraft_raster::Plane;
#[test]
fn straight_alpha_composites_onto_white_in_the_working_space() {
let image = Rgb32f::filled(2, 1, [0.0, 0.0, 0.0]);
let alpha = Plane::from_fn(2, 1, |x, _| if x == 0 { 0.0 } else { 0.5 });
let d = Decoded {
format: Format::Png,
image,
alpha: Some(alpha),
space: SourceSpace::named(NamedSpace::Rec2020, SpaceOrigin::Container),
bit_depth: 8,
float: false,
has_alpha: true,
grayscale: false,
cmyk: false,
orientation: 1,
exif: None,
xmp: None,
icc: None,
width: 2,
height: 1,
source_width: 2,
source_height: 1,
};
let w = to_working(&d);
assert!((w.get(0, 0)[0] - 1.0).abs() < 1e-5, "a fully transparent black pixel becomes white");
assert!((w.get(1, 0)[0] - 0.5).abs() < 1e-5);
}
}
+24 -1
View File
@@ -13,7 +13,7 @@ use crate::backend::{Backend, ProgressHook};
/// File extensions recognised as photos when expanding folders.
pub const PHOTO_EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd", "jxl",
"gif", "bmp", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
"gif", "bmp", "avif", "heic", "heif", "nrw", // avif is preview-only; heic/heif/nrw match the engine list (#1080)
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff",
];
@@ -193,6 +193,29 @@ pub fn expand_paths(paths: &[String]) -> Vec<String> {
out
}
#[cfg(test)]
mod expand_tests {
use super::expand_paths;
#[test]
fn a_folder_import_includes_heic_heif_and_nrw() {
let dir = std::env::temp_dir().join(format!("lightcraft-ext-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for name in ["a.heic", "b.HEIF", "c.nrw", "d.png", "note.txt"] {
std::fs::write(dir.join(name), b"x").unwrap();
}
let got = expand_paths(&[dir.to_string_lossy().to_string()]);
let names: Vec<String> = got.iter().map(|p| std::path::Path::new(p).file_name().unwrap().to_string_lossy().to_lowercase()).collect();
assert!(names.contains(&"a.heic".into()), "{names:?}");
assert!(names.contains(&"b.heif".into()), "{names:?}");
assert!(names.contains(&"c.nrw".into()), "{names:?}");
assert!(names.contains(&"d.png".into()), "{names:?}");
assert!(!names.iter().any(|n| n == "note.txt"), "{names:?}");
let _ = std::fs::remove_dir_all(&dir);
}
}
/// Encode by extension: `.png` (default), `.jpg`/`.jpeg` (quality), `.tif`/`.tiff`, `.webp` (lossless),
/// `.avif`, `.jxl` — via the shared export encoder (embeds an sRGB profile).
pub fn encode_image(ext: &str, img: &Rgba8, quality: u8) -> Result<Vec<u8>, String> {
+3 -2
View File
@@ -247,8 +247,9 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh
let flat: Vec<f32> = tgt.iter().flatten().copied().collect();
let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default())
.unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole));
for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) {
*d = [v[0], v[1], v[2]];
let (pixels, _) = out.as_chunks::<3>();
for (d, v) in src.iter_mut().zip(pixels) {
*d = *v;
}
}
+1
View File
@@ -703,6 +703,7 @@
"In memory — nothing is saved": "Im Arbeitsspeicher — nichts wird gespeichert",
"In use": "In Verwendung",
"In-memory session: these settings last until LightCraft quits.": "Sitzung im Arbeitsspeicher: Diese Einstellungen gelten bis zum Beenden von LightCraft.",
"Include selected photos": "Ausgewählte Fotos einschließen",
"Include subfolders": "Unterordner einbeziehen",
"Increase Brush Feather": "Weiche Pinselkante vergrößern",
"Increase Brush Size": "Pinselgröße vergrößern",
+1 -1
View File
@@ -1678,7 +1678,7 @@ mod tests {
}
assert_eq!(h.app.ui.dialog, None, "Return confirmed the dialog ({typed})");
};
confirm(&mut h, Dialog::NewAlbum { name: String::new(), folder: false, parent: None }, "Weddings");
confirm(&mut h, Dialog::NewAlbum { name: String::new(), folder: false, parent: None, include_selected: true }, "Weddings");
assert!(album(&h, "Weddings").is_some(), "New Album");
confirm(&mut h, Dialog::RenameAlbum { id: garden, name: "Garden".into() }, "Flowers");
assert_eq!(album(&h, "Flowers"), Some(garden), "Rename Album");
+2
View File
@@ -114,6 +114,8 @@ mod tests_sync;
#[cfg(test)]
mod tests_titlebar;
#[cfg(test)]
mod tests_toast;
#[cfg(test)]
mod tests_unsaved;
#[cfg(test)]
mod tests_wb_selector;
+13 -4
View File
@@ -1069,13 +1069,22 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(Value::Null)
}
"dialog.newFolder" => {
app.ui.dialog =
Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: true, parent: parent_param(p) });
app.ui.dialog = Some(Dialog::NewAlbum {
name: p.get("name").and_then(Value::as_str).unwrap_or("").into(),
folder: true,
parent: parent_param(p),
include_selected: false,
});
Ok(Value::Null)
}
"dialog.newAlbum" => {
app.ui.dialog =
Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: false, parent: parent_param(p) });
// {name?, parent?, includeSelected? (true)}
app.ui.dialog = Some(Dialog::NewAlbum {
name: p.get("name").and_then(Value::as_str).unwrap_or("").into(),
folder: false,
parent: parent_param(p),
include_selected: p.get("includeSelected").and_then(Value::as_bool).unwrap_or(true),
});
Ok(Value::Null)
}
"dialog.autoStack" => {
+1 -1
View File
@@ -2145,7 +2145,7 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
// a vertical mouse wheel scrolls the strip sideways (horizontal trackpad scrolls still work)
child.style_mut().always_scroll_the_only_direction = true;
// only a new active photo (or the strip appearing) scrolls it; see `grid::follow_active`
let follow = super::grid::follow_active(child.ctx(), egui::Id::new("film-follow-active"), active);
let follow = super::grid::follow_active(child.ctx(), egui::Id::new("film-follow-active"), active, app.session.source);
egui::ScrollArea::horizontal().id_salt("filmstrip").auto_shrink([false, false]).show_viewport(&mut child, |ui, vp| {
let (area, _) = ui.allocate_exact_size(vec2(ids.len() as f32 * cell_w, r.height() - 16.0), Sense::hover());
app.film_scroll = Some(vp.left());
+14 -3
View File
@@ -608,7 +608,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
confirm = true;
}
}
Dialog::NewAlbum { name, .. } | Dialog::RenameAlbum { name, .. } => {
Dialog::NewAlbum { name, folder, include_selected, .. } => {
if dialog_field(ui, "field:albumName", name, "Name") {
confirm = true;
}
// as in Lightroom, the option is only there when something is selected
if !*folder && !app.session.targets(&json!({})).is_empty() {
ui.add_space(6.0);
let r = ui.checkbox(include_selected, crate::i18n::tr("Include selected photos"));
crate::widgets::register(ui.ctx(), "check:albumIncludeSelected", r.rect);
}
}
Dialog::RenameAlbum { name, .. } => {
if dialog_field(ui, "field:albumName", name, "Name") {
confirm = true;
}
@@ -1520,8 +1531,8 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
}
r
}
Dialog::NewAlbum { name, folder, parent } => {
created_in(app, "album.create", json!({"name": name, "folder": folder, "addSelected": !folder, "parent": parent}))
Dialog::NewAlbum { name, folder, parent, include_selected } => {
created_in(app, "album.create", json!({"name": name, "folder": folder, "addSelected": !folder && *include_selected, "parent": parent}))
}
Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})),
Dialog::TextPrompt { value, command, params, key, .. } => {
+27 -5
View File
@@ -229,7 +229,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let active = app.session.selection.active;
// bring the active photo into view when it changes (keyboard, click, command) or the grid
// comes back on screen; otherwise the scroll position is the user's
let scroll_to: Option<Rect> = if follow_active(ui.ctx(), egui::Id::new("grid-follow-active"), active) {
let scroll_to: Option<Rect> = if follow_active(ui.ctx(), egui::Id::new("grid-follow-active"), active, app.session.source) {
active.and_then(|a| ids.iter().position(|x| *x == a)).and_then(|i| lay.cells.get(i).copied())
} else {
None
@@ -295,16 +295,29 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
/// the strip was not drawn on the previous pass (view switch, panel shown again). Otherwise the
/// scroll position is the user's: time passing, finished renders, edits to the photo or a released
/// scroll bar never move it (issue #11).
pub(crate) fn follow_active(ctx: &egui::Context, key: egui::Id, active: Option<PhotoId>) -> bool {
pub(crate) fn follow_active(ctx: &egui::Context, key: egui::Id, active: Option<PhotoId>, source: lightcraft_engine::LibrarySource) -> bool {
let pass = ctx.cumulative_pass_nr();
let last: Option<(Option<PhotoId>, u64)> = ctx.data(|d| d.get_temp(key));
ctx.data_mut(|d| d.insert_temp(key, (active, pass)));
let last: Option<(Option<PhotoId>, lightcraft_engine::LibrarySource, u64)> = ctx.data(|d| d.get_temp(key));
ctx.data_mut(|d| d.insert_temp(key, (active, source, pass)));
match last {
Some((was, drawn)) => was != active || drawn + 1 < pass,
Some((was, where_, drawn)) => follow_changed(was, where_, drawn, active, source, pass),
None => true,
}
}
/// Scroll when the active photo changes, the strip was not drawn last pass, or the same photo
/// sits in a different library source (another album) (#1142).
pub(crate) fn follow_changed(
was: Option<PhotoId>,
where_was: lightcraft_engine::LibrarySource,
drawn: u64,
active: Option<PhotoId>,
source: lightcraft_engine::LibrarySource,
pass: u64,
) -> bool {
was != active || where_was != source || drawn + 1 < pass
}
/// Height of a date header row (points).
pub const HEADER_H: f32 = 40.0;
@@ -1146,6 +1159,15 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
mod tests {
use super::*;
#[test]
fn the_same_photo_in_another_source_scrolls_into_view() {
let id = PhotoId(3);
let all = lightcraft_engine::LibrarySource::All;
let album = lightcraft_engine::LibrarySource::Album(lightcraft_catalog::AlbumId(9));
assert!(!follow_changed(Some(id), all, 4, Some(id), all, 5));
assert!(follow_changed(Some(id), all, 4, Some(id), album, 5));
}
#[test]
fn ungrouped_justified_rows_fill_the_width() {
let aspects = [1.5f32, 1.5, 0.66, 1.0, 1.5, 1.5, 1.5];
+2 -2
View File
@@ -533,7 +533,7 @@ fn sections_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, on: Option<SidebarS
/// The menu of Albums' +: Create Album, Smart Album, Folder…
fn new_album_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if ui.button(crate::i18n::tr("Create Album…")).clicked() {
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false, parent: None });
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false, parent: None, include_selected: true });
}
if ui.button(crate::i18n::tr("Create Smart Album…")).clicked() {
app.ui.dialog = Some(crate::state::Dialog::SmartRules {
@@ -547,7 +547,7 @@ fn new_album_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new(), parent: None });
}
if ui.button(crate::i18n::tr("Create Folder…")).clicked() {
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true, parent: None });
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true, parent: None, include_selected: false });
}
// only once the albums were put in an order by hand
if app.session.catalog.album_children_are_ordered(None) {
+20 -8
View File
@@ -69,6 +69,9 @@ pub fn resizable_side(
}
/// The HUD toast at the bottom centre of the canvas.
///
/// Drawn as widgets rather than a painted galley so the message can be selected and copied: an
/// error here often carries a path or a command the user wants to paste somewhere else.
pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);
let Some((text, until, label)) = app.ui.toast.clone() else { return };
@@ -79,16 +82,25 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(canvas) = app.canvas_rect else { return };
let t = Tokens::get(ctx);
let fade = ((until - now) / 0.25).clamp(0.0, 1.0) as f32;
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("toast")));
let (background, foreground) = label.map(crate::theme::label_toast_colors).unwrap_or((egui::Color32::from_black_alpha(200), t.text));
let background = background.gamma_multiply(fade);
let foreground = foreground.gamma_multiply(fade);
// long messages (a save warning, the web build's notices) wrap within the canvas
let wrap = (canvas.width() - 80.0).clamp(200.0, 720.0);
let (background, foreground) = label.map(crate::theme::label_toast_colors).unwrap_or((egui::Color32::from_black_alpha(200), t.text));
let foreground = foreground.gamma_multiply(fade);
let galley = painter.layout(text, t.font(14.0), foreground, wrap);
let size = vec2(galley.size().x + 40.0, (galley.size().y + 22.0).max(40.0));
let r = Rect::from_center_size(pos2(canvas.center().x, canvas.bottom() - 60.0), size);
painter.rect_filled(r, 6.0, background.gamma_multiply(fade));
painter.galley(r.center() - galley.size() / 2.0, galley, foreground);
egui::Area::new(egui::Id::new("toast"))
.order(egui::Order::Foreground)
.movable(false)
.pivot(Align2::CENTER_CENTER)
.fixed_pos(pos2(canvas.center().x, canvas.bottom() - 60.0))
.show(ctx, |ui| {
ui.set_max_width(wrap);
egui::Frame::NONE.fill(background).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::symmetric(20, 11)).show(
ui,
|ui| {
ui.add(egui::Label::new(egui::RichText::new(text).font(t.font(14.0)).color(foreground)).wrap());
},
);
});
ctx.request_repaint_after(std::time::Duration::from_millis(30));
}
+5 -2
View File
@@ -11,7 +11,9 @@ use lightcraft_engine::pipeline::PixelWindow;
/// Windows start and end on multiples of this many pixels of the zoomed frame.
pub const SNAP: usize = 256;
/// The most context rendered beyond the visible pixels (a multiple of [`SNAP`]).
pub const MAX_MARGIN: usize = 768;
/// 4.5 % of an 11:1 view of a 24 MP frame, snapped to [`SNAP`]. The old 768 px cap left Dehaze
/// tens of codes off the full-frame render from 4:1 up (#1146).
pub const MAX_MARGIN: usize = 3072;
/// The most a window may span on either axis: a bound on its texture whatever the window size.
pub const MAX_SPAN: usize = if cfg!(target_arch = "wasm32") { 3072 } else { 6144 };
@@ -484,7 +486,8 @@ mod tests {
fn the_margin_grows_with_the_zoom_up_to_a_cap() {
assert_eq!(margin_for(1000), SNAP, "never less than one grid step");
assert!(margin_for(8000) > margin_for(3000));
assert_eq!(margin_for(48_000), MAX_MARGIN);
assert_eq!(margin_for(48_000), 2304, "4.5 % of an 8:1 frame, under the cap");
assert_eq!(margin_for(80_000), MAX_MARGIN);
assert_eq!(margin_for(0), SNAP);
assert!(margin_for(usize::MAX) <= MAX_MARGIN);
for l in [100, 3000, 8000, 48_000, 400_000] {
+4 -1
View File
@@ -766,12 +766,15 @@ pub enum Dialog {
ContactSheet {
options: lightcraft_engine::contact_sheet::Options,
},
/// `parent`: the folder to create it in (none: the top level).
/// `parent`: the folder to create it in (none: the top level). `include_selected`: the new album
/// holds the selected photos (Lightroom's "Include selected photos"; folders hold none).
NewAlbum {
name: String,
folder: bool,
#[serde(default)]
parent: Option<u64>,
#[serde(default = "yes")]
include_selected: bool,
},
RenameAlbum {
id: u64,
+50 -1
View File
@@ -2129,7 +2129,7 @@ fn right_click_add_to_album_offers_a_new_album_when_there_is_none() {
h.step();
h.step();
match &mut h.app.ui.dialog {
Some(crate::state::Dialog::NewAlbum { name, folder: false, parent: None }) => *name = "Trip".into(),
Some(crate::state::Dialog::NewAlbum { name, folder: false, parent: None, .. }) => *name = "Trip".into(),
other => panic!("New Album dialog expected, got {other:?}"),
}
h.step();
@@ -2141,3 +2141,52 @@ fn right_click_add_to_album_offers_a_new_album_when_there_is_none() {
assert!(trip.photos.contains(&photo), "the right-clicked photo is in it: {:?}", trip.photos);
assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog);
}
/// New Album's "Include selected photos" (as in Lightroom): on by default; unticked, the new album
/// starts empty; with nothing selected the option isn't shown, and a folder never has it.
#[test]
fn new_album_can_leave_the_selected_photos_out() {
let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true}));
let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id);
let photo = lightcraft_catalog::PhotoId(h.app.session.visible_cloned()[1].0);
h.app.session.execute("library.select", &json!({"ids": [photo.0]})).unwrap();
let create = |h: &mut Headless, command: &str, name: &str| {
h.app.run(command, json!({"name": name})).unwrap();
h.step();
h.step();
};
let confirm = |h: &mut Headless| {
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
h.step();
assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog);
};
let photos = |h: &Headless, name: &str| h.app.session.catalog.albums().find(|a| a.name == name).expect("the album was created").photos.clone();
// ticked by default: the selection goes in, as before
create(&mut h, "dialog.newAlbum", "With");
assert!(has(&h, "check:albumIncludeSelected"), "the option is offered with a selection");
confirm(&mut h);
assert_eq!(photos(&h, "With"), vec![photo]);
// unticked: an empty album
create(&mut h, "dialog.newAlbum", "Without");
let r = h.request("ui.clickWidget", json!({"id": "check:albumIncludeSelected"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
confirm(&mut h);
assert!(photos(&h, "Without").is_empty(), "nothing was added");
// a folder never offers it
create(&mut h, "dialog.newFolder", "Folder");
assert!(!has(&h, "check:albumIncludeSelected"), "folders hold no photos");
confirm(&mut h);
// nothing selected: no option, an empty album
h.app.session.selection = lightcraft_engine::Selection { ids: vec![], active: None };
create(&mut h, "dialog.newAlbum", "Nothing");
assert!(!has(&h, "check:albumIncludeSelected"), "no selection, no option");
confirm(&mut h);
assert!(photos(&h, "Nothing").is_empty());
}
+58
View File
@@ -0,0 +1,58 @@
//! The toast is drawn as widgets, not as paint. That is what makes its message selectable: a toast
//! often carries a path or a command the user wants somewhere else, and painted text cannot be
//! picked up by the pointer at all.
use std::time::Duration;
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
fn demo() -> Headless {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
// let the first renders finish: a render still running when the test binary exits can crash in the
// GPU driver's teardown (the preview pool doesn't join its workers)
h.settle(T);
h
}
/// One event per frame: [`LightcraftApp::raw_input_hook`] takes pointer events singly.
fn send(h: &mut Headless, e: egui::Event) {
h.app.synthetic.push(e);
h.step();
}
fn show_toast(h: &mut Headless, msg: &str) -> egui::Rect {
h.app.ui.toast = Some((msg.to_owned(), f64::MAX, None));
h.step();
h.step();
h.view.ctx.memory(|m| m.area_rect(egui::Id::new("toast"))).expect("the toast is laid out as a widget, not paint")
}
#[test]
fn the_toast_is_a_widget_the_pointer_reaches() {
let mut h = demo();
let msg = "Exported frame to /tmp/scene-0001.png";
let area = show_toast(&mut h, msg);
assert!(area.is_positive(), "the toast has a rect of its own: {area:?}");
assert!(h.painted_text().iter().any(|t| t.contains("Exported frame")), "the message is still painted");
// The pointer reaches the message: egui offers the text cursor over it. Painted text is never
// hit-tested, so this is the difference that makes selection possible at all.
send(&mut h, egui::Event::PointerMoved(area.center()));
assert_eq!(h.last_cursor, egui::CursorIcon::Text, "the toast text is a text target");
}
#[test]
fn the_toast_still_wraps_a_long_message_inside_the_canvas() {
let mut h = demo();
let msg = "The SAM 3 download failed: the file is 4.1 GB and the connection dropped at 98 %, so nothing was installed and the masks are still unavailable";
let area = show_toast(&mut h, msg);
let canvas = h.app.canvas_rect.expect("the demo has a canvas");
assert!(area.width() <= canvas.width() - 80.0, "the toast wraps within the canvas: {area:?}");
assert!(h.painted_text().iter().any(|t| t.contains("SAM 3 download failed")), "the whole message is painted");
}
+1 -1
View File
@@ -94,7 +94,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-LIB-ALLPHOTOS | All photos | P0 | ✅ | `cmd:library.source`, `crates/ui-egui/src/panels/left.rs` | |
| LR-LIB-RECENT-ADDED | Recently added | P1 | ✅ | `cmd:library.source` (`recentlyAdded`), `crates/engine/src/view.rs` (`RECENT_DAYS`) | photos imported in the 30 days up to the latest import, newest import first, grouped by import day |
| LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters |
| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum`, `crates/engine/src/tests_albums_local.rs`, `crates/ui-egui/src/tests_panels.rs` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP); albums hold library photos (issue #684): `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B) skip browsed Local photos and return `skipped` (a toast says so), a Local-only selection is an error and leaves Photo ▸ Add to Album, the grid's right-click Add to Album, a folder row's Add Selected Photos and a drop onto an album row off with the hint to Add to My Photos first (`button:contextAddToAlbum`, `button:contextAlbum:<id>`); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; `button:contextNewAlbum`) |
| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum`, `crates/engine/src/tests_albums_local.rs`, `crates/ui-egui/src/tests_panels.rs` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP); albums hold library photos (issue #684): `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B) skip browsed Local photos and return `skipped` (a toast says so), a Local-only selection is an error and leaves Photo ▸ Add to Album, the grid's right-click Add to Album, a folder row's Add Selected Photos and a drop onto an album row off with the hint to Add to My Photos first (`button:contextAddToAlbum`, `button:contextAlbum:<id>`); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; `button:contextNewAlbum`); New Album's "Include selected photos" (`check:albumIncludeSelected`, on by default; `dialog.newAlbum {includeSelected}`) leaves the selection out when unticked, and, as in Lightroom, is only shown when something is selected (never for a folder) |
| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:album.reorder`, `cmd:album.sort`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | Delete removes a folder and its nested albums as one undoable edit, keeps photos in the library and returns a deleted album view to All Photos (issue #501); folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:<id>`; the row folds them too; open by default); right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; the "Albums" header takes an item that sits in a folder back to the top level while it is dragged), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; drop on the top / bottom half of an album or the edge of a folder to place it there by hand (`cmd:album.reorder`, a line shows the place; ordered per folder, folders stay first, new albums go after the ordered ones; right-click ▸ Sort Contents A–Z or + ▸ Sort Albums A–Z = `cmd:album.sort`) |
| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album) or a rule set from the rule editor (match all / any / none, nested groups; see LRC-LIB-SMARTCOLL), live; sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists |
| LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service |