Merge pull request from storytold/claude/integrate-batch-32 (batch 32a)
Integration batch 32a: 8 reviewed contributor PRs
This commit is contained in:
19 files changed
+326
-50
No files matched your search
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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, .. } => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user