Merge branch 'worktree-agent-aada35d252224c5cd'

# Conflicts:
#	crates/engine/src/media.rs
#	crates/ui-egui/src/state.rs
This commit is contained in:
Brandon Thomas 2026-10-01 11:11:49 -04:00
commit 59140d8265
44 changed files with 3352 additions and 151 deletions

39
Cargo.lock generated
View File

@ -853,6 +853,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
@ -1927,6 +1936,16 @@ dependencies = [
"tracing",
]
[[package]]
name = "keyboard-types"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
dependencies = [
"bitflags 2.13.2",
"serde",
]
[[package]]
name = "khronos-egl"
version = "6.0.0"
@ -2011,6 +2030,7 @@ dependencies = [
"lightcraft-raster",
"lightcraft-ui-egui",
"log",
"muda",
"rfd",
"serde_json",
]
@ -2411,6 +2431,25 @@ dependencies = [
"pxfm",
]
[[package]]
name = "muda"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c"
dependencies = [
"crossbeam-channel",
"dpi",
"keyboard-types",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"once_cell",
"png",
"thiserror 2.0.21",
"windows-sys 0.61.2",
]
[[package]]
name = "naga"
version = "30.0.1"

View File

@ -69,6 +69,7 @@ egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
muda = { version = "0.21", default-features = false } # native menu bar (macOS: objc2/AppKit, pure Rust; no GTK)
web-time = "1"
# web (apps/lightcraft-web)

View File

@ -21,3 +21,6 @@ log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
muda = { workspace = true }

View File

@ -13,14 +13,20 @@
//! See `lightcraft_ui_egui::control` for the methods.
mod control_server;
#[cfg(target_os = "macos")]
mod native_menu;
use lightcraft_engine::Session;
use lightcraft_ui_egui::{LightcraftApp, Services, UiState};
struct App(LightcraftApp);
struct App(LightcraftApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
if let Some(m) = self.1.as_mut() {
m.update(&mut self.0, ctx);
}
self.0.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
@ -234,7 +240,15 @@ fn main() -> eframe::Result {
let _ = app.run("library.import", serde_json::json!({"paths": files}));
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
}
Ok(Box::new(App(app)))
// native menu bar generated from the command registry (macOS; elsewhere the menus are
// drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus.
#[cfg(target_os = "macos")]
let menu = (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
Ok(Box::new(App(
app,
#[cfg(target_os = "macos")]
menu,
)))
}),
)
}

View File

@ -0,0 +1,392 @@
//! The native macOS menu bar (via `muda`, pure Rust over AppKit), generated from the same model as
//! the in-window menus ([`lightcraft_ui_egui::menubar`]): the registry's menu paths, shortcuts,
//! enabled and checked state, and live labels ("Undo Exposure").
//!
//! Key handling: a native key equivalent consumes the key press before egui sees it, so
//! - shortcuts installed natively are listed in `LightcraftApp::native_shortcuts` and skipped by
//! the egui shortcut handler (nothing fires twice);
//! - while a text field has keyboard focus, accelerators without ⌘ (`G`, `1`, `Delete`…) and the
//! text-editing ones (⌘A/⌘C/⌘V/⌘X/⌘Z) are removed, so typing and text editing work; they come
//! back when the field loses focus.
use std::collections::{HashMap, HashSet};
use std::sync::mpsc::{Receiver, channel};
use lightcraft_ui_egui::LightcraftApp;
use lightcraft_ui_egui::menubar::{MenuNode, menu_bar};
use muda::accelerator::{Accelerator, Code, Modifiers};
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use serde_json::Value;
const QUIT: &str = "app.quit";
/// Keys whose meaning depends on the panel (X = reject or swap crop aspect, ⌫ = delete the photo
/// or the active mask): left to the egui handler, which knows the context.
const CONTEXTUAL: &[&str] = &["X", "Delete"];
/// Shortcuts text fields need while they have focus.
const TEXT_EDIT: &[&str] = &["Cmd+A", "Cmd+C", "Cmd+V", "Cmd+X", "Cmd+Z", "Cmd+Shift+Z"];
enum Handle {
Plain(MenuItem),
Check(CheckMenuItem),
}
struct Item {
handle: Handle,
cmd: String,
params: Value,
shortcut: Option<String>,
accel: Option<Accelerator>,
label: String,
enabled: bool,
checked: Option<bool>,
}
pub struct NativeMenu {
menu: Menu,
items: HashMap<String, Item>,
rx: Receiver<String>,
/// Structure of the installed menu (ids, kinds, submenu names); a change rebuilds it.
structure: String,
text_focus: bool,
}
/// `Cmd+Shift+Z` → a muda accelerator (`None` for keys we leave to egui, e.g. Escape).
fn accelerator(sc: &str) -> Option<Accelerator> {
let mut mods = Modifiers::empty();
let mut key = None;
for part in sc.split('+') {
match part {
"Cmd" => mods |= Modifiers::META,
"Shift" => mods |= Modifiers::SHIFT,
"Alt" => mods |= Modifiers::ALT,
"Ctrl" => mods |= Modifiers::CONTROL,
k => key = Some(k),
}
}
let code = match key? {
k if k.len() == 1 && k.as_bytes()[0].is_ascii_alphabetic() => {
const LETTERS: [Code; 26] = [
Code::KeyA,
Code::KeyB,
Code::KeyC,
Code::KeyD,
Code::KeyE,
Code::KeyF,
Code::KeyG,
Code::KeyH,
Code::KeyI,
Code::KeyJ,
Code::KeyK,
Code::KeyL,
Code::KeyM,
Code::KeyN,
Code::KeyO,
Code::KeyP,
Code::KeyQ,
Code::KeyR,
Code::KeyS,
Code::KeyT,
Code::KeyU,
Code::KeyV,
Code::KeyW,
Code::KeyX,
Code::KeyY,
Code::KeyZ,
];
LETTERS[(k.as_bytes()[0].to_ascii_uppercase() - b'A') as usize]
}
k if k.len() == 1 && k.as_bytes()[0].is_ascii_digit() => {
const DIGITS: [Code; 10] = [
Code::Digit0,
Code::Digit1,
Code::Digit2,
Code::Digit3,
Code::Digit4,
Code::Digit5,
Code::Digit6,
Code::Digit7,
Code::Digit8,
Code::Digit9,
];
DIGITS[(k.as_bytes()[0] - b'0') as usize]
}
"[" => Code::BracketLeft,
"]" => Code::BracketRight,
"\\" => Code::Backslash,
"/" => Code::Slash,
"=" => Code::Equal,
"-" => Code::Minus,
"'" => Code::Quote,
"Delete" => Code::Backspace,
_ => return None,
};
Some(Accelerator::new(mods, code))
}
/// Accelerators that must step aside while a text field has focus.
fn yields_to_text(sc: &str) -> bool {
!sc.contains("Cmd") && !sc.contains("Ctrl") || TEXT_EDIT.contains(&sc)
}
/// `&` marks a mnemonic in muda labels.
fn label_text(s: &str) -> String {
s.replace('&', "&&")
}
fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
fn walk(n: &[MenuNode], out: &mut String) {
for x in n {
match x {
MenuNode::Item { id, params, checked, shortcut, .. } => {
out.push_str(&MenuNode::key(id, params));
out.push(if checked.is_some() { 'c' } else { 'i' });
out.push_str(shortcut.as_deref().unwrap_or(""));
}
MenuNode::Separator => out.push('-'),
MenuNode::Submenu { label, children } => {
out.push('[');
out.push_str(label);
walk(children, out);
out.push(']');
}
}
out.push(';');
}
}
let mut s = String::new();
for (t, items) in bar {
s.push_str(t);
walk(items, &mut s);
}
s
}
impl NativeMenu {
/// Build the menu bar and install it as the application menu (main thread, after launch).
pub fn install(app: &mut LightcraftApp, ctx: &egui::Context) -> NativeMenu {
let (tx, rx) = channel::<String>();
let repaint = ctx.clone();
MenuEvent::set_event_handler(Some(move |e: MenuEvent| {
let _ = tx.send(e.id.0);
repaint.request_repaint();
}));
let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false };
m.rebuild(app);
app.native_menu = true;
m
}
fn rebuild(&mut self, app: &mut LightcraftApp) {
let bar = menu_bar(app);
self.structure = structure_of(&bar);
self.menu = Menu::new();
self.items.clear();
// the application menu
let app_menu = Submenu::new("LightCraft", true);
let about = MenuItem::with_id("app.about", "About LightCraft", true, None);
let quit = MenuItem::with_id(QUIT, "Quit LightCraft", true, accelerator("Cmd+Q"));
let _ = app_menu.append_items(&[
&about,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::services(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::hide(Some("Hide LightCraft")),
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
&quit,
]);
let _ = self.menu.append(&app_menu);
for (title, nodes) in &bar {
let sub = Submenu::new(title, true);
if title == "Window" {
let _ = sub.append_items(&[
&PredefinedMenuItem::minimize(None),
&PredefinedMenuItem::maximize(Some("Zoom")),
&PredefinedMenuItem::separator(),
]);
}
// About lives in the app menu on macOS
let nodes: Vec<MenuNode> = nodes.iter().filter(|n| !matches!(n, MenuNode::Item { id, .. } if id == "app.about")).cloned().collect();
self.append_nodes(&sub, &nodes);
if title == "Window" {
let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]);
sub.set_as_windows_menu_for_nsapp();
}
if title == "Help" {
sub.set_as_help_menu_for_nsapp();
}
let _ = self.menu.append(&sub);
}
self.menu.init_for_nsapp();
self.text_focus = false;
self.publish_shortcuts(app);
}
fn append_nodes(&mut self, sub: &Submenu, nodes: &[MenuNode]) {
for n in nodes {
match n {
MenuNode::Separator => {
let _ = sub.append(&PredefinedMenuItem::separator());
}
MenuNode::Submenu { label, children } => {
let s = Submenu::new(label_text(label), true);
self.append_nodes(&s, children);
let _ = sub.append(&s);
}
MenuNode::Item { id, params, label, shortcut, enabled, checked } => {
let key = MenuNode::key(id, params);
if self.items.contains_key(&key) {
continue; // muda ids must be unique
}
let accel = shortcut.as_deref().filter(|s| !CONTEXTUAL.contains(s)).and_then(accelerator);
let handle = match checked {
Some(c) => {
let it = CheckMenuItem::with_id(key.clone(), label_text(label), *enabled, *c, accel);
let _ = sub.append(&it);
Handle::Check(it)
}
None => {
let it = MenuItem::with_id(key.clone(), label_text(label), *enabled, accel);
let _ = sub.append(&it);
Handle::Plain(it)
}
};
self.items.insert(
key,
Item {
handle,
cmd: id.clone(),
params: params.clone(),
shortcut: shortcut.clone(),
accel,
label: label.clone(),
enabled: *enabled,
checked: *checked,
},
);
}
}
}
}
/// Tell the egui shortcut handler which shortcuts the menu bar currently owns.
fn publish_shortcuts(&self, app: &mut LightcraftApp) {
app.native_shortcuts = self
.items
.values()
.filter(|i| i.accel.is_some())
.filter_map(|i| i.shortcut.clone())
.filter(|sc| !(self.text_focus && yields_to_text(sc)))
.collect::<HashSet<_>>();
}
/// Per frame: run chosen items, then sync labels / enabled / checked and the text-focus
/// accelerators with the app state.
pub fn update(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
while let Ok(key) = self.rx.try_recv() {
if key == QUIT {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
continue;
}
if key == "app.about" {
let _ = app.run("app.about", serde_json::json!({}));
continue;
}
if let Some(it) = self.items.get(&key) {
let (cmd, params) = (it.cmd.clone(), it.params.clone());
let _ = lightcraft_ui_egui::menubar::run_item(app, &cmd, params);
}
}
let bar = menu_bar(app);
if structure_of(&bar) != self.structure {
self.rebuild(app);
return;
}
fn walk(nodes: &[MenuNode], items: &mut HashMap<String, Item>) {
for n in nodes {
match n {
MenuNode::Submenu { children, .. } => walk(children, items),
MenuNode::Item { id, params, label, enabled, checked, .. } => {
let Some(it) = items.get_mut(&MenuNode::key(id, params)) else { continue };
if it.label != *label {
match &it.handle {
Handle::Plain(h) => h.set_text(label_text(label)),
Handle::Check(h) => h.set_text(label_text(label)),
}
it.label = label.clone();
}
if it.enabled != *enabled {
match &it.handle {
Handle::Plain(h) => h.set_enabled(*enabled),
Handle::Check(h) => h.set_enabled(*enabled),
}
it.enabled = *enabled;
}
if let (Handle::Check(h), Some(c)) = (&it.handle, checked) {
// also resyncs after AppKit toggled it on click
if h.is_checked() != *c {
h.set_checked(*c);
}
it.checked = Some(*c);
}
}
MenuNode::Separator => {}
}
}
}
walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::<Vec<_>>(), &mut self.items);
let focus = ctx.egui_wants_keyboard_input();
if focus != self.text_focus {
self.text_focus = focus;
for it in self.items.values() {
let Some(sc) = it.shortcut.as_deref() else { continue };
if it.accel.is_none() || !yields_to_text(sc) {
continue;
}
let accel = if focus { None } else { it.accel };
let _ = match &it.handle {
Handle::Plain(h) => h.set_accelerator(accel),
Handle::Check(h) => h.set_accelerator(accel),
};
}
self.publish_shortcuts(app);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shortcuts_map_to_accelerators() {
assert_eq!(accelerator("Cmd+Shift+Z"), Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ)));
assert_eq!(accelerator("G"), Some(Accelerator::new(Modifiers::empty(), Code::KeyG)));
assert_eq!(accelerator("Cmd+'"), Some(Accelerator::new(Modifiers::META, Code::Quote)));
assert_eq!(accelerator("Escape"), None);
assert!(yields_to_text("G") && yields_to_text("Delete") && yields_to_text("Cmd+V"));
assert!(!yields_to_text("Cmd+Shift+E"));
// every registry shortcut that a menu shows maps (or is deliberately left to egui)
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
fn all(n: &[MenuNode], out: &mut Vec<String>) {
for x in n {
match x {
MenuNode::Item { shortcut: Some(s), .. } => out.push(s.clone()),
MenuNode::Submenu { children, .. } => all(children, out),
_ => {}
}
}
}
let mut scs = Vec::new();
for (_, v) in menu_bar(&app) {
all(&v, &mut scs);
}
for sc in scs.iter().filter(|s| !CONTEXTUAL.contains(&s.as_str())) {
assert!(accelerator(sc).is_some(), "{sc}");
}
}
}

View File

@ -10,6 +10,7 @@
pub mod journal;
pub mod model;
pub mod query;
pub mod stacks;
pub mod store;
use std::collections::BTreeMap;
@ -28,6 +29,8 @@ pub enum CatalogError {
NoPhoto(PhotoId),
#[error("no such album {0:?}")]
NoAlbum(AlbumId),
#[error("no such stack {0:?}")]
NoStack(StackId),
#[error("invalid: {0}")]
Invalid(String),
#[error("corrupt catalog data: {0}")]
@ -113,6 +116,23 @@ pub enum Op {
id: AlbumId,
cover: Option<PhotoId>,
},
/// Replace a smart album's rules.
SetAlbumRules {
id: AlbumId,
rules: Box<Filter>,
},
AddStack {
stack: Stack,
},
RemoveStack {
id: StackId,
},
/// Replace a stack's members (first = top) and collapsed state.
SetStack {
id: StackId,
photos: Vec<PhotoId>,
collapsed: bool,
},
/// Several ops as one step (undo applies the inverses in reverse).
Batch {
ops: Vec<Op>,
@ -125,6 +145,10 @@ pub struct Catalog {
albums: BTreeMap<AlbumId, Album>,
next_photo: u64,
next_album: u64,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
stacks: BTreeMap<StackId, Stack>,
#[serde(default)]
next_stack: u64,
/// Increments on every applied op.
#[serde(skip)]
pub revision: u64,
@ -132,7 +156,7 @@ pub struct Catalog {
impl Catalog {
pub fn new() -> Catalog {
Catalog { next_photo: 1, next_album: 1, ..Default::default() }
Catalog { next_photo: 1, next_album: 1, next_stack: 1, ..Default::default() }
}
// ---- ids
@ -147,6 +171,11 @@ impl Catalog {
self.next_album = id.0 + 1;
id
}
pub fn alloc_stack_id(&mut self) -> StackId {
let id = StackId(self.next_stack.max(1));
self.next_stack = id.0 + 1;
id
}
// ---- reads
@ -168,8 +197,51 @@ impl Catalog {
pub fn albums(&self) -> impl Iterator<Item = &Album> {
self.albums.values()
}
/// Albums (regular and smart) that contain the photo.
pub fn albums_of(&self, id: PhotoId) -> Vec<AlbumId> {
self.albums.values().filter(|a| a.photos.contains(&id)).map(|a| a.id).collect()
let Some(p) = self.photos.get(&id) else { return Vec::new() };
self.albums.values().filter(|a| self.album_contains(a.id, p)).map(|a| a.id).collect()
}
/// Whether album `id` contains `p` (a smart album evaluates its rules; deleted photos are in
/// no smart album).
pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool {
match self.albums.get(&id) {
Some(Album { smart: Some(rules), .. }) => !p.deleted && rules.matches(p, self),
Some(a) => a.photos.contains(&p.id),
None => false,
}
}
/// The photos of an album: the stored list, or a smart album's current matches (id order).
pub fn album_photos(&self, id: AlbumId) -> Vec<PhotoId> {
match self.albums.get(&id) {
Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).map(|p| p.id).collect(),
Some(a) => a.photos.clone(),
None => Vec::new(),
}
}
/// Number of photos shown for an album in the sources list (excludes deleted photos).
pub fn album_count(&self, id: AlbumId) -> usize {
match self.albums.get(&id) {
Some(Album { smart: Some(_), .. }) => self.album_photos(id).len(),
Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(),
None => 0,
}
}
/// Smart-album rules must not reference another smart album (no recursion) or deleted photos.
fn validate_rules(&self, rules: &Filter) -> Result<()> {
if rules.deleted {
return Err(CatalogError::Invalid("smart album rules can't select deleted photos".into()));
}
if let Some(a) = rules.album
&& self.albums.get(&a).is_some_and(Album::is_smart)
{
return Err(CatalogError::Invalid("smart album rules can't reference another smart album".into()));
}
Ok(())
}
// ---- writes
@ -261,6 +333,12 @@ impl Catalog {
{
return Err(CatalogError::Invalid("parent must be an existing folder".into()));
}
if let Some(rules) = &album.smart {
if album.folder || !album.photos.is_empty() {
return Err(CatalogError::Invalid("a smart album holds rules, not photos".into()));
}
self.validate_rules(rules)?;
}
let id = album.id;
self.next_album = self.next_album.max(id.0 + 1);
self.albums.insert(id, album);
@ -296,8 +374,10 @@ impl Catalog {
}
Op::SetAlbumPhotos { id, photos } => {
let a = self.album_mut(id)?;
if a.folder && !photos.is_empty() {
return Err(CatalogError::Invalid("folders can't hold photos".into()));
if (a.folder || a.smart.is_some()) && !photos.is_empty() {
return Err(CatalogError::Invalid(
if a.folder { "folders can't hold photos" } else { "smart albums update automatically" }.into(),
));
}
Op::SetAlbumPhotos { id, photos: std::mem::replace(&mut a.photos, photos) }
}
@ -305,6 +385,38 @@ impl Catalog {
let a = self.album_mut(id)?;
Op::SetAlbumCover { id, cover: std::mem::replace(&mut a.cover, cover) }
}
Op::SetAlbumRules { id, rules } => {
self.validate_rules(&rules)?;
let a = self.album_mut(id)?;
let Some(old) = a.smart.as_mut() else {
return Err(CatalogError::Invalid("not a smart album".into()));
};
Op::SetAlbumRules { id, rules: std::mem::replace(old, rules) }
}
Op::AddStack { stack } => {
if self.stacks.contains_key(&stack.id) {
return Err(CatalogError::Invalid(format!("stack {:?} exists", stack.id)));
}
self.validate_stack(stack.id, &stack.photos)?;
let id = stack.id;
self.next_stack = self.next_stack.max(id.0 + 1);
self.stacks.insert(id, stack);
Op::RemoveStack { id }
}
Op::RemoveStack { id } => {
let s = self.stacks.remove(&id).ok_or(CatalogError::NoStack(id))?;
Op::AddStack { stack: s }
}
Op::SetStack { id, photos, collapsed } => {
if !self.stacks.contains_key(&id) {
return Err(CatalogError::NoStack(id));
}
self.validate_stack(id, &photos)?;
let s = self.stacks.get_mut(&id).ok_or(CatalogError::NoStack(id))?;
let old = Op::SetStack { id, photos: std::mem::replace(&mut s.photos, photos), collapsed: s.collapsed };
s.collapsed = collapsed;
old
}
Op::Batch { ops } => {
let mut inverses = Vec::with_capacity(ops.len());
for op in ops {
@ -325,7 +437,8 @@ impl Catalog {
})
}
/// Ops that remove a photo permanently including album memberships (one undoable batch).
/// Ops that remove a photo permanently including album and stack memberships (one undoable
/// batch).
pub fn delete_permanently_ops(&self, id: PhotoId) -> Op {
let mut ops: Vec<Op> = self
.albums
@ -333,6 +446,7 @@ impl Catalog {
.filter(|a| a.photos.contains(&id))
.map(|a| Op::SetAlbumPhotos { id: a.id, photos: a.photos.iter().copied().filter(|p| *p != id).collect() })
.collect();
ops.extend(self.remove_from_stacks_ops(&[id]));
ops.push(Op::RemovePhoto { id });
Op::Batch { ops }
}

View File

@ -13,6 +13,30 @@ pub struct PhotoId(pub u64);
#[serde(transparent)]
pub struct AlbumId(pub u64);
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct StackId(pub u64);
/// A stack: photos grouped under a top photo (bursts, brackets, merge results with their sources).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Stack {
pub id: StackId,
/// Members in stack order; `photos[0]` is the top of the stack. At least two.
pub photos: Vec<PhotoId>,
/// Collapsed stacks show only their top photo in the grid.
#[serde(default)]
pub collapsed: bool,
}
impl Stack {
pub fn top(&self) -> PhotoId {
self.photos[0]
}
pub fn position(&self, id: PhotoId) -> Option<usize> {
self.photos.iter().position(|p| *p == id)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MediaKind {
@ -150,6 +174,13 @@ pub struct Photo {
/// "Enable Profile Corrections" is on.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embedded_lens: Option<lightcraft_develop::EmbeddedLens>,
/// A virtual copy: the photo it was copied from (it shares that photo's file but has its own
/// settings, metadata and history).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub copy_of: Option<PhotoId>,
/// Virtual copies' name ("Copy 1").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub copy_name: Option<String>,
}
impl Photo {
@ -178,6 +209,8 @@ impl Photo {
as_shot_wb: None,
content_hash: None,
embedded_lens: None,
copy_of: None,
copy_name: None,
}
}
/// The develop settings import gives this photo (raws: as-shot white balance and the raw
@ -216,6 +249,20 @@ pub struct Album {
pub photos: Vec<PhotoId>,
#[serde(default)]
pub cover: Option<PhotoId>,
/// A smart album: its photos are every (non-deleted) photo matching these rules, evaluated
/// live; `photos` stays empty.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub smart: Option<Box<crate::Filter>>,
}
impl Album {
/// A regular (manual) album.
pub fn new(id: AlbumId, name: impl Into<String>) -> Album {
Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None }
}
pub fn is_smart(&self) -> bool {
self.smart.is_some()
}
}
#[cfg(test)]

View File

@ -33,6 +33,12 @@ pub struct Filter {
pub date: Option<String>,
pub keyword: Option<String>,
pub camera: Option<String>,
/// Lens (case-insensitive substring).
pub lens: Option<String>,
/// Capture date range, inclusive: `dateFrom` is compared as a lower bound (`2026-04-01`),
/// `dateTo` as a prefix upper bound (`2026-04` includes all of April).
pub date_from: Option<String>,
pub date_to: Option<String>,
/// Import date prefix (Recently Added).
pub imported: Option<String>,
}
@ -91,6 +97,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
"type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val),
"edited" => (val == "true" || val == "yes") == p.is_edited(),
"date" => p.date().starts_with(val),
"copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(),
"name" | "file" => p.file_name.to_lowercase().contains(val),
_ => false,
};
@ -100,6 +107,48 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
}
impl Filter {
/// Human-readable summary of the active rules (smart album tooltips, `album.list`).
pub fn describe(&self) -> String {
let mut v: Vec<String> = Vec::new();
if self.rating > 0 {
let op = match self.rating_op {
RatingOp::AtLeast => "≥",
RatingOp::Exactly => "=",
RatingOp::AtMost => "≤",
};
v.push(format!("rating {op} {}", self.rating));
}
if let Some(f) = self.flag {
v.push(format!("flag {}", format!("{f:?}").to_lowercase()));
}
if let Some(l) = self.label {
v.push(format!("label {}", format!("{l:?}").to_lowercase()));
}
if let Some(k) = self.kind {
v.push(format!("kind {}", format!("{k:?}").to_lowercase()));
}
if let Some(e) = self.edited {
v.push(if e { "edited".into() } else { "unedited".into() });
}
for (name, val) in
[("keyword", &self.keyword), ("camera", &self.camera), ("lens", &self.lens), ("date", &self.date), ("imported", &self.imported)]
{
if let Some(x) = val {
v.push(format!("{name} {x}"));
}
}
match (&self.date_from, &self.date_to) {
(Some(a), Some(b)) => v.push(format!("captured {a} – {b}")),
(Some(a), None) => v.push(format!("captured from {a}")),
(None, Some(b)) => v.push(format!("captured until {b}")),
_ => {}
}
if !self.text.trim().is_empty() {
v.push(format!("“{}”", self.text.trim()));
}
if v.is_empty() { "all photos".into() } else { v.join(", ") }
}
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
if p.deleted != self.deleted {
return false;
@ -121,7 +170,22 @@ impl Filter {
return false;
}
if let Some(a) = self.album
&& !cat.album(a).is_some_and(|al| al.photos.contains(&p.id))
&& !cat.album_contains(a, p)
{
return false;
}
if let Some(from) = &self.date_from
&& p.date() < from.as_str()
{
return false;
}
if let Some(to) = &self.date_to
&& p.date().get(..to.len()).unwrap_or(p.date()) > to.as_str()
{
return false;
}
if let Some(l) = &self.lens
&& !p.meta.lens.to_lowercase().contains(&l.to_lowercase())
{
return false;
}

View File

@ -0,0 +1,316 @@
//! Stacks: photos grouped under a top photo.
//!
//! A stack is catalog data ([`Stack`], changed by `AddStack` / `RemoveStack` / `SetStack` ops), so
//! grouping is undoable and journaled like everything else. The helpers here build the ops for the
//! usual gestures (group, ungroup, remove from stack, set top, collapse) and for programmatic
//! grouping such as merge results stacked with their sources ([`Catalog::stack_with_ops`]).
//! [`Catalog::arrange_stacks`] turns a query result into display order (members together,
//! collapsed stacks reduced to one photo).
use std::collections::{HashMap, HashSet};
use crate::{Catalog, CatalogError, Op, PhotoId, Result, Stack, StackId};
impl Catalog {
pub fn stack(&self, id: StackId) -> Option<&Stack> {
self.stacks.get(&id)
}
pub fn stacks(&self) -> impl Iterator<Item = &Stack> {
self.stacks.values()
}
/// The stack a photo belongs to.
pub fn stack_of(&self, photo: PhotoId) -> Option<&Stack> {
self.stacks.values().find(|s| s.photos.contains(&photo))
}
/// Photo → (stack, position in the stack), for every stacked photo.
pub fn stack_index(&self) -> HashMap<PhotoId, (StackId, usize)> {
self.stacks.values().flat_map(|s| s.photos.iter().enumerate().map(move |(i, p)| (*p, (s.id, i)))).collect()
}
pub(crate) fn validate_stack(&self, id: StackId, photos: &[PhotoId]) -> Result<()> {
if photos.len() < 2 {
return Err(CatalogError::Invalid("a stack needs at least two photos".into()));
}
let mut seen = HashSet::new();
for p in photos {
if !seen.insert(*p) {
return Err(CatalogError::Invalid(format!("photo {p:?} twice in a stack")));
}
if !self.photos.contains_key(p) {
return Err(CatalogError::NoPhoto(*p));
}
}
if self.stacks.values().any(|s| s.id != id && s.photos.iter().any(|p| seen.contains(p))) {
return Err(CatalogError::Invalid("a photo can be in only one stack".into()));
}
Ok(())
}
/// Ops that put `top` and `members` into one stack with `top` on top. Stacks they already
/// belong to are merged into it (their other members follow, in order). `None` if fewer than
/// two photos would be in the stack.
pub fn group_ops(&mut self, top: PhotoId, members: &[PhotoId], collapsed: bool) -> Option<Op> {
let mut order: Vec<PhotoId> = Vec::new();
let push = |order: &mut Vec<PhotoId>, p: PhotoId| {
if !order.contains(&p) && self.photos.contains_key(&p) {
order.push(p);
}
};
push(&mut order, top);
for m in members {
push(&mut order, *m);
}
let mut touched: Vec<StackId> = Vec::new();
for p in order.clone() {
if let Some(s) = self.stack_of(p)
&& !touched.contains(&s.id)
{
touched.push(s.id);
}
}
for sid in &touched {
for p in self.stacks[sid].photos.clone() {
push(&mut order, p);
}
}
if order.len() < 2 || order.first() != Some(&top) {
return None;
}
let mut ops: Vec<Op> = touched.iter().skip(1).map(|id| Op::RemoveStack { id: *id }).collect();
match touched.first() {
Some(keep) => ops.push(Op::SetStack { id: *keep, photos: order, collapsed }),
None => {
let id = self.alloc_stack_id();
ops.push(Op::AddStack { stack: Stack { id, photos: order, collapsed } });
}
}
Some(Op::Batch { ops })
}
/// Stack `sources` under `result` (e.g. an HDR or panorama merge with the photos it was made
/// from), collapsed so the grid shows the result.
pub fn stack_with_ops(&mut self, result: PhotoId, sources: &[PhotoId]) -> Option<Op> {
self.group_ops(result, sources, true)
}
/// Ops that dissolve every stack containing one of `photos`.
pub fn ungroup_ops(&self, photos: &[PhotoId]) -> Vec<Op> {
self.stacks.values().filter(|s| s.photos.iter().any(|p| photos.contains(p))).map(|s| Op::RemoveStack { id: s.id }).collect()
}
/// Ops that take `photos` out of their stacks (a stack left with one photo is dissolved).
pub fn remove_from_stacks_ops(&self, photos: &[PhotoId]) -> Vec<Op> {
self.stacks
.values()
.filter(|s| s.photos.iter().any(|p| photos.contains(p)))
.map(|s| {
let rest: Vec<PhotoId> = s.photos.iter().copied().filter(|p| !photos.contains(p)).collect();
if rest.len() < 2 { Op::RemoveStack { id: s.id } } else { Op::SetStack { id: s.id, photos: rest, collapsed: s.collapsed } }
})
.collect()
}
/// The op that moves `photo` to the top of its stack (`None` if unstacked or already on top).
pub fn set_top_ops(&self, photo: PhotoId) -> Option<Op> {
let s = self.stack_of(photo)?;
if s.top() == photo {
return None;
}
let mut photos = vec![photo];
photos.extend(s.photos.iter().copied().filter(|p| *p != photo));
Some(Op::SetStack { id: s.id, photos, collapsed: s.collapsed })
}
/// Ops that collapse or expand stacks.
pub fn set_collapsed_ops(&self, stacks: &[StackId], collapsed: bool) -> Vec<Op> {
stacks
.iter()
.filter_map(|id| self.stacks.get(id))
.filter(|s| s.collapsed != collapsed)
.map(|s| Op::SetStack { id: s.id, photos: s.photos.clone(), collapsed })
.collect()
}
/// Display order for a query result: a stack's members appear together (in stack order) where
/// its first member appears; a collapsed stack shows only its highest-ranked member in `ids` (the top,
/// unless the filter hides it).
pub fn arrange_stacks(&self, ids: &[PhotoId]) -> Vec<PhotoId> {
if self.stacks.is_empty() {
return ids.to_vec();
}
let index = self.stack_index();
let present: HashSet<PhotoId> = ids.iter().copied().collect();
let mut done: HashSet<StackId> = HashSet::new();
let mut out = Vec::with_capacity(ids.len());
for id in ids {
match index.get(id) {
None => out.push(*id),
Some((sid, _)) => {
if !done.insert(*sid) {
continue;
}
let s = &self.stacks[sid];
let members = s.photos.iter().copied().filter(|p| present.contains(p));
if s.collapsed {
out.extend(members.take(1));
} else {
out.extend(members);
}
}
}
}
out
}
/// Auto-stack by capture time: runs of `ids` (not deleted, not already stacked, with a
/// capture time) whose consecutive captures are at most `gap_secs` apart, in time order.
/// Only runs of two or more are returned.
pub fn auto_stack_groups(&self, ids: &[PhotoId], gap_secs: f64) -> Vec<Vec<PhotoId>> {
let index = self.stack_index();
let mut timed: Vec<(i64, PhotoId)> = ids
.iter()
.filter_map(|id| self.photos.get(id))
.filter(|p| !p.deleted && !index.contains_key(&p.id))
.filter_map(|p| Some((iso_seconds(p.captured.as_deref()?)?, p.id)))
.collect();
timed.sort();
timed.dedup_by_key(|(_, id)| *id);
let mut groups: Vec<Vec<PhotoId>> = Vec::new();
let mut last: Option<i64> = None;
for (t, id) in timed {
match (last, groups.last_mut()) {
(Some(l), Some(g)) if (t - l) as f64 <= gap_secs => g.push(id),
_ => groups.push(vec![id]),
}
last = Some(t);
}
groups.retain(|g| g.len() >= 2);
groups
}
}
/// Seconds since 1970-01-01 for an ISO 8601 local time (`2026-04-01T10:00:00`, `2026-04-01 10:00`,
/// `2026-04-01`; fractions and zone suffixes are ignored). `None` if it doesn't parse.
pub fn iso_seconds(s: &str) -> Option<i64> {
let s = s.trim();
let num = |a: usize, b: usize| -> Option<i64> { s.get(a..b).filter(|x| x.bytes().all(|c| c.is_ascii_digit())).and_then(|x| x.parse().ok()) };
let (y, m, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
let (hh, mm, ss) = if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, num(17, 19).unwrap_or(0)) } else { (0, 0, 0) };
// days from civil (proleptic Gregorian)
let y2 = if m <= 2 { y - 1 } else { y };
let era = y2.div_euclid(400);
let yoe = y2 - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
Some(days * 86_400 + hh * 3600 + mm * 60 + ss)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Photo, Source};
fn cat(n: usize, times: &[&str]) -> (Catalog, Vec<PhotoId>) {
let mut c = Catalog::new();
let mut ids = Vec::new();
for i in 0..n {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 1 }, &format!("{i}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00");
p.captured = times.get(i).map(|t| t.to_string());
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
ids.push(id);
}
(c, ids)
}
#[test]
fn iso_seconds_parses() {
assert_eq!(iso_seconds("1970-01-01T00:00:00"), Some(0));
assert_eq!(iso_seconds("1970-01-02"), Some(86_400));
assert_eq!(iso_seconds("2026-04-01T10:00:05.123+02:00").unwrap() - iso_seconds("2026-04-01T10:00:00").unwrap(), 5);
assert_eq!(iso_seconds("2024-03-01").unwrap() - iso_seconds("2024-02-28").unwrap(), 2 * 86_400, "leap year");
assert_eq!(iso_seconds("2000-01-01T00:00:00"), Some(946_684_800));
assert_eq!(iso_seconds("garbage"), None);
}
#[test]
fn group_merge_set_top_remove_and_arrange() {
let (mut c, ids) = cat(6, &[]);
let [a, b, x, d, e, f] = ids[..] else { unreachable!() };
let op = c.group_ops(b, &[a, b, x], false).unwrap();
let inv = c.apply(op).unwrap();
let s = c.stack_of(a).unwrap().clone();
assert_eq!(s.photos, vec![b, a, x]);
// query order a b x d e f → stack shown where its first member appears, in stack order
assert_eq!(c.arrange_stacks(&ids), vec![b, a, x, d, e, f]);
// collapse: only the top
let ops = c.set_collapsed_ops(&[s.id], true);
c.apply(Op::Batch { ops }).unwrap();
assert_eq!(c.arrange_stacks(&ids), vec![b, d, e, f]);
// filtered-out top: first visible member stands in
assert_eq!(c.arrange_stacks(&[x, d, a]), vec![a, d]);
// grouping with another stack merges both
let op = c.group_ops(d, &[e], false).unwrap();
c.apply(op).unwrap();
let op = c.group_ops(f, &[a, e], true).unwrap();
c.apply(op).unwrap();
assert_eq!(c.stacks().count(), 1);
assert_eq!(c.stack_of(f).unwrap().photos, vec![f, a, e, b, x, d]);
// set top, remove from stack, ungroup
c.apply(c.set_top_ops(x).unwrap()).unwrap();
assert_eq!(c.stack_of(f).unwrap().top(), x);
assert!(c.set_top_ops(x).is_none());
c.apply(Op::Batch { ops: c.remove_from_stacks_ops(&[x, a, e, b]) }).unwrap();
assert_eq!(c.stack_of(f).unwrap().photos, vec![f, d]);
c.apply(Op::Batch { ops: c.remove_from_stacks_ops(&[d]) }).unwrap();
assert_eq!(c.stacks().count(), 0, "a one-photo stack dissolves");
// undo everything back to the first group's inverse state
let (mut c2, _) = cat(6, &[]);
let op = c2.group_ops(a, &[b], false).unwrap();
c2.apply(op).unwrap();
c2.apply(Op::Batch { ops: c2.ungroup_ops(&[b]) }).unwrap();
assert_eq!(c2.stacks().count(), 0);
let _ = inv;
}
#[test]
fn stack_validation_and_permanent_delete() {
let (mut c, ids) = cat(3, &[]);
let id = c.alloc_stack_id();
assert!(c.apply(Op::AddStack { stack: Stack { id, photos: vec![ids[0]], collapsed: false } }).is_err(), "one photo");
assert!(c.apply(Op::AddStack { stack: Stack { id, photos: vec![ids[0], ids[0]], collapsed: false } }).is_err(), "duplicate");
assert!(c.apply(Op::AddStack { stack: Stack { id, photos: vec![ids[0], PhotoId(99)], collapsed: false } }).is_err(), "missing");
c.apply(Op::AddStack { stack: Stack { id, photos: vec![ids[0], ids[1]], collapsed: true } }).unwrap();
let id2 = c.alloc_stack_id();
assert!(c.apply(Op::AddStack { stack: Stack { id: id2, photos: vec![ids[1], ids[2]], collapsed: false } }).is_err(), "one stack per photo");
// deleting a member permanently dissolves the 2-photo stack; undo restores it
let before = c.to_snapshot();
let inv = c.apply(c.delete_permanently_ops(ids[1])).unwrap();
assert!(c.stack_of(ids[0]).is_none());
c.apply(inv).unwrap();
assert_eq!(c.to_snapshot(), before);
// snapshot round trip keeps stacks and the id allocator
let back = Catalog::from_snapshot(&c.to_snapshot()).unwrap();
assert_eq!(back.stack(id), c.stack(id));
assert_eq!(back.clone().alloc_stack_id(), c.clone().alloc_stack_id());
}
#[test]
fn auto_stack_by_capture_time() {
let times = ["2026-04-01T10:00:00", "2026-04-01T10:00:02", "2026-04-01T10:00:09", "2026-04-01T11:00:00", "2026-04-01T11:00:01"];
let (mut c, ids) = cat(6, &times);
let g = c.auto_stack_groups(&ids, 5.0);
assert_eq!(g, vec![vec![ids[0], ids[1]], vec![ids[3], ids[4]]]);
assert_eq!(c.auto_stack_groups(&ids, 10.0), vec![vec![ids[0], ids[1], ids[2]], vec![ids[3], ids[4]]]);
assert_eq!(c.auto_stack_groups(&ids, 3600.0).len(), 1);
// already stacked photos are skipped
let op = c.group_ops(ids[0], &[ids[1]], true).unwrap();
c.apply(op).unwrap();
assert_eq!(c.auto_stack_groups(&ids, 10.0), vec![vec![ids[3], ids[4]]]);
}
}

View File

@ -57,9 +57,11 @@ fn albums_and_folders() {
let mut c = Catalog::new();
let a = photo(&mut c, "a.jpg", "2026-04-01");
let f = c.alloc_album_id();
c.apply(Op::AddAlbum { album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None } }).unwrap();
c.apply(Op::AddAlbum { album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None } })
.unwrap();
let al = c.alloc_album_id();
c.apply(Op::AddAlbum { album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None } }).unwrap();
c.apply(Op::AddAlbum { album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None, smart: None } })
.unwrap();
assert_eq!(c.albums_of(a), vec![al]);
assert!(c.apply(Op::RemoveAlbum { id: f }).is_err(), "non-empty folder");
assert!(c.apply(Op::SetAlbumPhotos { id: f, photos: vec![a] }).is_err(), "folders hold no photos");
@ -141,3 +143,51 @@ proptest::proptest! {
proptest::prop_assert_eq!(c.to_snapshot(), start);
}
}
#[test]
fn smart_albums_update_live() {
let mut c = Catalog::new();
let a = photo(&mut c, "a.jpg", "2026-04-01T10:00:00");
let b = photo(&mut c, "b.jpg", "2026-05-02T10:00:00");
let z = photo(&mut c, "z.jpg", "2025-12-31T10:00:00");
let id = c.alloc_album_id();
let rules = Filter { rating: 3, date_from: Some("2026-01-01".into()), date_to: Some("2026-04".into()), ..Default::default() };
c.apply(Op::AddAlbum { album: Album { smart: Some(Box::new(rules)), ..Album::new(id, "Good spring") } }).unwrap();
assert!(c.album_photos(id).is_empty());
for p in [a, b, z] {
c.apply(Op::SetRating { id: p, rating: 4 }).unwrap();
}
// b is after the range, z before it
assert_eq!(c.album_photos(id), vec![a]);
assert_eq!(c.album_count(id), 1);
assert_eq!(c.albums_of(a), vec![id]);
// via the album filter (what the grid uses)
assert_eq!(c.query(&Filter { album: Some(id), ..Default::default() }, &Sort::default()), vec![a]);
// deleted photos drop out
c.apply(Op::SetDeleted { id: a, deleted: true }).unwrap();
assert!(c.album_photos(id).is_empty());
c.apply(Op::SetDeleted { id: a, deleted: false }).unwrap();
// change the rules; inverse restores
let inv = c.apply(Op::SetAlbumRules { id, rules: Box::new(Filter { rating: 4, rating_op: RatingOp::Exactly, ..Default::default() }) }).unwrap();
assert_eq!(c.album_photos(id), vec![a, b, z]);
c.apply(inv).unwrap();
assert_eq!(c.album_photos(id), vec![a]);
// smart albums hold no photos and can't nest smart rules
assert!(c.apply(Op::SetAlbumPhotos { id, photos: vec![b] }).is_err());
let id2 = c.alloc_album_id();
let bad = Filter { album: Some(id), ..Default::default() };
assert!(c.apply(Op::AddAlbum { album: Album { smart: Some(Box::new(bad)), ..Album::new(id2, "x") } }).is_err());
assert!(c.apply(Op::SetAlbumRules { id, rules: Box::new(Filter { deleted: true, ..Default::default() }) }).is_err());
// rules on a manual album are rejected
let manual = c.alloc_album_id();
c.apply(Op::AddAlbum { album: Album::new(manual, "m") }).unwrap();
assert!(c.apply(Op::SetAlbumRules { id: manual, rules: Box::new(Filter::default()) }).is_err());
// a smart album may narrow a manual album
c.apply(Op::SetAlbumPhotos { id: manual, photos: vec![b, z] }).unwrap();
c.apply(Op::SetAlbumRules { id, rules: Box::new(Filter { album: Some(manual), lens: Some(String::new()), ..Default::default() }) }).unwrap();
assert_eq!(c.album_photos(id), vec![b, z]);
// persisted in snapshots
let back = Catalog::from_snapshot(&c.to_snapshot()).unwrap();
assert_eq!(back.album_photos(id), vec![b, z]);
assert!(Filter { rating: 3, keyword: Some("sea".into()), ..Default::default() }.describe().contains("rating ≥ 3, keyword sea"));
}

View File

@ -19,7 +19,14 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
let albums: Vec<AlbumId> = c.albums().map(|a| a.id).collect();
let pid = photos.get(a as usize % photos.len().max(1)).copied().unwrap_or(PhotoId(99));
let aid = albums.get(a as usize % albums.len().max(1)).copied().unwrap_or(AlbumId(99));
match kind % 10 {
let rules = |b: u8| Filter {
rating: b % 6,
flag: [None, Some(Flag::Pick), Some(Flag::Reject)][b as usize % 3],
date_from: (b % 4 == 1).then(|| "2026-01".to_string()),
text: if b.is_multiple_of(5) { format!("{}", b % 10) } else { String::new() },
..Default::default()
};
match kind % 16 {
0 | 1 => {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, &format!("{}.jpg", id.0), "JPEG", 60, 40, "2026-01-01");
@ -42,11 +49,36 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
}
5 => {
let id = c.alloc_album_id();
Op::AddAlbum { album: Album { id, name: format!("A{b}"), parent: None, folder: b.is_multiple_of(4), photos: vec![], cover: None } }
Op::AddAlbum {
album: Album { id, name: format!("A{b}"), parent: None, folder: b.is_multiple_of(4), photos: vec![], cover: None, smart: None },
}
}
6 => Op::SetAlbumPhotos { id: aid, photos: photos.iter().copied().filter(|p| (p.0 + b as u64).is_multiple_of(3)).collect() },
7 => Op::RenameAlbum { id: aid, name: format!("Renamed {b} ✓") },
8 => Op::SetDeleted { id: pid, deleted: b.is_multiple_of(2) },
10 => {
let id = c.alloc_album_id();
Op::AddAlbum { album: Album { smart: Some(Box::new(rules(b))), ..Album::new(id, format!("Smart {b}")) } }
}
11 => Op::SetAlbumRules { id: aid, rules: Box::new(rules(b)) },
12 => {
let members: Vec<PhotoId> = photos.iter().copied().filter(|p| (p.0 + b as u64).is_multiple_of(4)).collect();
c.group_ops(pid, &members, b.is_multiple_of(2)).unwrap_or(Op::Batch { ops: vec![] })
}
13 => Op::Batch { ops: c.remove_from_stacks_ops(&[pid]) },
14 => c.set_top_ops(pid).unwrap_or(Op::Batch { ops: vec![] }),
15 => match c.photo(pid).cloned() {
// a virtual copy: same source, own id, copy_of/copy_name set
Some(src) => {
let id = c.alloc_photo_id();
let mut v = (*src).clone();
v.id = id;
v.copy_of = Some(pid);
v.copy_name = Some(format!("Copy {b}"));
Op::AddPhoto { photo: Box::new(v) }
}
None => Op::Batch { ops: vec![] },
},
_ => c.delete_permanently_ops(pid),
}
}

View File

@ -302,9 +302,55 @@ pub fn specs() -> Vec<CommandSpec> {
let photos = if !folder && bool_or(p, "addSelected", false) { s.targets(&Value::Null) } else { vec![] };
let id = s.catalog.alloc_album_id();
let cover = photos.first().copied();
s.commit(if folder { "New Folder" } else { "New Album" }, Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover } })?;
s.commit(
if folder { "New Folder" } else { "New Album" },
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None } },
)?;
Ok(json!({"id": id.0}))
}),
cmd!(
"album.createSmart",
"New Smart Album…",
[],
None,
"{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album), parent?: folderId} — without `rules`, saves the current view (source + filter)",
always,
|s, p| {
let name = str_param(p, "name").unwrap_or("Smart Album").trim().to_string();
if name.is_empty() {
return Err(bad("album.createSmart", "empty name"));
}
let rules = match p.get("rules") {
Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart")?,
None => view_rules(s),
};
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
let id = s.catalog.alloc_album_id();
let album = Album { parent, smart: Some(Box::new(rules)), ..Album::new(id, name) };
s.commit("New Smart Album", Op::AddAlbum { album })?;
Ok(json!({"id": id.0, "count": s.catalog.album_count(id)}))
}
),
cmd!(
"album.setRules",
"Edit Smart Album",
[],
None,
"{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view)}",
always,
|s, p| {
let id = album_param(p, "id", "album.setRules")?;
let cur = s.catalog.album(id).and_then(|a| a.smart.as_deref().cloned()).ok_or_else(|| bad("album.setRules", "not a smart album"))?;
let rules = if bool_or(p, "fromView", false) {
view_rules(s)
} else {
let base = if bool_or(p, "replace", false) { Default::default() } else { cur };
merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules")?
};
s.commit("Edit Smart Album", Op::SetAlbumRules { id, rules: Box::new(rules) })?;
Ok(json!({"count": s.catalog.album_count(id)}))
}
),
cmd!("album.rename", "Rename Album", [], None, "{id, name}", always, |s, p| {
let id = album_param(p, "id", "album.rename")?;
let name = str_param(p, "name").ok_or_else(|| bad("album.rename", "missing name"))?.to_string();
@ -329,6 +375,9 @@ pub fn specs() -> Vec<CommandSpec> {
let id = album_param(p, "id", "album.addPhotos")?;
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?;
if al.is_smart() || al.folder {
return Err(bad("album.addPhotos", "smart albums and folders can't hold photos"));
}
let mut photos = al.photos.clone();
let before = photos.len();
for t in targets {
@ -345,6 +394,9 @@ pub fn specs() -> Vec<CommandSpec> {
let id = album_param(p, "id", "album.removePhotos")?;
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?;
if al.is_smart() {
return Err(bad("album.removePhotos", "smart albums update automatically: change their rules"));
}
let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect();
s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?;
ok()
@ -444,6 +496,51 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// `base` with a partial Filter (JSON) merged on top.
fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
let mut v = serde_json::to_value(base).unwrap_or_default();
lightcraft_develop::presets::deep_merge(&mut v, patch);
serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))
}
/// The current view (source + filter) as smart-album rules. Viewing a smart album starts from
/// its rules with the filter bar's settings on top.
fn view_rules(s: &Session) -> lightcraft_catalog::Filter {
use lightcraft_catalog::Filter;
let smart = match s.source {
LibrarySource::Album(a) => s.catalog.album(a).and_then(|a| a.smart.as_deref().cloned()),
_ => None,
};
match smart {
Some(base) => {
// overlay the fields the filter bar changed
let cur = serde_json::to_value(&s.filter).unwrap_or_default();
let def = serde_json::to_value(Filter::default()).unwrap_or_default();
let mut patch = serde_json::Map::new();
if let (Some(c), Some(d)) = (cur.as_object(), def.as_object()) {
for (k, v) in c {
if d.get(k) != Some(v) {
patch.insert(k.clone(), v.clone());
}
}
}
merge_rules(&base, &Value::Object(patch), "").unwrap_or(base)
}
None => {
let mut f = s.source.to_filter(&s.filter, &s.catalog);
f.deleted = false;
f
}
}
}
impl Session {
/// The current view (source + filter) as smart-album rules (see `album.createSmart`).
pub fn view_rules(&self) -> lightcraft_catalog::Filter {
view_rules(self)
}
}
fn has_library(s: &Session) -> std::result::Result<(), String> {
if s.library.is_some() { Ok(()) } else { Err("no library is open (in-memory session)".into()) }
}

View File

@ -10,6 +10,7 @@ mod edit;
mod library;
mod masks;
mod merge;
mod organize;
mod preset_files;
mod query;
mod xmp;
@ -105,6 +106,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(develop::specs());
v.extend(color::specs());
v.extend(masks::specs());
v.extend(organize::specs());
v.extend(merge::specs());
v.extend(query::specs());
v.extend(xmp::specs());

View File

@ -0,0 +1,207 @@
//! Organizing commands: stacks (group, ungroup, set top, expand/collapse, auto-stack by capture
//! time) and virtual copies.
use lightcraft_catalog::{HistoryStep, Op, PhotoId, Stack, StackId};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection};
use crate::{Result, Selection, Session};
/// Stacks containing any of `targets`, in first-seen order.
fn stacks_of(s: &Session, targets: &[PhotoId]) -> Vec<StackId> {
let mut v = Vec::new();
for t in targets {
if let Some(st) = s.catalog.stack_of(*t)
&& !v.contains(&st.id)
{
v.push(st.id);
}
}
v
}
fn in_stack(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
if stacks_of(s, &s.targets(&Value::Null)).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) }
}
fn has_stacks(s: &Session) -> std::result::Result<(), String> {
if s.catalog.stacks().next().is_none() { Err("no stacks".into()) } else { Ok(()) }
}
fn commit_ops(s: &mut Session, label: &str, ops: Vec<Op>) -> Result<usize> {
let n = ops.len();
if n > 0 {
s.commit(label, Op::Batch { ops })?;
}
Ok(n)
}
/// After collapsing, keep the active photo visible (move it to its stack's top).
fn fix_active(s: &mut Session) {
let vis = s.visible_cloned();
if let Some(a) = s.selection.active
&& !vis.contains(&a)
&& let Some(st) = s.catalog.stack_of(a)
{
let top = vis.iter().copied().find(|p| st.photos.contains(p)).unwrap_or(st.top());
s.selection = Selection::single(top);
}
}
fn set_collapsed(s: &mut Session, stacks: Vec<StackId>, collapsed: bool, label: &str) -> Result<Value> {
let ops = s.catalog.set_collapsed_ops(&stacks, collapsed);
let n = commit_ops(s, label, ops)?;
if collapsed {
fix_active(s);
}
Ok(json!({"changed": n}))
}
/// "Copy 3" → 3.
fn copy_number(name: Option<&str>) -> u32 {
name.and_then(|n| n.strip_prefix("Copy ")).and_then(|n| n.trim().parse().ok()).unwrap_or(0)
}
/// Create one virtual copy of `src`: a new photo sharing its file, with the same settings and
/// metadata, added to the same albums and stacked right after it (the stack is expanded so the
/// copy is visible).
fn virtual_copy(s: &mut Session, src: PhotoId) -> Result<Option<PhotoId>> {
let Some(orig) = s.catalog.photo(src).cloned() else { return Ok(None) };
let master = orig.copy_of.unwrap_or(src);
let n = 1 + s.catalog.photos().filter(|p| p.copy_of == Some(master)).map(|p| copy_number(p.copy_name.as_deref())).max().unwrap_or(0);
let id = s.catalog.alloc_photo_id();
let mut c = (*orig).clone();
c.id = id;
c.copy_of = Some(master);
c.copy_name = Some(format!("Copy {n}"));
c.versions.clear();
c.history = vec![HistoryStep { label: "Virtual Copy".into(), settings: c.develop.clone() }];
c.deleted = false;
let mut ops = vec![Op::AddPhoto { photo: Box::new(c) }];
for a in s.catalog.albums().filter(|a| !a.is_smart() && a.photos.contains(&src)) {
let mut photos = a.photos.clone();
let at = photos.iter().position(|p| *p == src).map_or(photos.len(), |i| i + 1);
photos.insert(at, id);
ops.push(Op::SetAlbumPhotos { id: a.id, photos });
}
match s.catalog.stack_of(src).cloned() {
Some(st) => {
let mut photos = st.photos.clone();
let at = st.position(src).map_or(photos.len(), |i| i + 1);
photos.insert(at, id);
ops.push(Op::SetStack { id: st.id, photos, collapsed: false });
}
None => {
let sid = s.catalog.alloc_stack_id();
ops.push(Op::AddStack { stack: Stack { id: sid, photos: vec![src, id], collapsed: false } });
}
}
s.commit("Create Virtual Copy", Op::Batch { ops })?;
Ok(Some(id))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"photo.virtualCopy",
"Create Virtual Copy",
["Photo"],
Some("Cmd+'"),
"{ids?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named \"Copy N\"",
has_selection,
|s, p| {
let targets = s.targets(p);
let mut made = Vec::new();
for t in targets {
if let Some(id) = virtual_copy(s, t)? {
made.push(id);
}
}
s.merge_undo(made.len(), "Create Virtual Copy");
if let Some(last) = made.last() {
s.selection = Selection { ids: made.clone(), active: Some(*last) };
}
Ok(json!({"ids": made.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
),
cmd!(
"stack.group",
"Group into Stack",
["Photo", "Stack"],
Some("Cmd+G"),
"{ids?, top?: photoId (default: the active photo), collapsed?: bool (default true)} — stacks the photos are already in are merged",
has_selection,
|s, p| {
let targets = s.targets(p);
let top = p
.get("top")
.and_then(Value::as_u64)
.map(PhotoId)
.or(s.selection.active.filter(|a| targets.contains(a)))
.or(targets.first().copied())
.ok_or_else(|| bad("stack.group", "no photos"))?;
let op = s
.catalog
.group_ops(top, &targets, bool_or(p, "collapsed", true))
.ok_or_else(|| bad("stack.group", "select at least two photos"))?;
s.commit("Group into Stack", op)?;
let st = s.catalog.stack_of(top).map(|st| (st.id.0, st.photos.len())).unwrap_or_default();
s.selection = Selection::single(top);
Ok(json!({"stack": st.0, "count": st.1}))
}
),
cmd!("stack.ungroup", "Ungroup Stack", ["Photo", "Stack"], Some("Cmd+Shift+G"), "{ids?}", in_stack, |s, p| {
let ops = s.catalog.ungroup_ops(&s.targets(p));
Ok(json!({"ungrouped": commit_ops(s, "Ungroup Stack", ops)?}))
}),
cmd!("stack.remove", "Remove from Stack", ["Photo", "Stack"], None, "{ids?}", in_stack, |s, p| {
let ops = s.catalog.remove_from_stacks_ops(&s.targets(p));
Ok(json!({"changed": commit_ops(s, "Remove from Stack", ops)?}))
}),
cmd!("stack.setTop", "Set as Top of Stack", ["Photo", "Stack"], Some("Shift+S"), "{id?} (default: the active photo)", in_stack, |s, p| {
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.setTop", "no photo"))?;
let op = s.catalog.set_top_ops(id);
Ok(json!({"changed": commit_ops(s, "Set Top of Stack", op.into_iter().collect())?}))
}),
cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| {
let stacks = stacks_of(s, &s.targets(p));
let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false);
let collapsed = bool_or(p, "collapsed", !first);
set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" })
}),
cmd!("stack.expandAll", "Expand All Stacks", ["View", "Stacks"], None, "{}", has_stacks, |s, _| {
let all: Vec<StackId> = s.catalog.stacks().map(|st| st.id).collect();
set_collapsed(s, all, false, "Expand All Stacks")
}),
cmd!("stack.collapseAll", "Collapse All Stacks", ["View", "Stacks"], None, "{}", has_stacks, |s, _| {
let all: Vec<StackId> = s.catalog.stacks().map(|st| st.id).collect();
set_collapsed(s, all, true, "Collapse All Stacks")
}),
cmd!(
"stack.auto",
"Auto-Stack by Capture Time",
[],
None,
"{gap?: seconds between consecutive captures (default 60), ids? (default: the selection if several photos, else the current view), preview?: bool} → {stacks, photos}",
always,
|s, p| {
let gap = f64_or(p, "gap", 60.0).max(0.0);
let explicit = p.get("ids").is_some();
let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() };
let groups = s.catalog.auto_stack_groups(&pool, gap);
let photos: usize = groups.iter().map(Vec::len).sum();
if bool_or(p, "preview", false) {
return Ok(json!({"stacks": groups.len(), "photos": photos}));
}
let mut ops = Vec::new();
for g in &groups {
ops.extend(s.catalog.group_ops(g[0], g, true));
}
commit_ops(s, "Auto-Stack", ops)?;
fix_active(s);
Ok(json!({"stacks": groups.len(), "photos": photos}))
}
),
]
}

View File

@ -24,18 +24,26 @@ pub fn photo_summary(p: &Photo) -> Value {
"keywords": p.meta.keywords,
"camera": p.meta.camera,
"deleted": p.deleted,
"copyOf": p.copy_of.map(|c| c.0),
"copyName": p.copy_name,
})
}
fn album_json(a: &Album, all: &[Album]) -> Value {
json!({
fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog) -> Value {
let mut v = json!({
"id": a.id.0,
"name": a.name,
"folder": a.folder,
"count": a.photos.len(),
"count": cat.album_count(a.id),
"cover": a.cover.map(|c| c.0),
"children": all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all)).collect::<Vec<_>>(),
})
"children": all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all, cat)).collect::<Vec<_>>(),
});
if let Some(rules) = &a.smart {
v["smart"] = json!(true);
v["rules"] = serde_json::to_value(rules).unwrap_or_default();
v["rulesText"] = json!(rules.describe());
}
v
}
fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result<PhotoId> {
@ -88,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!(query "albums.list", "List Albums", [], None, "{}", always, |s, _| {
let all: Vec<Album> = s.catalog.albums().cloned().collect();
Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all)).collect()))
Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog)).collect()))
}),
cmd!(query "photo.inspect", "Inspect Photo", [], None, "{id?}", always, |s, p| {
let id = photo_arg(s, p, "photo.inspect")?;
@ -96,6 +104,14 @@ pub fn specs() -> Vec<CommandSpec> {
let mut v = serde_json::to_value(ph.as_ref()).unwrap_or_default();
v["albums"] = json!(s.catalog.albums_of(id).iter().map(|a| a.0).collect::<Vec<_>>());
v["history"] = json!(ph.history.iter().map(|h| h.label.clone()).collect::<Vec<_>>());
if let Some(st) = s.catalog.stack_of(id) {
v["stack"] = json!({
"id": st.id.0,
"photos": st.photos.iter().map(|p| p.0).collect::<Vec<_>>(),
"position": st.position(id),
"collapsed": st.collapsed,
});
}
Ok(v)
}),
cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?}", always, |s, p| {

View File

@ -61,7 +61,9 @@ pub fn load(s: &mut Session) {
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
let folder = s.catalog.alloc_album_id();
ops.push(Op::AddAlbum { album: Album { id: folder, name: "Travel 2026".into(), parent: None, folder: true, photos: vec![], cover: None } });
ops.push(Op::AddAlbum {
album: Album { id: folder, name: "Travel 2026".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None },
});
let by_kw = |kw: &str| -> Vec<_> { scenes.iter().zip(&ids).filter(|(sc, _)| sc.meta.keywords.contains(&kw)).map(|(_, id)| *id).collect() };
for (name, parent, photos) in [
("Mountains", Some(folder), by_kw("mountains")),
@ -73,7 +75,7 @@ pub fn load(s: &mut Session) {
] {
let id = s.catalog.alloc_album_id();
let cover = photos.first().copied();
ops.push(Op::AddAlbum { album: Album { id, name: name.into(), parent, folder: false, photos, cover } });
ops.push(Op::AddAlbum { album: Album { id, name: name.into(), parent, folder: false, photos, cover, smart: None } });
}
for op in ops {
let _ = s.catalog.apply(op);

View File

@ -186,6 +186,17 @@ impl Session {
Ok(())
}
/// Fold the last `n` undo steps into one (commands that commit step by step because each op
/// depends on the state the previous one left).
pub fn merge_undo(&mut self, n: usize, label: &str) {
if n < 2 || n > self.undo.len() {
return;
}
let tail = self.undo.split_off(self.undo.len() - n);
let ops = tail.into_iter().rev().map(|e| e.op).collect();
self.undo.push(UndoEntry { label: label.to_string(), op: Op::Batch { ops } });
}
/// Apply without recording undo (interactive previews).
fn apply_silent(&mut self, op: Op) -> Result<()> {
self.catalog.apply(op)?;
@ -289,6 +300,7 @@ impl Session {
&& self.sort.key == lightcraft_catalog::SortKey::CaptureDate
&& let LibrarySource::Album(a) = self.source
&& let Some(al) = self.catalog.album(a)
&& !al.is_smart()
&& self.filter == Filter::default()
{
let order = al.photos.clone();
@ -297,6 +309,9 @@ impl Session {
self.visible.reverse();
}
}
if self.source != LibrarySource::RecentlyDeleted {
self.visible = self.catalog.arrange_stacks(&self.visible);
}
self.visible_key = Some(key);
}
&self.visible
@ -329,4 +344,6 @@ mod tests_library;
#[cfg(test)]
mod tests_merge;
#[cfg(test)]
mod tests_organize;
#[cfg(test)]
mod tests_xmp;

View File

@ -424,7 +424,14 @@ impl crate::Session {
let source = self.media.source_ref(&p, level);
let settings = if before { Arc::new(lightcraft_pipeline::before_settings(&p.develop)) } else { p.develop.clone() };
let request = RenderRequest { apply_crop, ..RenderRequest::fit(max_w, max_h) };
let key = settings.hash64() ^ ((max_w as u64) << 40) ^ ((max_h as u64) << 20) ^ (apply_crop as u64) ^ (level as u64) << 60;
// the photo id is part of the key: two photos with the same settings and size must not
// share a result (a view slot showing photo A would otherwise look current for photo B)
let key = settings.hash64()
^ ((max_w as u64) << 40)
^ ((max_h as u64) << 20)
^ (apply_crop as u64)
^ (level as u64) << 60
^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15);
let cache = thumb_bucket.map(|b| {
let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish();
(self.media.rendered.clone(), k)
@ -564,5 +571,9 @@ mod tests {
let loupe = s.render_job(p.id, 1600, 1600, false, true).unwrap();
assert_eq!(loupe.level, SourceLevel::Preview);
assert_ne!(loupe.key, s.render_job(p.id, 1600, 1600, false, true).unwrap().draft().key);
// two photos with identical settings and size never share a result key
let plain: Vec<_> = s.catalog.photos().filter(|p| !p.is_edited()).map(|p| p.id).take(2).collect();
let (a, b) = (s.render_job(plain[0], 1600, 1600, false, true).unwrap(), s.render_job(plain[1], 1600, 1600, false, true).unwrap());
assert_ne!(a.key, b.key);
}
}

View File

@ -62,8 +62,7 @@ impl MergeKind {
pub struct MergeFinish {
/// Run Auto Settings on the result.
pub auto_settings: bool,
/// Group the result with its sources. LightCraft has no stacks yet: recorded in the result's
/// caption instead (see `docs/merge.md`).
/// Stack the result on top of its sources (collapsed); the result's caption also lists them.
pub stack: bool,
/// Preview only: tint deghosted areas red.
pub show_overlay: bool,
@ -381,6 +380,12 @@ impl Session {
.copied()
.map(PhotoId)
.ok_or_else(|| EngineError::Other(format!("the merged file {path} could not be imported: {:?}", report.failed)))?;
if job.finish.stack {
let sources: Vec<PhotoId> = job.sources.iter().map(|(p, _)| *p).collect();
if let Some(op) = self.catalog.stack_with_ops(id, &sources) {
self.commit("Stack with Sources", op)?;
}
}
self.selection = crate::Selection::single(id);
if job.finish.auto_settings {
self.execute("develop.auto", &json!({}))?;

View File

@ -241,6 +241,9 @@ impl Session {
/// Write the photo's XMP sidecar; returns its path.
pub fn save_sidecar(&self, id: PhotoId) -> Result<PathBuf> {
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
if p.copy_of.is_some() {
return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name)));
}
let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?;
let path = sidecar_path(orig, self.xmp.naming);
write_atomic(&path, sidecar_packet(p).as_bytes()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
@ -272,7 +275,7 @@ impl Session {
let mut ids = Vec::new();
ops.iter().for_each(|o| op_photos(o, &mut ids));
for id in ids {
if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some())
if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none())
&& let Err(e) = self.save_sidecar(id)
{
log::warn!("auto-write XMP: {e}");

View File

@ -49,9 +49,17 @@ fn hdr_merge_command_creates_and_imports_a_dng() {
assert!(d.light.exposure != 0.0 || d.light.highlights != 0.0, "auto settings applied");
let img = s.render_now(id, 256, 256).unwrap();
assert_eq!(img.image.width, 256);
// Create Stack: the result tops a collapsed stack of its sources, so the grid shows the result
let st = s.catalog.stack_of(id).expect("merge result stacked").clone();
assert_eq!(st.top(), id);
assert_eq!(st.photos[1..].iter().map(|p| p.0).collect::<Vec<_>>(), ids);
assert!(st.collapsed);
assert_eq!(s.visible_cloned(), vec![id]);
// the second merge doesn't overwrite the first
let r2 = s.execute("merge.hdr", &json!({"ids": ids, "autoSettings": false})).unwrap();
assert!(r2["path"].as_str().unwrap().ends_with("IMG_0-HDR-2.dng"));
let id2 = lightcraft_catalog::PhotoId(r2["id"].as_u64().unwrap());
assert!(s.catalog.stack_of(id2).is_none(), "no stack without the option");
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -0,0 +1,178 @@
//! Organizing commands: smart albums, stacks, virtual copies — through `Session::execute`, with
//! undo and a persistent library (journal replay must reproduce the live state).
use lightcraft_catalog::Flag;
use serde_json::json;
use crate::{LibrarySource, Session};
fn temp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lc-organize-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
fn open(dir: &std::path::Path) -> Session {
let mut s = Session::new();
s.open_library(dir, true).unwrap();
s
}
#[test]
fn smart_album_from_view_updates_live_and_persists() {
let dir = temp_dir("smart");
let mut s = open(&dir);
s.execute("library.filter", &json!({"rating": 4})).unwrap();
let want = s.visible_cloned();
assert!(!want.is_empty());
let r = s.execute("album.createSmart", &json!({"name": "Four plus"})).unwrap();
let id = r["id"].as_u64().unwrap();
assert_eq!(r["count"].as_u64().unwrap() as usize, want.len());
s.execute("library.clearFilter", &json!({})).unwrap();
s.execute("library.source", &json!({"kind": "album", "id": id})).unwrap();
let mut got = s.visible_cloned();
got.sort();
let mut w = want.clone();
w.sort();
assert_eq!(got, w);
// live: rating another photo 5 adds it; rating a member 1 removes it
let other = s.catalog.photos().find(|p| p.rating < 4 && !p.deleted).unwrap().id;
s.execute("photo.rate", &json!({"ids": [other.0], "rating": 5})).unwrap();
assert!(s.visible_cloned().contains(&other));
s.execute("photo.rate", &json!({"ids": [want[0].0], "rating": 1})).unwrap();
assert!(!s.visible_cloned().contains(&want[0]));
// manual membership changes are refused
assert!(s.execute("album.addPhotos", &json!({"id": id, "ids": [want[0].0]})).is_err());
// edit rules (merge), rename; undo the rename
s.execute("album.setRules", &json!({"id": id, "rules": {"flag": "pick"}})).unwrap();
let rules = s.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().smart.clone().unwrap();
assert_eq!((rules.rating, rules.flag), (4, Some(Flag::Pick)));
s.execute("album.rename", &json!({"id": id, "name": "Best picks"})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().name, "Four plus");
// rules via params: camera + lens + date range
let r2 = s
.execute("album.createSmart", &json!({"name": "Range", "rules": {"dateFrom": "2000-01-01", "dateTo": "2100", "lens": "", "edited": true}}))
.unwrap();
let edited = s.catalog.photos().filter(|p| !p.deleted && p.is_edited()).count();
assert_eq!(r2["count"].as_u64().unwrap() as usize, edited);
let listed = s.execute("albums.list", &json!({})).unwrap();
assert!(listed.to_string().contains("\"rulesText\""), "{listed}");
let expect = s.catalog.to_snapshot();
drop(s); // crash: replay the log
let mut s2 = open(&dir);
assert_eq!(s2.catalog.to_snapshot(), expect);
// deleting the smart album
s2.execute("album.delete", &json!({"id": id})).unwrap();
assert!(s2.catalog.album(lightcraft_catalog::AlbumId(id)).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn smart_album_from_smart_album_view_keeps_its_rules() {
let mut s = Session::with_demo();
let id = s.execute("album.createSmart", &json!({"name": "Picks", "rules": {"flag": "pick"}})).unwrap()["id"].as_u64().unwrap();
s.execute("library.source", &json!({"kind": "album", "id": id})).unwrap();
s.execute("library.filter", &json!({"rating": 3})).unwrap();
let r = s.view_rules();
assert_eq!((r.flag, r.rating, r.album), (Some(Flag::Pick), 3, None));
assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id)));
}
#[test]
fn virtual_copies_are_independent_and_persist() {
let dir = temp_dir("vc");
let mut s = open(&dir);
let src = s.visible_cloned()[1];
let album = s.execute("album.create", &json!({"name": "Keep", "addSelected": false})).unwrap()["id"].as_u64().unwrap();
s.execute("album.addPhotos", &json!({"id": album, "ids": [src.0]})).unwrap();
s.execute("library.select", &json!({"ids": [src.0]})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap();
let undo_before = s.undo.len();
let r = s.execute("photo.virtualCopy", &json!({})).unwrap();
assert_eq!(s.undo.len(), undo_before + 1);
let c1 = lightcraft_catalog::PhotoId(r["ids"][0].as_u64().unwrap());
let r2 = s.execute("photo.virtualCopy", &json!({"ids": [src.0]})).unwrap();
let c2 = lightcraft_catalog::PhotoId(r2["ids"][0].as_u64().unwrap());
let (p, a, b) = (s.catalog.photo(src).unwrap().clone(), s.catalog.photo(c1).unwrap().clone(), s.catalog.photo(c2).unwrap().clone());
assert_eq!((a.copy_of, a.copy_name.as_deref()), (Some(src), Some("Copy 1")));
assert_eq!(b.copy_name.as_deref(), Some("Copy 2"));
assert_eq!(a.source, p.source, "shares the original file");
assert_eq!(a.develop.light.exposure, 0.5, "starts from the current settings");
// a copy of a copy names itself after the master
let r3 = s.execute("photo.virtualCopy", &json!({"ids": [c1.0]})).unwrap();
let c3 = s.catalog.photo(lightcraft_catalog::PhotoId(r3["ids"][0].as_u64().unwrap())).unwrap().clone();
assert_eq!((c3.copy_of, c3.copy_name.as_deref()), (Some(src), Some("Copy 3")));
// independent settings
s.execute("library.select", &json!({"ids": [c1.0]})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": -1.0})).unwrap();
assert_eq!(s.catalog.photo(src).unwrap().develop.light.exposure, 0.5);
assert_eq!(s.catalog.photo(c1).unwrap().develop.light.exposure, -1.0);
// same album, stacked (expanded) with the original, visible in the grid
assert!(s.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.starts_with(&[src, c2, c1, c3.id]));
assert_eq!(s.catalog.stack_of(c1).unwrap().photos, vec![src, c2, c1, c3.id]);
assert!(s.visible_cloned().contains(&c2));
let found = s.execute("catalog.query", &json!({"filter": {"text": "copy:yes"}})).unwrap();
assert_eq!(found["total"], 3);
// undo removes a copy completely
s.execute("edit.undo", &json!({})).unwrap(); // exposure
s.execute("edit.undo", &json!({})).unwrap(); // third copy
assert!(s.catalog.photo(c3.id).is_none());
let expect = s.catalog.to_snapshot();
drop(s);
let s2 = open(&dir);
assert_eq!(s2.catalog.to_snapshot(), expect);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn stacks_group_collapse_and_replay() {
let dir = temp_dir("stacks");
let mut s = open(&dir);
let vis = s.visible_cloned();
let n = vis.len();
let (a, b, c) = (vis[2], vis[3], vis[4]);
s.execute("library.select", &json!({"ids": [a.0, b.0, c.0], "active": b.0})).unwrap();
let r = s.execute("stack.group", &json!({})).unwrap();
assert_eq!(r["count"], 3);
// collapsed by default: the top (the active photo) stands for the stack
let v = s.visible_cloned();
assert_eq!(v.len(), n - 2);
assert!(v.contains(&b) && !v.contains(&a) && !v.contains(&c));
assert_eq!(s.selection.active, Some(b));
let info = s.execute("photo.inspect", &json!({"id": a.0})).unwrap();
assert_eq!(info["stack"]["photos"], json!([b.0, a.0, c.0]));
// expand: members follow the top
s.execute("stack.toggle", &json!({})).unwrap();
let v = s.visible_cloned();
let i = v.iter().position(|x| *x == b).unwrap();
assert_eq!(&v[i..i + 3], &[b, a, c]);
// set top, collapse with a member active → active moves to the visible top
s.execute("library.select", &json!({"ids": [c.0]})).unwrap();
s.execute("stack.setTop", &json!({})).unwrap();
s.execute("library.select", &json!({"ids": [a.0]})).unwrap();
s.execute("stack.toggle", &json!({})).unwrap();
assert_eq!(s.selection.active, Some(c));
// remove from stack; undo
s.execute("stack.remove", &json!({"ids": [a.0]})).unwrap();
assert_eq!(s.catalog.stack_of(c).unwrap().photos, vec![c, b]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.stack_of(c).unwrap().photos.len(), 3);
// auto-stack the rest of the view: a huge gap groups everything unstacked
let pre = s.execute("stack.auto", &json!({"gap": 1e9, "preview": true})).unwrap();
assert_eq!(pre["stacks"], 1);
assert_eq!(s.catalog.stacks().count(), 1, "preview changes nothing");
s.execute("stack.auto", &json!({"gap": 1e9})).unwrap();
assert_eq!(s.catalog.stacks().count(), 2);
assert_eq!(s.visible_cloned().len(), 2);
s.execute("stack.expandAll", &json!({})).unwrap();
assert_eq!(s.visible_cloned().len(), n);
let expect = s.catalog.to_snapshot();
drop(s);
let mut s2 = open(&dir);
assert_eq!(s2.catalog.to_snapshot(), expect);
s2.execute("library.select", &json!({"ids": [b.0]})).unwrap();
s2.execute("stack.ungroup", &json!({})).unwrap();
assert_eq!(s2.catalog.stacks().count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -77,14 +77,31 @@ impl Buf {
impl Drop for Buf {
fn drop(&mut self) {
if let Some(b) = self.buf.take() {
RETIRED.with(|r| r.borrow_mut().push(b));
// during thread teardown the list may already be gone: then the buffer leaks
let mut b = Some(b);
let _ = RETIRED.try_with(|r| r.0.borrow_mut().extend(b.take()));
if let Some(b) = b {
std::mem::forget(b);
}
}
}
}
/// Buffers dropped on a thread since its last submit.
struct Retired(std::cell::RefCell<Vec<wgpu::Buffer>>);
impl Drop for Retired {
/// At thread exit wgpu's own thread-locals may already be destroyed, and dropping a buffer
/// then aborts the process: leak the few buffers left instead (the device owns their memory).
fn drop(&mut self) {
for b in self.0.get_mut().drain(..) {
std::mem::forget(b);
}
}
}
thread_local! {
/// Buffers dropped on this thread since its last submit.
static RETIRED: std::cell::RefCell<Vec<wgpu::Buffer>> = const { std::cell::RefCell::new(Vec::new()) };
static RETIRED: Retired = const { Retired(std::cell::RefCell::new(Vec::new())) };
}
/// Most bytes kept in the free pool.
@ -236,7 +253,7 @@ impl Gpu {
/// Submit command buffers, then make the buffers this thread dropped before reusable.
pub fn submit(&self, cmds: impl IntoIterator<Item = wgpu::CommandBuffer>) {
self.queue.submit(cmds);
let retired = RETIRED.with(|r| std::mem::take(&mut *r.borrow_mut()));
let retired = RETIRED.with(|r| std::mem::take(&mut *r.0.borrow_mut()));
let mut f = self.free.lock().unwrap_or_else(|e| e.into_inner());
f.extend(retired);
let mut total: u64 = f.iter().map(|b| b.size()).sum();

View File

@ -133,6 +133,9 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
}
"engine.commands" => ok(all_commands(app)),
"ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()),
"ui.menu.tree" => {
ok(Value::Array(crate::menubar::menu_bar(app).into_iter().map(|(title, items)| json!({"label": title, "children": items})).collect()))
}
"ui.inspect" => ok(inspect(app, ctx)),
"ui.widgets" => {
let filter = s("filter").unwrap_or("");

View File

@ -242,6 +242,10 @@ impl Headless {
mod tests {
use super::*;
/// Generous: renders are slow when the machine is loaded (parallel builds), and a timed-out
/// settle would show a half-rendered UI.
const SETTLE: Duration = Duration::from_secs(120);
fn demo(size: [f32; 2]) -> Headless {
let services = crate::Services { png: None, ..Default::default() };
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
@ -253,7 +257,7 @@ mod tests {
fn demo_grid_snapshot_has_ui_pixels() {
let t0 = Instant::now();
let mut h = demo([1200.0, 760.0]);
let img = h.snapshot(Duration::from_secs(20));
let img = h.snapshot(SETTLE);
eprintln!("headless snapshot: {:?} in {:?} ({} frames)", img.size, t0.elapsed(), h.frames());
assert_eq!(img.size, [1200, 760]);
// not blank: many distinct colours
@ -275,24 +279,110 @@ mod tests {
let mut h = demo([900.0, 600.0]);
let r = h.request("ui.set", json!({"view": "detail"}), Duration::from_secs(10));
assert_eq!(r["ok"], true, "{r}");
h.snapshot(Duration::from_secs(20))
h.snapshot(SETTLE)
};
let (a, b) = (shot(), shot());
assert_eq!(a.size, b.size);
let diff = a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x != y).count();
// The photo may come from the GPU in one run and the CPU in the other (the GPU warms up in
// the background), which differ by ≤ 1–2 LSB; anything more is a real difference.
let diff = a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.to_array().iter().zip(y.to_array()).any(|(p, q)| p.abs_diff(q) > 2)).count();
assert_eq!(diff, 0, "{diff} pixels differ between two runs");
}
/// Keyboard culling: Compare (rating keys hit the candidate, arrows move it, auto-advance),
/// Survey (keys hit the active photo only), and auto-advance in Detail.
#[test]
fn compare_survey_and_auto_advance_by_keyboard() {
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
let rating = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().rating;
let flag = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().flag;
let vis: Vec<u64> = h.app.session.visible_cloned().iter().map(|p| p.0).collect();
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]]}}), t);
let r = h.request("engine.execute", json!({"command": "view.compare"}), t);
assert_eq!(r["result"], json!({"select": vis[0], "candidate": vis[1]}), "{r}");
assert_eq!(h.app.ui.view, crate::state::ViewMode::Compare);
// rating applies to the candidate (active) only
let before = rating(&h, vis[0]);
h.request("ui.key", json!({"key": "2"}), t);
assert_eq!((rating(&h, vis[0]), rating(&h, vis[1])), (before, 2));
// arrows move the candidate; the select stays
h.request("ui.key", json!({"key": "right"}), t);
assert_eq!(h.app.ui.compare, Some((vis[0], vis[2])));
h.request("ui.key", json!({"key": "left"}), t);
assert_eq!(h.app.ui.compare, Some((vis[0], vis[1])));
// Shift+X: reject and advance to the next candidate
h.request("ui.key", json!({"key": "x", "shift": true}), t);
assert_eq!(flag(&h, vis[1]), lightcraft_catalog::Flag::Reject);
assert_eq!(h.app.ui.compare, Some((vis[0], vis[2])));
h.request("engine.execute", json!({"command": "compare.swap"}), t);
assert_eq!(h.app.ui.compare, Some((vis[2], vis[0])));
h.request("engine.execute", json!({"command": "compare.makeSelect"}), t);
assert_eq!(h.app.ui.compare.map(|c| c.0), Some(vis[0]));
// Survey: the selection tiled; keys hit the active photo, auto-advance walks the survey
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[3], vis[4], vis[5]], "active": vis[3]}}), t);
let r = h.request("engine.execute", json!({"command": "view.survey"}), t);
assert_eq!(r["result"]["photos"], 3);
h.request("engine.execute", json!({"command": "view.autoAdvance"}), t);
assert!(h.app.ui.auto_advance);
h.request("ui.key", json!({"key": "5"}), t);
h.request("ui.key", json!({"key": "p"}), t);
assert_eq!((rating(&h, vis[3]), flag(&h, vis[4])), (5, lightcraft_catalog::Flag::Pick));
assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(vis[5]));
assert_eq!(h.app.session.selection.ids.len(), 3, "the survey keeps its selection");
let img = h.snapshot(SETTLE);
assert_eq!(img.size, [1000, 700]);
// Detail: auto-advance moves to the next photo
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[6]]}}), t);
h.request("ui.set", json!({"view": "detail"}), t);
h.request("ui.key", json!({"key": "u"}), t);
assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(vis[7]));
// Escape leaves the culling views for Detail
h.request("ui.set", json!({"view": "survey"}), t);
h.request("ui.key", json!({"key": "escape"}), t);
assert_eq!(h.app.ui.view, crate::state::ViewMode::Detail);
// let in-flight renders finish: worker threads must not outlive the test process' TLS
h.settle(SETTLE);
}
/// The filter bar drives `library.filter` and saves the view as a smart album.
#[test]
fn filter_bar_filters_and_saves_a_smart_album() {
let mut h = demo([1400.0, 800.0]);
let t = Duration::from_secs(10);
let all = h.app.session.visible_cloned().len();
h.request("engine.execute", json!({"command": "view.filterBar"}), t);
assert!(h.app.ui.filter_bar);
for w in ["filter:star3", "filter:pick", "filter:label-red", "filter:label-red"] {
let r = h.request("ui.clickWidget", json!({"id": w}), t);
assert_eq!(r["ok"], true, "{w}: {r}");
}
let f = h.app.session.filter.clone();
assert_eq!((f.rating, f.flag, f.label), (3, Some(lightcraft_catalog::Flag::Pick), None), "a second click clears the label");
let n = h.app.session.visible_cloned().len();
assert!(n > 0 && n < all);
h.request("ui.clickWidget", json!({"id": "button:filterSave"}), t);
assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::NewSmartAlbum { .. })));
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
let smart = h.app.session.catalog.albums().find(|a| a.is_smart()).expect("smart album").id;
assert_eq!(h.app.session.catalog.album_count(smart), n);
h.request("ui.clickWidget", json!({"id": "button:filterClear"}), t);
assert_eq!(h.app.session.filter, Default::default());
assert_eq!(h.app.session.visible_cloned().len(), all);
h.settle(SETTLE);
}
#[test]
fn screenshot_request_is_answered_without_a_window() {
let mut h = demo([800.0, 500.0]);
let r = h.request("ui.screenshot", json!({}), Duration::from_secs(20));
let r = h.request("ui.screenshot", json!({}), SETTLE);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(r["result"]["width"], 800);
assert_eq!(r["result"]["height"], 500);
let r = h.request("ui.resize", json!({"width": 640, "height": 400}), Duration::from_secs(5));
assert_eq!(r["ok"], true);
let r = h.request("ui.screenshot", json!({"headless": true}), Duration::from_secs(20));
let r = h.request("ui.screenshot", json!({"headless": true}), SETTLE);
assert_eq!(r["result"]["width"], 640, "{r}");
}
}

View File

@ -29,6 +29,7 @@ pub enum Icon {
GridSquare,
Single,
Compare,
Survey,
Sort,
Star,
StarFilled,
@ -45,6 +46,8 @@ pub enum Icon {
Plus,
Minus,
Album,
SmartAlbum,
Stack,
Folder,
Photos,
Clock,
@ -242,9 +245,15 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) {
pen.rect_fill(10.5, 10.5, 16.5, 16.0, 0.5);
}
Single => pen.rect_fill(3.0, 5.0, 17.0, 15.0, 0.5),
Survey => {
pen.rect(2.5, 3.5, 9.0, 9.0, 1.0);
pen.rect(11.0, 3.5, 17.5, 9.0, 1.0);
pen.rect(2.5, 11.0, 9.0, 16.5, 1.0);
pen.rect(11.0, 11.0, 17.5, 16.5, 1.0);
}
Compare => {
pen.rect_fill(3.0, 5.0, 9.5, 15.0, 0.5);
pen.rect_fill(10.5, 5.0, 17.0, 15.0, 0.5);
pen.rect_fill(2.0, 5.0, 8.8, 15.0, 0.5);
pen.rect_fill(11.2, 5.0, 18.0, 15.0, 0.5);
}
Sort => {
pen.line(&[(3.0, 5.0), (17.0, 5.0)]);
@ -304,6 +313,17 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) {
pen.rect(3.0, 5.0, 15.0, 17.0, 1.0);
pen.line(&[(6.0, 2.5), (17.5, 2.5), (17.5, 14.0)]);
}
Stack => {
pen.rect(2.5, 7.0, 13.5, 17.5, 1.0);
pen.line(&[(5.0, 4.5), (16.0, 4.5), (16.0, 14.5)]);
pen.line(&[(7.5, 2.0), (18.5, 2.0), (18.5, 11.5)]);
}
SmartAlbum => {
pen.rect(3.0, 5.0, 15.0, 17.0, 1.0);
pen.line(&[(6.0, 2.5), (17.5, 2.5), (17.5, 14.0)]);
// a small funnel: the album is a saved filter
pen.closed(&[(5.5, 8.5), (12.5, 8.5), (10.0, 11.5), (10.0, 14.5), (8.0, 13.5), (8.0, 11.5)]);
}
Folder => pen.closed(&[(2.5, 5.0), (8.0, 5.0), (9.5, 7.0), (17.5, 7.0), (17.5, 15.5), (2.5, 15.5)]),
Photos => {
pen.rect(2.5, 4.0, 17.5, 16.0, 1.5);

View File

@ -8,6 +8,7 @@
pub mod control;
pub mod headless;
pub mod icons;
pub mod menubar;
pub mod menus;
pub mod merge;
pub mod panels;
@ -64,8 +65,11 @@ pub struct LightcraftApp {
pub perf: Perf,
/// macOS: the host draws the traffic lights over our top bar.
pub integrated_titlebar: bool,
/// The host installed a native menu bar.
/// The host installed a native menu bar (no in-window menus then).
pub native_menu: bool,
/// Shortcuts the native menu bar currently handles (`Cmd+Z`, `G`…): the egui shortcut handler
/// leaves them alone so nothing fires twice.
pub native_shortcuts: std::collections::HashSet<String>,
/// The host is [`headless::Headless`] (it answers viewport screenshot commands itself).
pub headless_host: bool,
control_rx: Option<Receiver<ControlRequest>>,
@ -104,6 +108,7 @@ impl LightcraftApp {
perf: Perf::default(),
integrated_titlebar: false,
native_menu: false,
native_shortcuts: Default::default(),
headless_host: false,
control_rx: None,
pending_screenshots: vec![],
@ -350,10 +355,13 @@ impl LightcraftApp {
}
panels::bottombar::show(self, ui);
let t = theme::Tokens::get(&ctx);
let bg = if matches!(self.ui.view, state::ViewMode::Detail | state::ViewMode::Compare) { t.canvas } else { t.grid_bg };
let bg =
if matches!(self.ui.view, state::ViewMode::Detail | state::ViewMode::Compare | state::ViewMode::Survey) { t.canvas } else { t.grid_bg };
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(bg)).show(ui, |ui| match self.ui.view {
state::ViewMode::PhotoGrid | state::ViewMode::SquareGrid => panels::grid::show(self, ui),
state::ViewMode::Detail | state::ViewMode::Compare => panels::detail::show(self, ui),
state::ViewMode::Detail => panels::detail::show(self, ui),
state::ViewMode::Compare => panels::compare::show_compare(self, ui),
state::ViewMode::Survey => panels::compare::show_survey(self, ui),
});
panels::dialogs::show(self, &ctx);
panels::toast(self, &ctx);

View File

@ -0,0 +1,580 @@
//! The menu bar model: File, Edit, View, Photo, Window, Help, generated from the command registry
//! (engine commands with menu paths + [`crate::menus::UI_COMMANDS`]) with live labels, shortcuts,
//! enabled and checked state.
//!
//! One model drives every menu surface: the native macOS menu bar (built by the desktop host), the
//! in-window menu bar (web, Windows, Linux — [`show_in_window`]) and the control channel
//! (`ui.menu.tree`). Items are a command id plus parameters, run through [`run_item`] so they
//! behave exactly like their keyboard shortcuts.
use serde::Serialize;
use serde_json::{Value, json};
use crate::LightcraftApp;
use crate::menus::MenuEntry;
use crate::state::{RightPanel, ViewMode};
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum MenuNode {
Item {
id: String,
#[serde(skip_serializing_if = "Value::is_null")]
params: Value,
label: String,
#[serde(skip_serializing_if = "Option::is_none")]
shortcut: Option<String>,
enabled: bool,
/// `Some` for toggles and radio-style choices.
#[serde(skip_serializing_if = "Option::is_none")]
checked: Option<bool>,
},
Separator,
Submenu {
label: String,
children: Vec<MenuNode>,
},
}
impl MenuNode {
/// `id|params` — a stable key for one item (native menu ids).
pub fn key(id: &str, params: &Value) -> String {
if params.is_null() { id.to_string() } else { format!("{id}|{params}") }
}
}
/// Top-level menus in order (the macOS app menu is added by the host).
pub const MENUS: &[&str] = &["File", "Edit", "View", "Photo", "Window", "Help"];
/// Order and grouping per menu: command ids, `---` separators and `@Submenu` placeholders.
/// Entries with a menu path that aren't listed are appended at the end of their menu.
const LAYOUT: &[(&str, &[&str])] = &[
(
"File",
&[
"file.addPhotos",
"---",
"dialog.newAlbum",
"dialog.newFolder",
"dialog.newSmartAlbum",
"---",
"file.importPresets",
"file.exportPresets",
"---",
"dialog.export",
"app.exportPrevious",
"---",
"library.toggleAutoWriteXmp",
"---",
],
),
(
"Edit",
&["edit.undo", "edit.redo", "---", "develop.copy", "dialog.copySettings", "develop.paste", "---", "library.selectAll", "library.selectNone"],
),
(
"View",
&[
"view.photoGrid",
"view.squareGrid",
"view.detail",
"view.compare",
"view.survey",
"---",
"view.leftPanel",
"view.filmstrip",
"view.histogram",
"---",
"view.showOriginal",
"view.beforeAfter",
"view.beforeAfterSplit",
"---",
"view.zoomIn",
"view.zoomOut",
"view.zoomToggle",
"view.zoomFit",
"view.zoom100",
"---",
"view.clipping",
"view.maskOverlay",
"view.cropOverlay",
"---",
"@Sort",
"@Stacks",
"view.filterBar",
"library.clearFilter",
"---",
"compare.swap",
"compare.makeSelect",
],
),
(
"Photo",
&[
"@Add to Album",
"---",
"@Set Rating",
"@Set Flag",
"@Set Color Label",
"view.autoAdvance",
"---",
"photo.rotateLeft",
"photo.rotateRight",
"photo.flipHorizontal",
"photo.flipVertical",
"---",
"version.create",
"photo.virtualCopy",
"@Stack",
"@Photo Merge",
"---",
"develop.auto",
"develop.treatment",
"develop.reset",
"dialog.createPreset",
"---",
"photo.saveMetadataToFile",
"photo.readMetadataFromFile",
"app.showInFinder",
"---",
"photo.delete",
],
),
(
"Window",
&[
"panel.edit",
"panel.crop",
"panel.remove",
"panel.masking",
"panel.redeye",
"---",
"panel.presets",
"panel.versions",
"panel.activity",
"panel.info",
"panel.keywords",
"---",
"@Edit Sections",
"@Tools",
],
),
("Help", &["app.shortcuts", "---", "app.about"]),
];
/// Registry entries that are reached another way (parameterized commands are expanded into
/// submenus below; others duplicate a dialog command).
const HIDDEN: &[&str] = &[
"photo.rate",
"photo.flag",
"photo.pick",
"photo.reject",
"photo.unflag",
"photo.label",
"library.sort",
"album.addPhotos",
"album.create",
"library.import",
"preset.create",
];
fn item(id: &str, params: Value, label: impl Into<String>, shortcut: Option<&str>, enabled: bool, checked: Option<bool>) -> MenuNode {
MenuNode::Item { id: id.into(), params, label: label.into(), shortcut: shortcut.map(str::to_string), enabled, checked }
}
/// Checked state of toggles and radio items.
pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> {
let u = &app.ui;
let panel = |p: RightPanel| Some(u.right == p);
match id {
"view.photoGrid" => Some(u.view == ViewMode::PhotoGrid),
"view.squareGrid" => Some(u.view == ViewMode::SquareGrid),
"view.detail" => Some(u.view == ViewMode::Detail),
"view.compare" => Some(u.view == ViewMode::Compare),
"view.survey" => Some(u.view == ViewMode::Survey),
"view.leftPanel" => Some(u.left_panel),
"view.filmstrip" => Some(u.filmstrip),
"view.histogram" => Some(u.histogram),
"view.clipping" => Some(u.show_clipping),
"view.maskOverlay" => Some(u.mask_overlay),
"view.showOriginal" => Some(u.before_after == crate::state::BeforeAfter::Original),
"view.beforeAfter" => Some(u.before_after == crate::state::BeforeAfter::SideBySide),
"view.beforeAfterSplit" => Some(u.before_after == crate::state::BeforeAfter::Split),
"view.autoAdvance" => Some(u.auto_advance),
"view.filterBar" => Some(u.filter_bar),
"panel.edit" => panel(RightPanel::Edit),
"panel.crop" => panel(RightPanel::Crop),
"panel.remove" => panel(RightPanel::Remove),
"panel.masking" => panel(RightPanel::Masking),
"panel.redeye" => panel(RightPanel::RedEye),
"panel.versions" => panel(RightPanel::Versions),
"panel.activity" => panel(RightPanel::Activity),
"panel.info" => panel(RightPanel::Info),
"panel.keywords" => panel(RightPanel::Keywords),
"panel.presets" => Some(u.presets),
"library.toggleAutoWriteXmp" => Some(app.session.xmp.auto_write),
_ => None,
}
}
/// Labels that follow the state ("Undo Exposure", "Delete 3 Photos").
fn live_label(app: &LightcraftApp, id: &str, label: &str) -> String {
let n = app.session.selection.ids.len();
match id {
"edit.undo" => app.session.undo.last().map(|e| format!("Undo {}", e.label)).unwrap_or_else(|| "Undo".into()),
"edit.redo" => app.session.redo.last().map(|e| format!("Redo {}", e.label)).unwrap_or_else(|| "Redo".into()),
"photo.delete" if n > 1 => format!("Delete {n} Photos"),
"photo.virtualCopy" if n > 1 => format!("Create {n} Virtual Copies"),
_ => label.to_string(),
}
}
/// The parameterized submenus.
fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
let active = app.session.active().and_then(|id| app.session.catalog.photo(id).cloned());
let has = active.is_some();
Some(match name {
"Set Rating" => (0..=5u8)
.map(|r| {
let label = if r == 0 { "None".to_string() } else { "★".repeat(r as usize) };
let sc = ["0", "1", "2", "3", "4", "5"][r as usize];
item("photo.rate", json!({"rating": r}), label, Some(sc), has, Some(active.as_ref().is_some_and(|p| p.rating == r)))
})
.collect(),
"Set Flag" => [
("pick", "Pick", "P", lightcraft_catalog::Flag::Pick),
("reject", "Reject", "X", lightcraft_catalog::Flag::Reject),
("none", "Unflagged", "U", lightcraft_catalog::Flag::None),
]
.into_iter()
.map(|(f, label, sc, flag)| {
item("photo.flag", json!({"flag": f}), label, Some(sc), has, Some(active.as_ref().is_some_and(|p| p.flag == flag)))
})
.collect(),
"Set Color Label" => {
let mut v: Vec<MenuNode> = lightcraft_catalog::ColorLabel::ALL
.iter()
.zip([Some("6"), Some("7"), Some("8"), Some("9"), None])
.map(|(l, sc)| {
let name = format!("{l:?}");
item(
"photo.label",
json!({"label": name.to_lowercase()}),
name,
sc,
has,
Some(active.as_ref().is_some_and(|p| p.label == Some(*l))),
)
})
.collect();
v.push(MenuNode::Separator);
v.push(item("photo.label", json!({"label": "none"}), "None", None, has, Some(active.as_ref().is_some_and(|p| p.label.is_none()))));
v
}
"Sort" => {
use lightcraft_catalog::SortKey::*;
let cur = app.session.sort;
let mut v: Vec<MenuNode> = [
("Capture Date", CaptureDate, "captureDate"),
("Import Date", ImportDate, "importDate"),
("Modified Date", EditDate, "editDate"),
("File Name", FileName, "fileName"),
("Rating", Rating, "rating"),
("File Size", FileSize, "fileSize"),
]
.into_iter()
.map(|(label, key, k)| item("library.sort", json!({"key": k}), label, None, true, Some(cur.key == key)))
.collect();
v.push(MenuNode::Separator);
v.push(item("library.sort", json!({"ascending": true}), "Ascending", None, true, Some(cur.ascending)));
v.push(item("library.sort", json!({"ascending": false}), "Descending", None, true, Some(!cur.ascending)));
v
}
"Add to Album" => {
let sel = !app.session.selection.ids.is_empty() || has;
let mut albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.name.clone(), a.id.0)).collect();
albums.sort_by_key(|(n, _)| n.to_lowercase());
let mut v: Vec<MenuNode> =
albums.into_iter().map(|(name, id)| item("album.addPhotos", json!({"id": id}), name, None, sel, None)).collect();
if !v.is_empty() {
v.push(MenuNode::Separator);
}
v.push(item("dialog.newAlbum", Value::Null, "New Album…", None, true, None));
v
}
_ => return None,
})
}
fn node(app: &LightcraftApp, e: &MenuEntry) -> MenuNode {
item(&e.id, Value::Null, live_label(app, &e.id, &e.label), e.shortcut.as_deref(), e.enabled, checked(app, &e.id))
}
/// Drop leading, trailing and doubled separators (also inside submenus) and empty submenus.
fn tidy(v: Vec<MenuNode>) -> Vec<MenuNode> {
let mut out: Vec<MenuNode> = Vec::with_capacity(v.len());
for n in v {
let n = match n {
MenuNode::Submenu { label, children } => {
let children = tidy(children);
if children.is_empty() {
continue;
}
MenuNode::Submenu { label, children }
}
n => n,
};
if n == MenuNode::Separator && out.last().is_none_or(|l| *l == MenuNode::Separator) {
continue;
}
out.push(n);
}
while out.last() == Some(&MenuNode::Separator) {
out.pop();
}
out
}
/// The whole menu bar: (title, items) per menu in [`MENUS`] order.
pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec<MenuNode>)> {
let entries: Vec<MenuEntry> = crate::menus::menu_entries(app).into_iter().filter(|e| !HIDDEN.contains(&e.id.as_str())).collect();
let mut used = vec![false; entries.len()];
let mut bar = Vec::new();
for title in MENUS {
let layout = LAYOUT.iter().find(|(t, _)| t == title).map(|(_, l)| *l).unwrap_or(&[]);
let mut items = Vec::new();
let sub = |name: &str, used: &mut [bool]| -> Vec<MenuNode> {
let mut children = expanded(app, name).unwrap_or_default();
let mut dialogs = Vec::new();
for (i, e) in entries.iter().enumerate() {
if !used[i] && e.menu.len() == 2 && e.menu[0] == *title && e.menu[1] == name {
used[i] = true;
// commands that open a dialog go last, after a separator
if e.label.ends_with('…') { dialogs.push(node(app, e)) } else { children.push(node(app, e)) }
}
}
if !dialogs.is_empty() {
children.push(MenuNode::Separator);
children.extend(dialogs);
}
children
};
for slot in layout {
if *slot == "---" {
items.push(MenuNode::Separator);
} else if let Some(name) = slot.strip_prefix('@') {
let children = sub(name, &mut used);
items.push(MenuNode::Submenu { label: name.to_string(), children });
} else if let Some(i) = entries.iter().position(|e| e.id == *slot && e.menu.first().map(String::as_str) == Some(title)) {
used[i] = true;
items.push(node(app, &entries[i]));
}
}
// everything else that names this menu
items.push(MenuNode::Separator);
let mut extra_subs: Vec<String> = Vec::new();
for (i, e) in entries.iter().enumerate() {
if used[i] || e.menu.first().map(String::as_str) != Some(title) {
continue;
}
if e.menu.len() == 1 {
used[i] = true;
items.push(node(app, e));
} else if !extra_subs.contains(&e.menu[1]) {
extra_subs.push(e.menu[1].clone());
}
}
for name in extra_subs {
let children = sub(&name, &mut used);
items.push(MenuNode::Submenu { label: name, children });
}
bar.push((title.to_string(), tidy(items)));
}
bar
}
/// Run a menu item. Rating, flag and label items behave like their keys (the active photo only in
/// Compare/Survey; Auto Advance moves on).
pub fn run_item(app: &mut LightcraftApp, id: &str, params: Value) -> Result<Value, String> {
let mut params = if params.is_null() { json!({}) } else { params };
let culling_cmd = matches!(id, "photo.rate" | "photo.flag" | "photo.label" | "photo.pick" | "photo.reject" | "photo.unflag");
if culling_cmd {
crate::panels::compare::target_active(app, &mut params);
}
let r = app.run(id, params);
if culling_cmd && r.is_ok() && app.ui.auto_advance {
crate::panels::compare::advance(app);
}
r
}
/// Human-readable shortcut text for menus: `Cmd+Shift+Z` → `⌘⇧Z` on macOS, `Ctrl+Shift+Z` elsewhere.
pub fn shortcut_text(sc: &str, mac: bool) -> String {
if mac {
let mut mods = String::new();
let mut key = "";
for part in sc.split('+') {
match part {
"Ctrl" => mods.push('⌃'),
"Alt" => mods.push('⌥'),
"Shift" => mods.push('⇧'),
"Cmd" => mods.push('⌘'),
k => key = k,
}
}
let key = match key {
"Delete" => "⌫",
"Escape" => "⎋",
"Left" => "←",
"Right" => "→",
k => k,
};
format!("{mods}{key}")
} else {
sc.replace("Cmd", "Ctrl")
}
}
/// Width of the in-window menu bar's titles.
pub fn bar_width(ui: &egui::Ui) -> f32 {
let t = crate::theme::Tokens::get(ui.ctx());
MENUS.iter().map(|m| ui.painter().layout_no_wrap(m.to_string(), t.font(13.0), t.text).size().x + 16.0).sum::<f32>()
+ ui.spacing().item_spacing.x * MENUS.len() as f32
}
/// The in-window menu bar (hosts without a native one): one dropdown per menu, or a single
/// "Menu" button when the space is too narrow. Returns the width used.
pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32) -> f32 {
let t = crate::theme::Tokens::get(ui.ctx());
let bar = menu_bar(app);
let font = t.font(13.0);
let widths: Vec<f32> = bar.iter().map(|(title, _)| ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text).size().x + 16.0).collect();
let total: f32 = widths.iter().sum();
let mut clicked: Option<(String, Value)> = None;
let start = ui.cursor().left();
let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;
if total <= max_width {
for (title, items) in &bar {
let r = ui.add(egui::Button::new(egui::RichText::new(title).font(font.clone()).color(t.text_label)).frame(false));
crate::widgets::register(ui.ctx(), format!("menu:{title}"), r.rect);
egui::Popup::menu(&r).show(|ui| nodes_ui(ui, items, mac, &mut clicked));
}
} else {
let r = ui.add(egui::Button::new(egui::RichText::new("Menu").font(font.clone()).color(t.text_label)).frame(false));
crate::widgets::register(ui.ctx(), "menu:all", r.rect);
egui::Popup::menu(&r).show(|ui| {
for (title, items) in &bar {
ui.menu_button(title, |ui| nodes_ui(ui, items, mac, &mut clicked));
}
});
}
if let Some((id, params)) = clicked {
let _ = run_item(app, &id, params);
}
ui.cursor().left() - start
}
fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Option<(String, Value)>) {
ui.set_min_width(220.0);
for n in nodes {
match n {
MenuNode::Separator => {
ui.separator();
}
MenuNode::Submenu { label, children } => {
ui.menu_button(format!(" {label}"), |ui| nodes_ui(ui, children, mac, clicked));
}
MenuNode::Item { id, params, label, shortcut, enabled, checked } => {
// a gutter for check marks, like native menus
let mut b = egui::Button::new(format!(" {label}"));
if let Some(sc) = shortcut {
b = b.shortcut_text(shortcut_text(sc, mac));
}
let r = ui.add_enabled(*enabled, b);
if *checked == Some(true) {
let t = crate::theme::Tokens::get(ui.ctx());
let c = egui::Rect::from_center_size(egui::pos2(r.rect.left() + 11.0, r.rect.center().y), egui::vec2(13.0, 13.0));
crate::icons::paint(ui.painter(), c, crate::icons::Icon::Check, if *enabled { t.text } else { t.text_disabled });
}
if r.clicked() {
*clicked = Some((id.clone(), params.clone()));
ui.close();
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn app() -> LightcraftApp {
LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default())
}
fn find<'a>(nodes: &'a [MenuNode], id: &str) -> Option<&'a MenuNode> {
nodes.iter().find_map(|n| match n {
MenuNode::Item { id: i, .. } if i == id => Some(n),
MenuNode::Submenu { children, .. } => find(children, id),
_ => None,
})
}
#[test]
fn bar_follows_the_registry_and_state() {
let mut app = app();
let bar = menu_bar(&app);
let titles: Vec<&str> = bar.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(titles, MENUS);
let all: Vec<MenuNode> = bar.iter().flat_map(|(_, v)| v.clone()).collect();
// every engine command with a menu path is reachable (parameterized ones via submenus)
for c in lightcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty() && !HIDDEN.contains(&c.id)) {
assert!(find(&all, c.id).is_some(), "{} missing from the menu bar", c.id);
}
for id in ["photo.rate", "photo.flag", "photo.label", "library.sort", "album.addPhotos", "view.compare", "stack.group", "photo.virtualCopy"] {
assert!(find(&all, id).is_some(), "{id}");
}
// no doubled or dangling separators
fn check(v: &[MenuNode]) {
assert_ne!(v.first(), Some(&MenuNode::Separator));
assert_ne!(v.last(), Some(&MenuNode::Separator));
for w in v.windows(2) {
assert!(!(w[0] == MenuNode::Separator && w[1] == MenuNode::Separator));
}
for n in v {
if let MenuNode::Submenu { children, .. } = n {
check(children);
}
}
}
bar.iter().for_each(|(_, v)| check(v));
// live state: undo label, enabled, checked
let Some(MenuNode::Item { enabled, .. }) = find(&all, "edit.undo") else { panic!() };
assert!(!enabled);
run_item(&mut app, "photo.rate", json!({"rating": 3})).unwrap();
let bar = menu_bar(&app);
let all: Vec<MenuNode> = bar.iter().flat_map(|(_, v)| v.clone()).collect();
let Some(MenuNode::Item { label, enabled, .. }) = find(&all, "edit.undo") else { panic!() };
assert_eq!((label.as_str(), *enabled), ("Undo Set Rating", true));
let rated: Vec<_> = all
.iter()
.filter_map(|n| match n {
MenuNode::Submenu { label, children } if label == "Set Rating" => Some(children.clone()),
_ => None,
})
.flatten()
.filter(|n| matches!(n, MenuNode::Item { checked: Some(true), .. }))
.collect();
assert!(matches!(&rated[..], [MenuNode::Item { params, .. }] if params["rating"] == 3));
let Some(MenuNode::Item { checked, .. }) = find(&all, "view.filmstrip") else { panic!() };
assert_eq!(*checked, Some(app.ui.filmstrip));
}
#[test]
fn shortcut_text_per_platform() {
assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⌘⇧Z");
assert_eq!(shortcut_text("Cmd+Shift+Z", false), "Ctrl+Shift+Z");
assert_eq!(shortcut_text("Delete", true), "⌫");
}
}

View File

@ -14,6 +14,11 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("view.photoGrid", "Photo Grid", Some("G"), "View"),
("view.squareGrid", "Square Grid", Some("Shift+G"), "View"),
("view.detail", "Detail", Some("D"), "View"),
("view.compare", "Compare", Some("Shift+C"), "View"),
("view.survey", "Survey", Some("N"), "View"),
("compare.swap", "Swap Compare Photos", None, "View"),
("compare.makeSelect", "Make Candidate the Select", None, "View"),
("view.autoAdvance", "Auto Advance", None, "Photo"),
("view.filmstrip", "Filmstrip", Some("/"), "View"),
("view.leftPanel", "My Photos Panel", Some("Cmd+Shift+L"), "View"),
("view.beforeAfter", "Compare Before and After", Some("Y"), "View"),
@ -31,41 +36,43 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"),
("view.visualizeSpots", "Visualize Spots", Some("A"), "View"),
("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"),
("view.back", "Back to Grid", Some("Escape"), "View"),
("view.filterBar", "Filter", None, "View"),
("panel.edit", "Edit", Some("E"), "View"),
("panel.crop", "Crop & Rotate", Some("C"), "View"),
("panel.remove", "Remove", Some("H"), "View"),
("panel.masking", "Masking", Some("M"), "View"),
("panel.redeye", "Red Eye", None, "View"),
("panel.presets", "Presets", Some("Shift+P"), "View"),
("panel.info", "Info", Some("I"), "View"),
("panel.keywords", "Keywords", Some("K"), "View"),
("panel.versions", "Versions", Some("Shift+V"), "View"),
("panel.activity", "History", None, "View"),
("panel.close", "Close Panel", None, "View"),
("section.light", "Light", Some("Cmd+Alt+1"), "View"),
("section.color", "Color", Some("Cmd+Alt+2"), "View"),
("section.effects", "Effects", Some("Cmd+Alt+3"), "View"),
("section.detail", "Detail", Some("Cmd+Alt+4"), "View"),
("section.optics", "Optics", Some("Cmd+Alt+5"), "View"),
("tool.brush", "Brush", Some("B"), "View"),
("tool.linear", "Linear Gradient", Some("L"), "View"),
("tool.radial", "Radial Gradient", Some("R"), "View"),
("tool.wbPicker", "White Balance Selector", Some("W"), "View"),
("tool.none", "No Tool", None, "View"),
("view.back", "Back to Grid", Some("Escape"), ""),
("view.filterBar", "Filter Bar", Some("Shift+F"), "View"),
("panel.edit", "Edit", Some("E"), "Window"),
("panel.crop", "Crop & Rotate", Some("C"), "Window"),
("panel.remove", "Remove", Some("H"), "Window"),
("panel.masking", "Masking", Some("M"), "Window"),
("panel.redeye", "Red Eye", None, "Window"),
("panel.presets", "Presets", Some("Shift+P"), "Window"),
("panel.info", "Info", Some("I"), "Window"),
("panel.keywords", "Keywords", Some("K"), "Window"),
("panel.versions", "Versions", Some("Shift+V"), "Window"),
("panel.activity", "History", None, "Window"),
("panel.close", "Close Panel", None, ""),
("section.light", "Light", Some("Cmd+Alt+1"), "Window>Edit Sections"),
("section.color", "Color", Some("Cmd+Alt+2"), "Window>Edit Sections"),
("section.effects", "Effects", Some("Cmd+Alt+3"), "Window>Edit Sections"),
("section.detail", "Detail", Some("Cmd+Alt+4"), "Window>Edit Sections"),
("section.optics", "Optics", Some("Cmd+Alt+5"), "Window>Edit Sections"),
("tool.brush", "Brush", Some("B"), "Window>Tools"),
("tool.linear", "Linear Gradient", Some("L"), "Window>Tools"),
("tool.radial", "Radial Gradient", Some("R"), "Window>Tools"),
("tool.wbPicker", "White Balance Selector", Some("W"), "Window>Tools"),
("tool.none", "No Tool", None, ""),
("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"),
("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"),
("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"),
("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"),
("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Photo"),
("dialog.autoStack", "Auto-Stack by Capture Time…", None, "Photo>Stack"),
("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Edit"),
("dialog.export", "Export…", Some("Cmd+Shift+E"), "File"),
("dialog.mergeHdr", "Photo Merge ▸ HDR…", Some("Ctrl+H"), "Photo"),
("dialog.mergePanorama", "Photo Merge ▸ Panorama…", Some("Ctrl+M"), "Photo"),
("dialog.mergeHdrPanorama", "Photo Merge ▸ HDR Panorama…", None, "Photo"),
("dialog.mergeHdr", "HDR…", Some("Ctrl+H"), "Photo>Photo Merge"),
("dialog.mergePanorama", "Panorama…", Some("Ctrl+M"), "Photo>Photo Merge"),
("dialog.mergeHdrPanorama", "HDR Panorama…", None, "Photo>Photo Merge"),
("file.addPhotos", "Add Photos…", Some("Cmd+Shift+I"), "File"),
("file.importPresets", "Import Presets…", None, "File"),
("file.exportPresets", "Export Presets…", None, "File"),
("app.about", "About LightCraft", None, "LightCraft"),
("app.about", "About LightCraft", None, "Help"),
("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"),
("app.export", "Export Now", None, ""),
("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"),
@ -105,11 +112,25 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.view = ViewMode::Detail;
Ok(Value::Null)
}
"view.compare" => crate::panels::compare::enter_compare(app),
"view.survey" => {
app.ui.view = ViewMode::Survey;
Ok(json!({"photos": crate::panels::compare::survey_photos(app).len()}))
}
"compare.swap" => crate::panels::compare::swap(app),
"compare.makeSelect" => crate::panels::compare::make_select(app),
"view.autoAdvance" => {
app.ui.auto_advance = !app.ui.auto_advance;
app.toast(&ctx, if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" });
Ok(json!({"autoAdvance": app.ui.auto_advance}))
}
"view.back" => {
if app.ui.dialog.is_some() {
app.ui.dialog = None;
} else if !app.ui.tool.is_empty() {
app.ui.tool.clear();
} else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) {
app.ui.view = ViewMode::Detail;
} else if app.ui.view == ViewMode::Detail {
app.ui.view = ViewMode::PhotoGrid;
}
@ -203,8 +224,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(Value::Null)
}
"view.filterBar" => {
app.ui.left_panel = true;
Ok(Value::Null)
app.ui.filter_bar = !app.ui.filter_bar;
if app.ui.filter_bar && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) {
app.ui.view = ViewMode::PhotoGrid;
}
Ok(json!({"filterBar": app.ui.filter_bar}))
}
"panel.edit" => {
panel(app, &ctx, RightPanel::Edit, "Edit");
@ -297,6 +321,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.dialog = Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: false });
Ok(Value::Null)
}
"dialog.autoStack" => {
app.ui.dialog = Some(Dialog::AutoStack { gap: p.get("gap").and_then(Value::as_f64).unwrap_or(60.0) as f32 });
Ok(Value::Null)
}
"dialog.newSmartAlbum" => {
app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into() });
Ok(Value::Null)
}
"dialog.createPreset" => {
app.ui.dialog = Some(Dialog::CreatePreset { name: String::new(), group: "User Presets".into() });
Ok(Value::Null)
@ -418,6 +450,8 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
.is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. }))
}
"file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin),
"view.compare" => app.session.catalog.len() > 1,
"compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare,
s if s.starts_with("dialog.merge") => app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none(),
_ => true,
}
@ -440,7 +474,7 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec<MenuEntry> {
.map(|(id, label, sc, m)| MenuEntry {
id: id.to_string(),
label: label.to_string(),
menu: vec![m.to_string()],
menu: m.split('>').map(str::to_string).collect(),
shortcut: sc.map(str::to_string),
enabled: ui_enabled(app, id),
})

View File

@ -23,6 +23,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
("photoGrid", Icon::GridPhoto, ViewMode::PhotoGrid, "Photo Grid (G)"),
("squareGrid", Icon::GridSquare, ViewMode::SquareGrid, "Square Grid (Shift+G)"),
("detail", Icon::Single, ViewMode::Detail, "Detail (D)"),
("compare", Icon::Compare, ViewMode::Compare, "Compare (Shift+C)"),
("survey", Icon::Survey, ViewMode::Survey, "Survey (N)"),
] {
if icon_button(ui, id, icon, vec2(32.0, 32.0), app.ui.view == mode, true, tip).clicked() {
let _ = app.run(&format!("view.{id}"), json!({}));
@ -45,7 +47,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
pill_ui.spacing_mut().item_spacing.x = 3.0;
let rating = active.as_ref().map(|p| p.rating).unwrap_or(0);
if let Some(r) = stars(&mut pill_ui, "bottom", rating, 19.0) {
let _ = app.run("photo.rate", json!({"rating": r}));
let mut params = json!({"rating": r});
crate::panels::compare::target_active(app, &mut params);
let _ = app.run("photo.rate", params);
}
pill_ui.add_space(6.0);
let flag = active.as_ref().map(|p| p.flag).unwrap_or_default();
@ -64,7 +68,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
};
paint(pill_ui.painter(), r.shrink(3.0), icon, c);
if resp.clicked() {
let _ = app.run("photo.flag", json!({"flag": match f { Flag::Pick => "pick", Flag::Reject => "reject", Flag::None => "none" }}));
let mut params = json!({"flag": match f { Flag::Pick => "pick", Flag::Reject => "reject", Flag::None => "none" }});
crate::panels::compare::target_active(app, &mut params);
let _ = app.run("photo.flag", params);
}
}
// copy / paste settings
@ -102,7 +108,23 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
.layout(egui::Layout::right_to_left(egui::Align::Center)),
);
child.spacing_mut().item_spacing.x = 6.0;
if matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) {
if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey)
&& icon_button(
&mut child,
"autoAdvance",
Icon::ChevronRight,
vec2(30.0, 30.0),
app.ui.auto_advance,
true,
"Auto Advance after rating or flagging",
)
.clicked()
{
let _ = app.run("view.autoAdvance", json!({}));
}
if app.ui.view == ViewMode::Survey {
// no zoom or thumbnail size in Survey
} else if matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) {
if icon_button(
&mut child,
"beforeAfter",

View File

@ -0,0 +1,339 @@
//! Culling views: **Compare** (the "select" and a "candidate" side by side, with synced zoom and
//! pan) and **Survey** (the selected photos tiled). Rating, flag and label keys act on the active
//! photo only here, and with Auto Advance on they move to the next candidate / photo.
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use lightcraft_catalog::{Flag, PhotoId};
use serde_json::{Value, json};
use crate::LightcraftApp;
use crate::icons::{Icon, paint};
use crate::render::Slot;
use crate::state::{ViewMode, Zoom};
use crate::theme::Tokens;
use crate::widgets::register;
/// Most photos a survey shows.
pub const SURVEY_MAX: usize = 48;
/// Compare or Survey.
pub fn culling(app: &LightcraftApp) -> bool {
matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey)
}
/// The (select, candidate) pair, repaired or chosen if needed: the active photo against the next
/// selected photo, else against its neighbour in the view.
pub fn compare_pair(app: &mut LightcraftApp) -> Option<(PhotoId, PhotoId)> {
let exists = |app: &LightcraftApp, id: PhotoId| app.session.catalog.photo(id).is_some_and(|p| !p.deleted);
if let Some((a, b)) = app.ui.compare {
let (a, b) = (PhotoId(a), PhotoId(b));
if a != b && exists(app, a) && exists(app, b) {
return Some((a, b));
}
}
let vis = app.session.visible_cloned();
let sel = app.session.active().or(vis.first().copied())?;
let cand = app.session.selection.ids.iter().copied().find(|x| *x != sel).or_else(|| {
let i = vis.iter().position(|x| *x == sel)?;
vis.get(i + 1).or(i.checked_sub(1).and_then(|j| vis.get(j))).copied()
})?;
app.ui.compare = Some((sel.0, cand.0));
Some((sel, cand))
}
/// Enter Compare with the current selection (the active photo becomes the select).
pub fn enter_compare(app: &mut LightcraftApp) -> Result<Value, String> {
app.ui.compare = None;
let (a, b) = compare_pair(app).ok_or("Compare needs at least two photos")?;
app.ui.view = ViewMode::Compare;
// the candidate is active: rating/flag keys judge it against the select
select_pair(app, a, b, b);
Ok(json!({"select": a.0, "candidate": b.0}))
}
fn select_pair(app: &mut LightcraftApp, a: PhotoId, b: PhotoId, active: PhotoId) {
app.ui.compare = Some((a.0, b.0));
let _ = app.session.execute("library.select", &json!({"ids": [a.0, b.0], "active": active.0}));
}
/// Move the candidate `d` photos through the view (skipping the select); it becomes active.
pub fn compare_step(app: &mut LightcraftApp, d: isize) -> Result<Value, String> {
let (sel, cand) = compare_pair(app).ok_or("nothing to compare")?;
let vis: Vec<PhotoId> = app.session.visible_cloned().into_iter().filter(|x| *x != sel).collect();
let Some(i) = vis.iter().position(|x| *x == cand).or(if vis.is_empty() { None } else { Some(0) }) else { return Ok(Value::Null) };
let j = (i as isize + d).clamp(0, vis.len() as isize - 1) as usize;
select_pair(app, sel, vis[j], vis[j]);
Ok(json!({"candidate": vis[j].0}))
}
pub fn swap(app: &mut LightcraftApp) -> Result<Value, String> {
let (a, b) = compare_pair(app).ok_or("nothing to compare")?;
let active = app.session.active().unwrap_or(b);
select_pair(app, b, a, active);
Ok(Value::Null)
}
/// The candidate becomes the select; the next photo becomes the candidate.
pub fn make_select(app: &mut LightcraftApp) -> Result<Value, String> {
let (_, b) = compare_pair(app).ok_or("nothing to compare")?;
let vis = app.session.visible_cloned();
let i = vis.iter().position(|x| *x == b).unwrap_or(0);
let next = vis.iter().skip(i + 1).chain(vis.iter().take(i).rev()).copied().find(|x| *x != b).ok_or("no next photo")?;
select_pair(app, b, next, next);
Ok(json!({"select": b.0, "candidate": next.0}))
}
/// The photos a survey shows: the selection (in view order), or the active photo alone.
pub fn survey_photos(app: &mut LightcraftApp) -> Vec<PhotoId> {
let vis = app.session.visible_cloned();
let sel = &app.session.selection;
let mut v: Vec<PhotoId> = vis.iter().copied().filter(|x| sel.contains(*x)).collect();
// selected photos hidden in the view (e.g. inside a collapsed stack) come last
let hidden: Vec<PhotoId> = sel.ids.iter().copied().filter(|x| !v.contains(x) && app.session.catalog.photo(*x).is_some()).collect();
v.extend(hidden);
if v.is_empty() {
v.extend(app.session.active());
}
v.truncate(SURVEY_MAX);
v
}
/// Move the active photo `d` steps within the survey.
pub fn survey_step(app: &mut LightcraftApp, d: isize) -> Result<Value, String> {
let v = survey_photos(app);
if v.is_empty() {
return Ok(Value::Null);
}
let i = app.session.selection.active.and_then(|a| v.iter().position(|x| *x == a)).unwrap_or(0);
let j = (i as isize + d).clamp(0, v.len() as isize - 1) as usize;
app.session.selection.active = Some(v[j]);
Ok(json!({"active": v[j].0}))
}
/// After rating/flagging with Auto Advance: next candidate (Compare), next photo in the survey,
/// else the next photo in the view.
pub fn advance(app: &mut LightcraftApp) {
let _ = match app.ui.view {
ViewMode::Compare => compare_step(app, 1),
ViewMode::Survey => survey_step(app, 1),
_ => app.session.execute("library.next", &json!({})).map_err(|e| e.to_string()),
};
}
/// In the culling views, point a photo command at the active photo only (not the whole selection).
pub fn target_active(app: &LightcraftApp, params: &mut Value) {
if culling(app)
&& params.get("ids").is_none()
&& let Some(a) = app.session.active()
{
params["ids"] = json!([a.0]);
}
}
fn area_and_filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui) -> Rect {
let t = Tokens::get(ui.ctx());
let full = ui.max_rect();
let film_h = if app.ui.filmstrip { t.film_h } else { 0.0 };
let canvas = Rect::from_min_max(full.min, pos2(full.right(), full.bottom() - film_h));
app.canvas_rect = Some(canvas);
if app.ui.filmstrip {
super::detail::filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max));
}
canvas
}
/// Draw one photo fitted into `area` (rendered at its display size into `slot`), with its caption
/// strip below. Returns the image rect and the click/drag response.
fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slot, area: Rect, label: &str, zoom: Zoom) -> (Rect, egui::Response) {
let t = Tokens::get(ui.ctx());
let ppp = ui.ctx().pixels_per_point();
let resp = ui.interact(area, egui::Id::new(("cull-tile", slot_index(slot), id.0)), Sense::click_and_drag());
let Some(photo) = app.session.catalog.photo(id).cloned() else { return (area, resp) };
let caption_h = 26.0;
let img_area = Rect::from_min_max(area.min, pos2(area.right(), area.bottom() - caption_h));
let frame = lightcraft_pipeline::geometry::Frame::with_lens(
photo.width.max(1) as usize,
photo.height.max(1) as usize,
&photo.develop,
true,
photo.embedded_lens.as_ref(),
);
let aspect = frame.aspect() as f32;
let native = [photo.width.max(1) as usize, photo.height.max(1) as usize];
let img = super::detail::fit_rect(img_area, aspect, zoom, native, ppp, app.ui.pan);
let want = (img.width().max(img.height()).min(img_area.width().max(img_area.height()) * 4.0) * ppp).min(2560.0) as usize;
let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) };
if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, true) {
app.renderer.request(slot, job, 60);
}
if let Some(job) = app.session.thumb_job(id, 256) {
app.renderer.request(Slot::Thumb(id), job, 30);
}
let p = ui.painter_at(img_area);
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
if let Some(tex) = app.renderer.textures.get(&slot).filter(|x| x.photo == id) {
p.image(tex.tex.id(), img, uv, Color32::WHITE);
} else if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) {
p.image(tex.tex.id(), img, uv, Color32::WHITE);
} else {
p.rect_filled(img.intersect(img_area), 0.0, Color32::from_gray(38));
}
if photo.flag == Flag::Reject {
p.rect_filled(img, 0.0, Color32::from_black_alpha(110));
}
let active = app.session.selection.active == Some(id);
if active {
p.rect_stroke(img.intersect(img_area.shrink(1.0)), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
}
// caption: label · file name · stars · flag
// caption right under the image (at the bottom when zoomed in)
let cap_top = (img.bottom() + 2.0).min(img_area.bottom());
let cap = Rect::from_min_max(pos2(img.left().max(area.left()), cap_top), pos2(area.right(), cap_top + caption_h));
let pt = ui.painter();
let mut x = cap.left() + 4.0;
if !label.is_empty() {
let g = pt.layout_no_wrap(label.to_string(), t.semibold(12.0), if active { t.text } else { t.text_label });
pt.galley(pos2(x, cap.center().y - g.size().y / 2.0), g.clone(), t.text);
x += g.size().x + 10.0;
}
let name = photo.copy_name.as_ref().map(|c| format!("{} ({c})", photo.file_name)).unwrap_or(photo.file_name.clone());
let g = pt.layout_no_wrap(name, t.font(11.5), t.text_dim);
let name_w = g.size().x;
if x + name_w + 110.0 < cap.right() {
pt.galley(pos2(x, cap.center().y - g.size().y / 2.0), g, t.text_dim);
x += name_w + 10.0;
}
for i in 0..5u8 {
let filled = i < photo.rating;
let r = Rect::from_min_size(pos2(x + i as f32 * 13.0, cap.center().y - 6.0), vec2(12.0, 12.0));
if r.right() < cap.right() {
paint(pt, r, if filled { Icon::StarFilled } else { Icon::Star }, if filled { t.star } else { t.icon.gamma_multiply(0.6) });
}
}
x += 5.0 * 13.0 + 4.0;
let fr = Rect::from_min_size(pos2(x, cap.center().y - 7.0), vec2(14.0, 14.0));
match photo.flag {
Flag::Pick if fr.right() < cap.right() => paint(pt, fr, Icon::FlagPick, t.pick),
Flag::Reject if fr.right() < cap.right() => paint(pt, fr, Icon::FlagReject, t.reject),
_ => {}
}
register(ui.ctx(), format!("cull:{}", id.0), img);
(img, resp)
}
fn slot_index(s: Slot) -> u8 {
match s {
Slot::Compare(i) => i,
_ => 255,
}
}
/// Drag-to-pan when zoomed in (shared by both compare panes: zoom and pan are synced).
fn pan(app: &mut LightcraftApp, resp: &egui::Response, img: Rect) {
if app.ui.zoom != Zoom::Fit && resp.dragged() {
let d = resp.drag_delta();
let (px, py) = app.ui.pan;
app.ui.pan = ((px - d.x / img.width().max(1.0)).clamp(0.0, 1.0), (py - d.y / img.height().max(1.0)).clamp(0.0, 1.0));
}
}
pub fn show_compare(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let canvas = area_and_filmstrip(app, ui);
let Some((sel, cand)) = compare_pair(app) else {
super::empty_message(ui, canvas, "Nothing to compare", "Select two photos, then choose View → Compare (Shift+C)");
return;
};
let area = canvas.shrink(18.0);
let half = (area.width() - 16.0) / 2.0;
let panes = [
(sel, Rect::from_min_size(area.min, vec2(half, area.height())), "Select", 0u8),
(cand, Rect::from_min_size(pos2(area.right() - half, area.top()), vec2(half, area.height())), "Candidate", 1u8),
];
let zoom = app.ui.zoom;
for (id, r, label, i) in panes {
let (img, resp) = photo_tile(app, ui, id, Slot::Compare(i), r, label, zoom);
if i == 1 {
app.image_rect = Some(img);
}
pan(app, &resp, img);
if resp.clicked() {
select_pair(app, sel, cand, id);
}
if resp.double_clicked() {
app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(100) } else { Zoom::Fit };
}
resp.context_menu(|ui| {
if ui.button("Swap").clicked() {
let _ = swap(app);
}
if ui.button("Make Candidate the Select").clicked() {
let _ = make_select(app);
}
ui.separator();
super::grid::context_menu(app, ui, id);
});
}
let mid = area.center().x;
ui.painter().line_segment([pos2(mid, area.top()), pos2(mid, area.bottom())], Stroke::new(1.0, Tokens::get(ui.ctx()).divider));
}
/// Columns for `n` tiles of aspect ~3:2 in `area`, maximizing the tile size.
pub fn survey_columns(n: usize, area: Rect) -> usize {
let mut best = (1, 0.0f32);
for cols in 1..=n.max(1) {
let rows = n.div_ceil(cols);
let w = area.width() / cols as f32;
let h = area.height() / rows as f32;
let s = w.min(h * 1.5);
if s > best.1 {
best = (cols, s);
}
}
best.0
}
pub fn show_survey(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let canvas = area_and_filmstrip(app, ui);
let photos = survey_photos(app);
if photos.is_empty() {
super::empty_message(ui, canvas, "Nothing to survey", "Select photos, then choose View → Survey (N)");
return;
}
let area = canvas.shrink(14.0);
let cols = survey_columns(photos.len(), area);
let rows = photos.len().div_ceil(cols);
let (cw, ch) = (area.width() / cols as f32, area.height() / rows as f32);
for (i, id) in photos.iter().enumerate() {
let (c, r) = (i % cols, i / cols);
let cell = Rect::from_min_size(pos2(area.left() + c as f32 * cw, area.top() + r as f32 * ch), vec2(cw, ch)).shrink(6.0);
let (img, resp) = photo_tile(app, ui, *id, Slot::Compare(i as u8), cell, "", Zoom::Fit);
if app.session.selection.active == Some(*id) {
app.image_rect = Some(img);
}
if resp.clicked() {
app.session.selection.active = Some(*id);
}
if resp.double_clicked() {
let _ = app.run("library.select", json!({"ids": [id.0]}));
app.ui.view = ViewMode::Detail;
}
// remove from the survey (deselect) on hover
if resp.hovered() && photos.len() > 1 {
let xr = Rect::from_min_size(pos2(img.right() - 26.0, img.top() + 6.0), vec2(20.0, 20.0));
let xresp = ui.interact(xr, egui::Id::new(("survey-x", id.0)), Sense::click()).on_hover_text("Remove from survey");
ui.painter().circle_filled(xr.center(), 10.0, Color32::from_black_alpha(if xresp.hovered() { 230 } else { 160 }));
paint(ui.painter(), xr.shrink(4.0), Icon::Close, t.text);
if xresp.clicked() {
let _ = app.run("library.select", json!({"ids": [id.0], "mode": "toggle"}));
}
}
resp.context_menu(|ui| super::grid::context_menu(app, ui, *id));
}
let n = photos.len();
let msg =
if app.session.selection.ids.len() > SURVEY_MAX { format!("Showing {n} of {}", app.session.selection.ids.len()) } else { String::new() };
if !msg.is_empty() {
ui.painter().text(pos2(canvas.right() - 16.0, canvas.top() + 10.0), Align2::RIGHT_TOP, msg, t.font(12.0), t.text_dim);
}
}

View File

@ -84,7 +84,7 @@ impl CanvasMap {
}
}
fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], ppp: f32, pan: (f32, f32)) -> Rect {
pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], ppp: f32, pan: (f32, f32)) -> Rect {
let (aw, ah) = (area.width(), area.height());
let (w, h) = match zoom {
Zoom::Fit => {
@ -858,7 +858,7 @@ fn eye_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response
// ------------------------------------------------------------------------ filmstrip
fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
let t = Tokens::get(ui.ctx());
ui.painter().rect_filled(r, 0.0, t.canvas);
ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 4.0)), 0.0, Color32::from_gray(0x20));
@ -906,6 +906,15 @@ fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
if sel {
p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside);
}
if let Some(st) = app.session.catalog.stack_of(*id) {
let text =
if st.collapsed { st.photos.len().to_string() } else { format!("{}/{}", st.position(*id).unwrap_or(0) + 1, st.photos.len()) };
let g = p.layout_no_wrap(text, t.semibold(9.5), Color32::WHITE);
let br = Rect::from_min_size(fr.min + vec2(3.0, 3.0), vec2(g.size().x + 22.0, 15.0));
p.rect_filled(br, 7.5, Color32::from_black_alpha(170));
crate::icons::paint(p, Rect::from_min_size(br.min + vec2(4.0, 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Stack, Color32::WHITE);
p.galley(pos2(br.min.x + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE);
}
}
if resp.clicked() {
let m = ui.input(|i| i.modifiers);

View File

@ -22,6 +22,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::NewAlbum { folder: true, .. } => "Create Folder",
Dialog::NewAlbum { .. } => "Create Album",
Dialog::RenameAlbum { .. } => "Rename Album",
Dialog::NewSmartAlbum { .. } => "Create Smart Album",
Dialog::AutoStack { .. } => "Auto-Stack by Capture Time",
Dialog::CreatePreset { .. } => "Create Preset",
Dialog::CopySettings { .. } => "Choose Edit Settings to Copy",
Dialog::Export { .. } => "Export",
@ -39,6 +41,35 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
.show(ctx, |ui| {
ui.spacing_mut().item_spacing.y = 8.0;
match &mut dlg {
Dialog::AutoStack { gap } => {
ui.label(egui::RichText::new("Stack photos taken within this time of each other:").color(t.text_label));
ui.add(
egui::Slider::new(gap, 0.0..=86400.0)
.logarithmic(true)
.smallest_positive(1.0)
.custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v))
.custom_parser(|s| s.trim().trim_end_matches('s').parse().ok()),
);
let preview = app.session.execute("stack.auto", &json!({"gap": *gap, "preview": true})).unwrap_or_default();
let scope = if app.session.selection.ids.len() > 1 { "the selected photos" } else { "the photos in view" };
ui.label(
egui::RichText::new(format!("Creates {} stacks from {} of {scope}", preview["stacks"], preview["photos"])).color(t.text_dim),
);
}
Dialog::NewSmartAlbum { name } => {
let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY));
r.request_focus();
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
confirm = true;
}
let rules = app.session.view_rules();
let n = app.session.catalog.query(&rules, &Default::default()).len();
ui.label(egui::RichText::new(format!("Matches: {}", rules.describe())).color(t.text_label));
ui.label(
egui::RichText::new(format!("{n} photo{} now · updates automatically as photos change", if n == 1 { "" } else { "s" }))
.color(t.text_dim),
);
}
Dialog::NewAlbum { name, .. } | Dialog::RenameAlbum { name, .. } => {
let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY));
r.request_focus();
@ -232,10 +263,25 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
/// Apply a dialog's action (also used by `ui.dialog.confirm`).
/// `90` → `1 min 30 s`.
pub fn fmt_gap(v: f64) -> String {
let v = v.round() as u64;
match v {
0..60 => format!("{v} s"),
60..3600 if v.is_multiple_of(60) => format!("{} min", v / 60),
60..3600 => format!("{} min {} s", v / 60, v % 60),
3600..86400 if v.is_multiple_of(3600) => format!("{} h", v / 3600),
3600..86400 => format!("{} h {} min", v / 3600, v % 3600 / 60),
_ => "1 day".into(),
}
}
pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_json::Value, String> {
match dlg {
Dialog::NewAlbum { name, folder } => app.run("album.create", json!({"name": name, "folder": folder, "addSelected": !folder})),
Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})),
Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})),
Dialog::NewSmartAlbum { name } => app.run("album.createSmart", json!({"name": if name.trim().is_empty() { "Smart Album" } else { name }})),
Dialog::CreatePreset { name, group } => {
app.run("preset.create", json!({"name": if name.is_empty() { "My Preset" } else { name }, "group": group}))
}

View File

@ -0,0 +1,255 @@
//! The library filter bar above the grid: rating (≥ / = / ≤), flags, colour labels, kind,
//! edited state and camera / lens / keyword pickers — every control drives `library.filter`.
//! "Save as Smart Album…" keeps the current view as a live album; "Clear" resets the filter.
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, vec2};
use lightcraft_catalog::{ColorLabel, Flag, MediaKind, RatingOp};
use serde_json::{Value, json};
use crate::LightcraftApp;
use crate::icons::{Icon, paint};
use crate::theme::Tokens;
use crate::widgets::register;
pub const HEIGHT: f32 = 40.0;
/// Bar width (points) below which the controls take two rows.
const WIDE: f32 = 1240.0;
/// Colour-label swatches (UI colours, our own choice).
pub fn label_color(l: ColorLabel) -> Color32 {
match l {
ColorLabel::Red => Color32::from_rgb(222, 72, 72),
ColorLabel::Yellow => Color32::from_rgb(232, 196, 58),
ColorLabel::Green => Color32::from_rgb(88, 176, 92),
ColorLabel::Blue => Color32::from_rgb(72, 130, 222),
ColorLabel::Purple => Color32::from_rgb(158, 100, 210),
}
}
fn filter(app: &mut LightcraftApp, patch: Value) {
let _ = app.run("library.filter", patch);
}
/// A small label before a group of controls.
fn caption(ui: &mut egui::Ui, text: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(text).font(t.font(12.0)).color(t.text_dim));
}
/// A square toggle with an icon or a colour swatch.
fn toggle(ui: &mut egui::Ui, id: &str, on: bool, tip: &str, draw: impl FnOnce(&egui::Painter, Rect, Color32)) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click());
register(ui.ctx(), format!("filter:{id}"), r);
let p = ui.painter();
if on {
p.rect_filled(r, 5.0, t.tool_active);
p.rect_stroke(r, 5.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
} else if resp.hovered() {
p.rect_filled(r, 5.0, t.hover);
}
draw(
p,
r.shrink(5.0),
if on {
t.text
} else if resp.hovered() {
t.text_label
} else {
t.icon
},
);
resp.on_hover_text(tip)
}
/// A dropdown showing `current`; `items` are (label, filter patch, selected).
fn picker(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, current: &str, active: bool, items: Vec<(String, Value, bool)>) {
let t = Tokens::get(ui.ctx());
let r = crate::widgets::dropdown(ui, &format!("filter-{id}"), current, t.font(12.5), if active { t.text } else { t.text_label });
if active {
ui.painter().rect_stroke(r.rect.expand(2.0), 4.0, Stroke::new(1.0, t.accent.gamma_multiply(0.7)), StrokeKind::Outside);
}
let mut chosen = None;
egui::Popup::menu(&r).show(|ui| {
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for (label, patch, sel) in &items {
if ui.selectable_label(*sel, label).clicked() {
chosen = Some(patch.clone());
}
}
});
});
if let Some(p) = chosen {
filter(app, p);
}
}
fn distinct(mut v: Vec<String>) -> Vec<String> {
v.retain(|s| !s.trim().is_empty());
v.sort_by_key(|s| s.to_lowercase());
v.dedup();
v
}
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
// one row when there is room, else the metadata pickers and actions go to a second row
let two_rows = ui.available_width() < WIDE;
let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), if two_rows { 2.0 * HEIGHT - 8.0 } else { HEIGHT }), Sense::hover());
ui.painter().rect_filled(bar, 0.0, t.chrome);
ui.painter().line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.divider));
register(ui.ctx(), "filterbar", bar);
let f = app.session.filter.clone();
let row = |ui: &mut egui::Ui, i: f32| {
let r = Rect::from_min_size(bar.min + vec2(16.0, 4.0 + i * (HEIGHT - 8.0)), vec2(bar.width() - 32.0, HEIGHT - 8.0));
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(r).layout(egui::Layout::left_to_right(egui::Align::Center)));
c.spacing_mut().item_spacing.x = 4.0;
c
};
let mut row1 = row(ui, 0.0);
let mut row2 = row(ui, 1.0);
let ui = &mut row1;
// rating: operator + stars
caption(ui, "Rating");
let op_label = match f.rating_op {
RatingOp::AtLeast => "≥",
RatingOp::Exactly => "=",
RatingOp::AtMost => "≤",
};
picker(
app,
ui,
"ratingOp",
op_label,
false,
[("≥ at least", "atLeast", RatingOp::AtLeast), ("= exactly", "exactly", RatingOp::Exactly), ("≤ at most", "atMost", RatingOp::AtMost)]
.into_iter()
.map(|(l, k, op)| (l.to_string(), json!({"ratingOp": k}), f.rating_op == op))
.collect(),
);
for i in 1..=5u8 {
let (r, resp) = ui.allocate_exact_size(vec2(17.0, 26.0), Sense::click());
register(ui.ctx(), format!("filter:star{i}"), r);
let filled = i <= f.rating;
let c = if filled {
t.star
} else if resp.hovered() {
t.text_label
} else {
t.icon
};
paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), if filled { Icon::StarFilled } else { Icon::Star }, c);
if resp.clicked() {
filter(app, json!({"rating": if f.rating == i { 0 } else { i }}));
}
}
ui.add_space(14.0);
// flags (one at a time; click again to clear)
caption(ui, "Flag");
for (id, icon, flag, tip) in [
("pick", Icon::FlagPick, Flag::Pick, "Picked"),
("reject", Icon::FlagReject, Flag::Reject, "Rejected"),
("none", Icon::Circle, Flag::None, "Unflagged"),
] {
let on = f.flag == Some(flag);
let color = match flag {
Flag::Pick if on => t.pick,
Flag::Reject if on => t.reject,
_ => Color32::TRANSPARENT,
};
let resp = toggle(ui, id, on, tip, |p, r, c| paint(p, r, icon, if color == Color32::TRANSPARENT { c } else { color }));
if resp.clicked() {
filter(app, json!({"flag": if on { Value::Null } else { json!(id) }}));
}
}
ui.add_space(14.0);
// colour labels
caption(ui, "Label");
for l in ColorLabel::ALL {
let on = f.label == Some(l);
let name = format!("{l:?}").to_lowercase();
let resp = toggle(ui, &format!("label-{name}"), on, &format!("{l:?} label"), |p, r, _| {
p.circle_filled(r.center(), 5.5, label_color(l));
});
if resp.clicked() {
filter(app, json!({"label": if on { Value::Null } else { json!(name) }}));
}
}
ui.add_space(14.0);
// kind + edited
let kind_label = match f.kind {
None => "All types",
Some(MediaKind::Image) => "Photos",
Some(MediaKind::Raw) => "Raw",
Some(MediaKind::Video) => "Videos",
};
picker(
app,
ui,
"kind",
kind_label,
f.kind.is_some(),
[
("All types", Value::Null, None),
("Photos", json!("image"), Some(MediaKind::Image)),
("Raw", json!("raw"), Some(MediaKind::Raw)),
("Videos", json!("video"), Some(MediaKind::Video)),
]
.into_iter()
.map(|(l, v, k)| (l.to_string(), json!({"kind": v}), f.kind == k))
.collect(),
);
let edited_label = match f.edited {
None => "Edited or not",
Some(true) => "Edited",
Some(false) => "Unedited",
};
picker(
app,
ui,
"edited",
edited_label,
f.edited.is_some(),
[("Edited or not", Value::Null, None), ("Edited", json!(true), Some(true)), ("Unedited", json!(false), Some(false))]
.into_iter()
.map(|(l, v, e)| (l.to_string(), json!({"edited": v}), f.edited == e))
.collect(),
);
// metadata pickers
let ui: &mut egui::Ui = if two_rows { &mut row2 } else { &mut row1 };
let photos: Vec<_> = app.session.catalog.photos().filter(|p| !p.deleted).cloned().collect();
let cameras = distinct(photos.iter().map(|p| p.meta.camera.clone()).collect());
let lenses = distinct(photos.iter().map(|p| p.meta.lens.clone()).collect());
let keywords: Vec<String> = app.session.catalog.keywords().into_iter().map(|(k, _)| k).collect();
for (id, key, all_label, current, values) in [
("camera", "camera", "Camera", f.camera.clone(), cameras),
("lens", "lens", "Lens", f.lens.clone(), lenses),
("keyword", "keyword", "Keyword", f.keyword.clone(), keywords),
] {
let mut items = vec![(format!("Any {}", all_label.to_lowercase()), json!({key: Value::Null}), current.is_none())];
items.extend(values.into_iter().map(|v| (v.clone(), json!({key: v.clone()}), current.as_deref() == Some(v.as_str()))));
let shown = current.clone().unwrap_or_else(|| all_label.to_string());
let shown = if shown.chars().count() > 22 { format!("{}…", shown.chars().take(21).collect::<String>()) } else { shown };
picker(app, ui, id, &shown, current.is_some(), items);
}
// save / clear
ui.add_space(14.0);
let filtering = app.session.filter != Default::default();
let save = ui.add_enabled(filtering, egui::Button::new(egui::RichText::new("Save as Smart Album…").font(t.font(12.5))));
register(ui.ctx(), "button:filterSave", save.rect);
if save.clicked() {
let _ = app.run("dialog.newSmartAlbum", json!({}));
}
let clear = ui.add_enabled(filtering, egui::Button::new(egui::RichText::new("Clear").font(t.font(12.5))));
register(ui.ctx(), "button:filterClear", clear.rect);
if clear.clicked() {
app.ui.search.clear();
let _ = app.run("library.clearFilter", json!({}));
}
}

View File

@ -36,9 +36,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let sel_n = app.session.selection.ids.len();
let cnt = if sel_n > 1 { format!("{sel_n} of {} photos", ids.len()) } else { format!("{} photos", ids.len()) };
ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim);
if app.ui.filter_bar {
super::filterbar::show(app, ui);
}
app.canvas_rect = Some(ui.max_rect());
if ids.is_empty() {
super::empty_message(ui, ui.max_rect(), "No photos", "Add photos with File → Add Photos (Cmd+Shift+I), or drop them here");
if app.session.filter != Default::default() {
super::empty_message(ui, ui.max_rect(), "No matching photos", "Change the filter, or clear it (View → Clear Filters)");
} else {
super::empty_message(ui, ui.max_rect(), "No photos", "Add photos with File → Add Photos (Cmd+Shift+I), or drop them here");
}
return;
}
let ppp = ui.ctx().pixels_per_point();
@ -106,6 +113,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
ui.data_mut(|d| d.insert_temp(key, active));
if last != active { active.and_then(|a| cells.iter().find(|c| c.id == a).map(|c| c.rect)) } else { None }
};
let stacks = app.session.catalog.stack_index();
let mut visible_ids = HashSet::new();
egui::ScrollArea::vertical().id_salt("grid-scroll").auto_shrink([false, false]).show_viewport(ui, |ui, viewport| {
let (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), total_h), Sense::hover());
@ -122,6 +130,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let r = c.rect.translate(origin.to_vec2());
let onscreen = c.rect.intersects(viewport);
cell(app, ui, c.id, r, square, onscreen, ppp);
if let Some((sid, pos)) = stacks.get(&c.id) {
stack_badge(app, ui, c.id, *sid, *pos, r, square);
}
}
});
app.renderer.evict_thumbs(&visible_ids, 600);
@ -212,6 +223,23 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
paint(p, Rect::from_min_size(pos2(bar.right() - 20.0, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::Sliders, t.text_label);
}
}
if let Some(name) = &photo.copy_name {
// virtual copy: a folded-corner tag at the top right
let g = p.layout_no_wrap(name.clone(), t.semibold(10.5), Color32::WHITE);
let tr = if square { img_rect.right_top() + vec2(-6.0, 6.0) } else { img_rect.right_top() + vec2(-5.0, 5.0) };
let br = Rect::from_min_max(pos2(tr.x - g.size().x - 26.0, tr.y), pos2(tr.x, tr.y + 20.0));
p.rect_filled(br, 10.0, Color32::from_black_alpha(165));
let c = pos2(br.left() + 13.0, br.center().y);
let s = 5.0;
p.add(egui::Shape::convex_polygon(
vec![c + vec2(-s, -s), c + vec2(s * 0.3, -s), c + vec2(s, -s * 0.3), c + vec2(s, s), c + vec2(-s, s)],
Color32::TRANSPARENT,
Stroke::new(1.2, Color32::WHITE),
));
p.line_segment([c + vec2(s * 0.3, -s), c + vec2(s * 0.3, -s * 0.3)], Stroke::new(1.2, Color32::WHITE));
p.line_segment([c + vec2(s * 0.3, -s * 0.3), c + vec2(s, -s * 0.3)], Stroke::new(1.2, Color32::WHITE));
p.galley(pos2(br.left() + 22.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE);
}
if photo.flag == Flag::Reject {
p.rect_filled(img_rect, 0.0, Color32::from_black_alpha(110));
}
@ -234,6 +262,36 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
resp.context_menu(|ui| context_menu(app, ui, id));
}
/// Stack badge at the cell's top-left: the photo count on a collapsed stack's top, `i/n` on the
/// members of an expanded stack. Clicking it expands/collapses the stack.
pub fn stack_badge(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, sid: lightcraft_catalog::StackId, pos: usize, r: Rect, square: bool) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.catalog.stack(sid) else { return };
let n = st.photos.len();
let collapsed = st.collapsed;
let text = if collapsed { n.to_string() } else { format!("{}/{n}", pos + 1) };
let p = ui.painter();
let g = p.layout_no_wrap(text, t.semibold(11.0), Color32::WHITE);
let origin = if square { r.min + vec2(10.0, 26.0) } else { r.min + vec2(5.0, 5.0) };
let br = Rect::from_min_size(origin, vec2(g.size().x + 30.0, 20.0));
let resp = ui.interact(br, egui::Id::new(("stack-badge", id.0)), Sense::click()).on_hover_text(if collapsed {
"Stack — click to expand (S)"
} else {
"Stack — click to collapse (S)"
});
register(ui.ctx(), format!("stack:{}", id.0), br);
let fill = if resp.hovered() { Color32::from_black_alpha(220) } else { Color32::from_black_alpha(165) };
p.rect_filled(br, 10.0, fill);
if pos == 0 && !collapsed {
p.rect_stroke(br, 10.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
}
paint(p, Rect::from_min_size(br.min + vec2(6.0, 3.0), vec2(14.0, 14.0)), Icon::Stack, Color32::WHITE);
p.galley(pos2(br.min.x + 24.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE);
if resp.clicked() {
let _ = app.run("stack.toggle", json!({"ids": [id.0]}));
}
}
pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if !app.session.selection.contains(id) {
let _ = app.run("library.select", json!({"ids": [id.0]}));
@ -257,13 +315,35 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
});
ui.menu_button("Add to Album", |ui| {
let albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder).map(|a| (a.id.0, a.name.clone())).collect();
let albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect();
for (aid, name) in albums {
if ui.button(name).clicked() {
let _ = app.run("album.addPhotos", json!({"id": aid}));
}
}
});
if ui.button("Create Virtual Copy").clicked() {
let _ = app.run("photo.virtualCopy", json!({}));
}
ui.menu_button("Stack", |ui| {
let stacked = app.session.catalog.stack_of(id).is_some();
let several = app.session.selection.ids.len() > 1;
for (label, cmd, on) in [
("Group into Stack", "stack.group", several),
("Ungroup Stack", "stack.ungroup", stacked),
("Remove from Stack", "stack.remove", stacked),
("Set as Top of Stack", "stack.setTop", stacked),
("Expand/Collapse Stack", "stack.toggle", stacked),
] {
if ui.add_enabled(on, egui::Button::new(label)).clicked() {
let _ = app.run(cmd, json!({}));
}
}
ui.separator();
if ui.button("Auto-Stack by Capture Time…").clicked() {
let _ = app.run("dialog.autoStack", json!({}));
}
});
ui.separator();
if ui.button("Copy Edit Settings").clicked() {
let _ = app.run("develop.copy", json!({}));

View File

@ -88,6 +88,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if ui.button("Create Album…").clicked() {
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false });
}
if ui.button("Create Smart Album from Filter…").clicked() {
app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new() });
}
if ui.button("Create Folder…").clicked() {
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true });
}
@ -132,7 +135,12 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent
}
} else {
let sel = app.session.source == LibrarySource::Album(a.id);
let resp = row(app, ui, &format!("album:{}", a.id.0), Icon::Album, &a.name, Some(a.photos.len()), sel, indent);
let icon = if a.is_smart() { Icon::SmartAlbum } else { Icon::Album };
let n = app.session.catalog.album_count(a.id);
let mut resp = row(app, ui, &format!("album:{}", a.id.0), icon, &a.name, Some(n), sel, indent);
if let Some(rules) = &a.smart {
resp = resp.on_hover_text(format!("Smart album: {}", rules.describe()));
}
if resp.clicked() {
let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0}));
}
@ -143,9 +151,12 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent
fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) {
resp.context_menu(|ui| {
if !a.folder && ui.button("Add Selected Photos").clicked() {
if !a.folder && !a.is_smart() && ui.button("Add Selected Photos").clicked() {
let _ = app.run("album.addPhotos", json!({"id": a.id.0}));
}
if a.is_smart() && ui.button("Update Rules from Current Filter").clicked() {
let _ = app.run("album.setRules", json!({"id": a.id.0, "fromView": true}));
}
if ui.button("Rename…").clicked() {
app.ui.dialog = Some(crate::state::Dialog::RenameAlbum { id: a.id.0, name: a.name.clone() });
}

View File

@ -1,9 +1,11 @@
//! Window regions and panels.
pub mod bottombar;
pub mod compare;
pub mod detail;
pub mod dialogs;
pub mod edit;
pub mod filterbar;
pub mod grid;
pub mod left;
pub mod masking;

View File

@ -321,8 +321,12 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
padded(ui, |ui| {
ui.label(egui::RichText::new(&p.file_name).font(t.semibold(14.0)).color(t.text));
ui.label(egui::RichText::new(format!("{} × {} · {}", p.width, p.height, p.format)).color(t.text_dim));
if let Some(name) = &p.copy_name {
let of = p.copy_of.and_then(|m| app.session.catalog.photo(m)).map(|m| m.file_name.clone()).unwrap_or_else(|| "a removed photo".into());
ui.label(egui::RichText::new(format!("Virtual copy “{name}” of {of}")).color(t.text_label));
}
ui.add_space(8.0);
if let Some(r) = crate::widgets::stars(ui, "info", p.rating, 20.0) {
if let Some(r) = ui.horizontal(|ui| crate::widgets::stars(ui, "info", p.rating, 20.0)).inner {
let _ = app.run("photo.rate", json!({"rating": r}));
}
});

View File

@ -16,6 +16,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right: 12, top: 0, bottom: 0 }))
.show(ui, |ui| {
let full = ui.max_rect();
let mut sw = 640.0f32.min(full.width() - 460.0).max(200.0);
if !app.native_menu {
// leave room for the in-window menus left of the (centred) search field
let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui) + 24.0;
sw = sw.min(2.0 * (full.center().x - menus_right)).max(200.0);
}
let search_left = full.center().x - sw / 2.0;
ui.horizontal_centered(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, "Show/hide My Photos panel").clicked() {
@ -26,9 +33,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let _ = app.run("view.back", json!({}));
}
icon_button(ui, "forward", Icon::Forward, vec2(30.0, 30.0), false, false, "Forward");
if !app.native_menu {
// no native menu bar (web, Windows, Linux): menus in the top bar
ui.add_space(10.0);
let room = search_left - ui.cursor().left() - 16.0;
crate::menubar::show_in_window(app, ui, room);
}
});
// search field (centred on the window)
let sw = 640.0f32.min(full.width() - 460.0).max(200.0);
let sr = Rect::from_center_size(pos2(full.center().x, full.center().y), vec2(sw, 28.0));
let id = egui::Id::new("search-field");
let focused = ui.memory(|m| m.has_focus(id));
@ -66,7 +78,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let fr = Rect::from_center_size(pos2(sr.right() + 22.0, sr.center().y), vec2(28.0, 28.0));
let fresp = ui.interact(fr, egui::Id::new("filter-btn"), Sense::click());
register(ui.ctx(), "icon:filter", fr);
let filtering = app.session.filter != Default::default();
let filtering = app.session.filter != Default::default() || app.ui.filter_bar;
paint(
ui.painter(),
fr.shrink(6.0),

View File

@ -40,6 +40,7 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> {
"-" => Some(Key::Minus),
"[" => Some(Key::OpenBracket),
"]" => Some(Key::CloseBracket),
"'" => Some(Key::Quote),
_ => None,
})
}
@ -67,13 +68,15 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
return;
}
let mut fire: Vec<String> = Vec::new();
// shortcuts the native menu bar handles (it consumes those key presses itself)
let native = |sc: &str| app.native_shortcuts.contains(sc);
let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new();
ctx.input(|i| {
let mut ui_keys = Vec::new();
for (id, _, sc, _) in crate::menus::UI_COMMANDS {
if let Some((m, k)) = sc.and_then(parse) {
ui_keys.push((m, k));
if matches(i, m, k) {
if !native(sc.unwrap_or_default()) && matches(i, m, k) {
fire.push(id.to_string());
}
}
@ -81,7 +84,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
for c in lightcraft_engine::command_specs() {
// A UI command bound to the same key wraps the engine command (e.g. `W` opens the
// White Balance Selector tool rather than sampling without a point): the UI one wins.
if let Some((m, k)) = c.shortcut.and_then(parse)
if let Some((m, k)) = c.shortcut.filter(|s| !native(s)).and_then(parse)
&& !ui_keys.contains(&(m, k))
&& matches(i, m, k)
{
@ -89,7 +92,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
}
}
for (sc, id, params) in ALIASES {
if let Some((m, k)) = parse(sc)
if let Some((m, k)) = parse(sc).filter(|_| !native(sc))
&& matches(i, m, k)
{
aliased.push((id, serde_json::from_str(params).unwrap_or_default()));
@ -97,30 +100,61 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
}
// rating 0-5, colour labels 6-9 (with Shift: and advance)
for (n, key) in [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5].iter().enumerate() {
if matches(i, Modifiers::NONE, *key) {
if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) {
fire.push(format!("rate:{n}:0"));
}
if matches(i, Modifiers::SHIFT, *key) {
fire.push(format!("rate:{n}:1"));
}
}
for (label, key) in [("red", Key::Num6), ("yellow", Key::Num7), ("green", Key::Num8), ("blue", Key::Num9)] {
if matches(i, Modifiers::NONE, key) {
for (label, key, sc) in [("red", Key::Num6, "6"), ("yellow", Key::Num7, "7"), ("green", Key::Num8, "8"), ("blue", Key::Num9, "9")] {
if matches(i, Modifiers::NONE, key) && !native(sc) {
fire.push(format!("label:{label}"));
}
}
});
use crate::panels::compare;
// rating/flag/label keys: in Compare/Survey they act on the active photo only; Shift+key or
// Auto Advance then moves on (next candidate in Compare, next photo elsewhere)
let cull = |app: &mut LightcraftApp, id: &str, mut params: serde_json::Value, advance: bool| {
compare::target_active(app, &mut params);
let ok = app.run(id, params).is_ok();
if ok && (advance || app.ui.auto_advance) {
compare::advance(app);
}
};
for (id, params) in aliased {
let _ = app.run(id, params);
// flag/rate aliases (Shift+X…) go through the culling path: active photo in Compare/Survey,
// `advance` moves to the next candidate there
if matches!(id, "photo.flag" | "photo.rate" | "photo.label") {
let mut params = params;
let advance = params.get("advance").and_then(serde_json::Value::as_bool).unwrap_or(false);
if let Some(o) = params.as_object_mut() {
o.remove("advance");
}
cull(app, id, params, advance);
} else {
let _ = app.run(id, params);
}
}
for f in fire {
if let Some(rest) = f.strip_prefix("rate:") {
let (n, adv) = rest.split_once(':').unwrap_or(("0", "0"));
let _ = app.run("photo.rate", json!({"rating": n.parse::<u8>().unwrap_or(0), "advance": adv == "1"}));
cull(app, "photo.rate", json!({"rating": n.parse::<u8>().unwrap_or(0)}), adv == "1");
let label = if n == "0" { "Rating cleared".to_string() } else { format!("Rated {}", "★".repeat(n.parse().unwrap_or(0))) };
app.toast(ctx, label);
} else if let Some(l) = f.strip_prefix("label:") {
let _ = app.run("photo.label", json!({"label": l}));
cull(app, "photo.label", json!({"label": l}), false);
} else if compare::culling(app) && (f == "library.next" || f == "library.previous") {
let d = if f == "library.next" { 1 } else { -1 };
let _ = if app.ui.view == crate::state::ViewMode::Compare { compare::compare_step(app, d) } else { compare::survey_step(app, d) };
} else if matches!(f.as_str(), "photo.pick" | "photo.reject" | "photo.unflag") && app.ui.right != crate::state::RightPanel::Crop {
cull(app, &f, json!({}), false);
match f.as_str() {
"photo.pick" => app.toast(ctx, "Flagged as Pick"),
"photo.reject" => app.toast(ctx, "Flagged as Reject"),
_ => app.toast(ctx, "Unflagged"),
}
} else {
// Delete acts on what's being edited: the active mask in the Masking panel; never the
// photo while retouching (spots are removed from their own panel).

View File

@ -10,7 +10,10 @@ pub enum ViewMode {
PhotoGrid,
SquareGrid,
Detail,
/// Two photos side by side (select | candidate), synced zoom.
Compare,
/// The selected photos tiled.
Survey,
}
/// The right-hand tool/panel shown next to the tool strip.
@ -121,6 +124,13 @@ pub struct UiState {
/// Remove tool: Visualize Spots (high-pass black/white view) and its threshold 0..100.
pub visualize_spots: bool,
pub spots_threshold: f32,
/// The library filter bar above the grid.
pub filter_bar: bool,
/// Culling: after a rating, flag or colour-label key, move to the next photo.
pub auto_advance: bool,
/// Compare view: (select, candidate) photo ids.
#[serde(skip)]
pub compare: Option<(u64, u64)>,
/// Transient toast text and its expiry (seconds of app time).
#[serde(skip)]
pub toast: Option<(String, f64)>,
@ -141,6 +151,14 @@ pub enum Dialog {
id: u64,
name: String,
},
/// Auto-stack by capture time: the largest gap between consecutive shots, in seconds.
AutoStack {
gap: f32,
},
/// Save the current view (source + filter) as a smart album.
NewSmartAlbum {
name: String,
},
CreatePreset {
name: String,
group: String,
@ -199,6 +217,9 @@ impl Default for UiState {
eye_pet: false,
visualize_spots: false,
spots_threshold: 50.0,
auto_advance: false,
filter_bar: false,
compare: None,
toast: None,
status: String::new(),
dialog: None,

View File

@ -19,7 +19,8 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation:
|---|---|---|
| `engine.execute` (alias `ui.menu.invoke`) | `{command, params?}` | Run any engine or UI command (see `engine.commands`) |
| `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled |
| `ui.menu.list` | — | Menu entries |
| `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) |
| `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus |
| `ui.inspect` | — | UI state, window, canvas/image rects, active photo, selection, perf, status |
| `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) |
| `ui.clickWidget` / `ui.dragWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` | Real egui input on a widget |

View File

@ -58,6 +58,7 @@ scale in `BaselineExposure`; raw sources keep their DNG colour tags (camera RGB)
- No camera response calibration for JPEG brackets (sRGB decoding is taken as linear light).
- Boundary Warp is a separable stretch, not a content-preserving mesh warp; Fill Edges is diffusion,
not patch synthesis.
- *Create Stack*: LightCraft has no stacks yet; the result's caption lists its sources.
- *Create Stack*: the result goes on top of a collapsed stack of its sources (`Catalog::stack_with_ops`); its
caption also lists them.
- Panorama output is limited to `maxMegapixels` (default 40) — blending memory is ~25 bytes/pixel.
- The browser build has no file access for merges yet.

View File

@ -22,9 +22,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done |
|---|---:|---:|---:|---:|---:|---:|
| A. Import (IMP) | 2 | 4 | 7 | 1 | 1/4 (25%) | 1/4 (25%) |
| B. Library management (LIB) | 9 | 4 | 12 | 2 | 7/9 (78%) | 1/9 (11%) |
| C. Views & navigation (VIEW) | 7 | 2 | 8 | 0 | 7/9 (78%) | 0/4 (0%) |
| D. Search & filter (FILT) | 2 | 7 | 4 | 0 | 2/4 (50%) | 0/4 (0%) |
| B. Library management (LIB) | 12 | 4 | 9 | 2 | 7/9 (78%) | 4/9 (44%) |
| C. Views & navigation (VIEW) | 9 | 2 | 6 | 0 | 7/9 (78%) | 1/4 (25%) |
| D. Search & filter (FILT) | 7 | 2 | 4 | 0 | 4/4 (100%) | 3/4 (75%) |
| E. Metadata (META) | 2 | 2 | 2 | 0 | 2/2 (100%) | 0/2 (0%) |
| F. Edit panel — global adjustments (EDIT) | 36 | 2 | 10 | 1 | 28/28 (100%) | 8/14 (57%) |
| G. Profiles (PROF) | 3 | 1 | 6 | 0 | 2/3 (67%) | 1/3 (33%) |
@ -45,10 +45,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| V. Preferences (PREF) | 0 | 3 | 5 | 3 | 0/1 (0%) | 0/4 (0%) |
| W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — |
| X. Cross-cutting behaviours (BEHAV) | 10 | 2 | 5 | 1 | 7/8 (88%) | 3/5 (60%) |
| Y. Menus | 42 | 19 | 24 | 8 | 37/47 (79%) | 3/23 (13%) |
| Z. Keyboard shortcuts (desktop) | 45 | 16 | 19 | 1 | 39/52 (75%) | 6/23 (26%) |
| Lightroom Classic extras | 1 | 17 | 61 | 9 | — | 1/21 (5%) |
| **Total** | 190 | 107 | 203 | 37 | 150/198 (76%) | 34/144 (24%) |
| Y. Menus | 46 | 18 | 21 | 8 | 37/47 (79%) | 7/23 (30%) |
| Z. Keyboard shortcuts (desktop) | 46 | 16 | 18 | 1 | 39/52 (75%) | 7/23 (30%) |
| Lightroom Classic extras | 4 | 19 | 56 | 9 | — | 3/21 (14%) |
| **Total** | 208 | 103 | 189 | 37 | 152/198 (77%) | 48/144 (33%) |
<!-- /parity:summary -->
## Top gaps
@ -57,45 +57,39 @@ Ordered by tier, then user value, then (low) effort. Take the first one nobody i
1. **LR-EXP-COLORSPACE** (P0) — export is sRGB only. Add Display P3 / Adobe RGB-compatible / ProPhoto-compatible /
Rec.2020 output (our own primaries + ICC from `crates/codecs/src/icc.rs`). High value, low–medium effort.
2. **LR-FILT-RATING / LR-FILT-FLAG / LR-FILT-LABEL / LR-FILT-TYPE** (P0/P1) — the engine filters already exist
(`cmd:library.filter`); there is no filter-bar UI (stars ≥/=/≤, flags, labels, kind, edited). High value, low effort.
3. **LR-EXP-PREVIOUS + LR-EXP-DIALOG presets** (P0) — remember the last export settings (⌘E "export with previous")
2. **LR-EXP-PREVIOUS + LR-EXP-DIALOG presets** (P0) — remember the last export settings (⌘E "export with previous")
and offer built-in export presets (small JPEG, full JPEG, TIFF). Medium value, low effort.
4. **LR-LIB-SHOWFINDER** (P0) — reveal the original in Finder/Explorer/file manager. Medium value, low effort.
5. **LR-EDIT-EFFECTS-VIGNETTE** (P0) — the vignette style (highlight priority / colour priority / paint overlay) is
3. **LR-LIB-SHOWFINDER** (P0) — reveal the original in Finder/Explorer/file manager. Medium value, low effort.
4. **LR-EDIT-EFFECTS-VIGNETTE** (P0) — the vignette style (highlight priority / colour priority / paint overlay) is
rendered but has no UI. Low effort.
6. **LR-VIEW-BEFOREAFTER** (P0) — add top/bottom and split top/bottom layouts (⌥Y). Low effort.
7. **LR-MASK-OVERLAY + LR-MASK-PINS** (P0/P1) — show the evaluated mask alpha as a coloured overlay (all mask
5. **LR-VIEW-BEFOREAFTER** (P0) — add top/bottom and split top/bottom layouts (⌥Y). Low effort.
6. **LR-MASK-OVERLAY + LR-MASK-PINS** (P0/P1) — show the evaluated mask alpha as a coloured overlay (all mask
kinds, colour/opacity choice), not just brush dabs and outlines. High value, medium effort.
8. **LR-MASK-BRUSH + LR-MASK-SLIDERS** (P0) — apply the stored Auto Mask flag (edge-aware brush) and the local
7. **LR-MASK-BRUSH + LR-MASK-SLIDERS** (P0) — apply the stored Auto Mask flag (edge-aware brush) and the local
Noise / Moiré / Defringe sliders, which are stored but not rendered. High value, medium effort.
9. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫,
8. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫,
feather/opacity sliders, `[`/`]` size keys. Medium effort.
10. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort.
11. **LR-CROP-STRAIGHTEN** (P0) — draw-along-the-horizon straighten tool (+ double-click auto). Low effort.
12. **LR-PRE-CREATE + LR-PRE-PANEL + LR-BEHAV-PREVIEW-HOVER** (P0/P1) — per-group checkboxes in Create Preset;
9. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort.
10. **LR-CROP-STRAIGHTEN** (P0) — draw-along-the-horizon straighten tool (+ double-click auto). Low effort.
11. **LR-PRE-CREATE + LR-PRE-PANEL + LR-BEHAV-PREVIEW-HOVER** (P0/P1) — per-group checkboxes in Create Preset;
live preview while hovering presets/profiles/versions. Medium effort.
13. **LR-PROF-DROPDOWN** (P0) — favourites/recent in the profile menu (and later a browser, LR-PROF-BROWSER).
14. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing.
15. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific),
12. **LR-PROF-DROPDOWN** (P0) — favourites/recent in the profile menu (and later a browser, LR-PROF-BROWSER).
13. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing.
14. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific),
XMP prefs, cache, GPU. Medium effort.
16. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination
15. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination
album; browse folders before adding. Medium effort.
17. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in
16. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in
`crates/raw/src/dngwrite.rs`), short edge / width / height / megapixels, "don't enlarge", ppi.
18. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no
17. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no
permissive pure-Rust decoder yet). High value, high effort (clean sources needed).
19. **MENU-BAR** (P1) — render the existing menu model (`ui.menu.list`) as a native macOS menu / in-window menu bar.
High value, medium effort.
20. **LR-LIB-SMARTALBUM** (P1) — smart albums from the existing filter + fielded search tokens. High value, medium effort.
21. **LR-LIB-STACK + LR-LIB-VIRTUALCOPY** (P1) — stacks (⌘G) and virtual copies. Medium effort.
22. **LR-VIEW-FULLSCREEN + LR-VIEW-COMPARE + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort.
23. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names.
24. **LR-EYE-RED** (P1) — red-eye correction (panel placeholder exists; no render).
25. **LR-EDIT-COLOR-POINTCOLOR, LR-EDIT-CALIB, LR-EDIT-LIGHT-CURVE-TAT, LR-EDIT-LIGHT-CURVE-REFINESAT** (P1) —
18. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort.
19. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names.
20. **LR-EYE-RED** (P1) — red-eye correction (panel placeholder exists; no render).
21. **LR-EDIT-COLOR-POINTCOLOR, LR-EDIT-CALIB, LR-EDIT-LIGHT-CURVE-TAT, LR-EDIT-LIGHT-CURVE-REFINESAT** (P1) —
missing develop controls (calibration matters for XMP interop).
26. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice.
27. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today).
22. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice.
23. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today).
## Shortcuts: conflicts and missing bindings
@ -160,7 +154,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-LIB-BYDATE | Browse by date | P1 | 🟡 | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` | years only; no month/day tree |
| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum` | no drag photos onto album (LR-BEHAV-DRAGDROP), no album sort |
| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`), `cmd:album.move` | moving is command-only (no drag, no "Move to" menu) |
| LR-LIB-SMARTALBUM | Smart albums | P1 | ⬜ | `crates/catalog/src/query.rs` | building blocks exist (filter + fielded search tokens) |
| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs` | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album), live; match-all only (no any/none rule groups, no rule editor dialog — rules come from the filter bar or `album.setRules`) |
| LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service |
| LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library |
| LR-LIB-TARGET | Target album | P2 | ⬜ | | |
@ -169,7 +163,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-LIB-LABEL | Colour labels | P1 | 🟡 | `cmd:photo.label`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | no label menu/buttons, no purple key, label names not editable |
| LR-LIB-KEYWORD | Keywords | P0 | 🟡 | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`) | no library-wide rename/delete, no keyword browser in the left panel |
| LR-LIB-PEOPLE | People / faces | P2 | ⬜ | | |
| LR-LIB-STACK | Stacks | P1 | ⬜ | | |
| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack |
| LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M |
| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | no confirmation dialog, no auto-purge after N days, no "Empty" |
| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | |
@ -181,7 +175,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-LIB-CULL | Assisted culling | P2 | ⬜ | | |
| LR-LIB-ACTIVITY | Comments & likes | OOS | 🚫 | | |
| LR-LIB-QUICKCOLL | Quick collection [Classic] | P2 | ⬜ | | |
| LR-LIB-VIRTUALCOPY | Virtual copies [Classic] | P1 | ⬜ | | |
| LR-LIB-VIRTUALCOPY | Virtual copies [Classic] | P1 | ✅ | `cmd:photo.virtualCopy`, `crates/engine/src/cmd/organize.rs` | “Copy N” badge, stacked with the original, same albums, no XMP writes; no “Set Copy as Master” |
## C. Views & navigation (VIEW)
@ -196,8 +190,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle` | steps 25–800 % (not 6–1600 %); Fill only in the bottom bar |
| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ⬜ | | |
| LR-VIEW-BEFOREAFTER | Before / after | P0 | 🟡 | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom` | all four layouts; "before" is always the original (no before-state from history) |
| LR-VIEW-COMPARE | Compare two photos | P1 | ⬜ | | |
| LR-VIEW-SURVEY | Survey view [Classic] | P2 | ⬜ | | |
| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle |
| LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes |
| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ⬜ | | |
| LR-VIEW-SLIDESHOW | Slideshow | P2 | ⬜ | | |
| LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ⬜ | | |
@ -211,12 +205,12 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|---|---|---|---|---|---|
| LR-FILT-SEARCH-META | Text search | P0 | ✅ | `cmd:library.filter` (`text`), `crates/ui-egui/src/panels/topbar.rs`, `crates/catalog/src/query.rs` | fielded tokens (`rating:3`, `iso:>800`, `camera:…`); no suggestions dropdown |
| LR-FILT-SEARCH-AI | Natural-language search | P2 | ⬜ | | |
| LR-FILT-RATING | Rating filter | P0 | 🟡 | `cmd:library.filter` (`rating`, `ratingOp`) | engine + search token only; no filter-bar UI |
| LR-FILT-FLAG | Flag filter | P0 | 🟡 | `cmd:library.filter` (`flag`), `cmd:library.source` (`picks`) | no filter-bar UI (Picks source only) |
| LR-FILT-LABEL | Colour-label filter | P1 | 🟡 | `cmd:library.filter` (`label`) | no UI |
| LR-FILT-TYPE | Type / edited filter | P1 | 🟡 | `cmd:library.filter` (`kind`, `edited`) | no UI; no HDR/panorama/depth kinds |
| LR-FILT-KEYWORD | Keyword filter | P1 | 🟡 | `cmd:library.filter` (`keyword`) | no UI |
| LR-FILT-CAMERA | Camera / lens filter | P1 | 🟡 | `cmd:library.filter` (`camera`) | no UI; lens via text search only |
| LR-FILT-RATING | Rating filter | P0 | ✅ | `cmd:library.filter` (`rating`, `ratingOp`), `crates/ui-egui/src/panels/filterbar.rs` | ≥ / = / ≤ stars in the filter bar (`cmd:view.filterBar`) |
| LR-FILT-FLAG | Flag filter | P0 | ✅ | `cmd:library.filter` (`flag`), `crates/ui-egui/src/panels/filterbar.rs` | picked / rejected / unflagged (one at a time) |
| LR-FILT-LABEL | Colour-label filter | P1 | ✅ | `cmd:library.filter` (`label`), `crates/ui-egui/src/panels/filterbar.rs` | one label at a time; no “no label” choice |
| LR-FILT-TYPE | Type / edited filter | P1 | 🟡 | `cmd:library.filter` (`kind`, `edited`), `crates/ui-egui/src/panels/filterbar.rs` | photos / raw / videos, edited / unedited; no HDR/panorama/depth kinds |
| LR-FILT-KEYWORD | Keyword filter | P1 | ✅ | `cmd:library.filter` (`keyword`), `crates/ui-egui/src/panels/filterbar.rs` | keyword picker |
| LR-FILT-CAMERA | Camera / lens filter | P1 | ✅ | `cmd:library.filter` (`camera`, `lens`), `crates/ui-egui/src/panels/filterbar.rs` | camera and lens pickers |
| LR-FILT-LOCATION | Location filter | P2 | 🟡 | `cmd:library.filter` (`text`) | free-text match on the location field only |
| LR-FILT-PEOPLE | People filter | P2 | ⬜ | | |
| LR-FILT-CULL | Culling-score filters | P2 | ⬜ | | |
@ -407,7 +401,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr` | auto align, deghost None–High + overlay, auto settings, float DNG; no Create Stack yet (no stacks), JPEG brackets treated as linear |
| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear |
| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG; no lens model / 360° wrap |
| LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF |
| LR-MERGE-HEADLESS | Merge with last settings | P2 | ⬜ | | |
@ -534,7 +528,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| MENU-BAR | Menu bar rendering | P1 | 🟡 | `crates/ui-egui/src/menus.rs` (`menu_entries`) | model only; no native macOS menu or in-window menu bar |
| MENU-BAR | Menu bar rendering | P1 | ✅ | `crates/ui-egui/src/menubar.rs`, `apps/lightcraft/src/native_menu.rs` | native macOS menu bar (muda) with live labels/enabled/checked; in-window menus on web/Windows/Linux; ⌫ and X stay egui-handled (contextual), so they show no key in the native menu |
| MENU-APP-ABOUT | About | P2 | ✅ | `cmd:app.about` | |
| MENU-APP-SETTINGS | Settings… | P0 | ⬜ | | see LR-PREF-GENERAL |
| MENU-APP-UPDATES | Check for updates | P2 | ⬜ | | |
@ -547,7 +541,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
| MENU-FILE-MIGRATE | Migrate photos | OOS | 🚫 | | |
| MENU-FILE-NEWALBUM | New Album… | P0 | ✅ | `cmd:dialog.newAlbum` | |
| MENU-FILE-NEWFOLDER | New Folder… | P0 | ✅ | `cmd:dialog.newFolder` | ⇧⌘N |
| MENU-FILE-NEWSMART | New Smart Album… | P1 | ⬜ | | |
| MENU-FILE-NEWSMART | New Smart Album… | P1 | ✅ | `cmd:dialog.newSmartAlbum` | saves the current view (source + filter) |
| MENU-FILE-IMPORTPROFILES | Import Profiles & Presets… | P1 | 🟡 | `cmd:file.importPresets` | presets only |
| MENU-FILE-EXPORT | Export… | P0 | ✅ | `cmd:dialog.export` | |
| MENU-FILE-EXPORTPREV | Export with Previous | P0 | ✅ | `cmd:app.exportPrevious` | ⌥⇧⌘E |
@ -587,7 +581,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
| MENU-VIEW-MASKOVERLAY | Mask overlay / cycle colour | P0 | 🟡 | `cmd:view.maskOverlay` | no colour cycle |
| MENU-VIEW-INCLUDESUBFOLDERS | Include subfolders | P1 | 🟡 | `cmd:library.import` | folder import is always recursive |
| MENU-VIEW-SORT | Sort submenu | P0 | ✅ | `cmd:library.sort` | no colour-label key |
| MENU-VIEW-STACKS | Expand/collapse stacks | P1 | ⬜ | | |
| MENU-VIEW-STACKS | Expand/collapse stacks | P1 | ✅ | `cmd:stack.expandAll`, `cmd:stack.collapseAll` | |
| MENU-VIEW-PHOTOCOUNT | Show photo counts | P2 | 🟡 | `crates/ui-egui/src/panels/left.rs` | counts always shown; no toggle |
| MENU-VIEW-HDR | HDR display options | P2 | ⬜ | | |
| MENU-PHOTO-ADDTOALBUM | Add to album | P0 | ✅ | `cmd:album.addPhotos` | |
@ -598,7 +592,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
| MENU-PHOTO-ROTATE | Rotate left / right | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | |
| MENU-PHOTO-FLIP | Flip horizontal / vertical | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical` | |
| MENU-PHOTO-CREATEVERSION | Create Version… | P1 | ✅ | `cmd:version.create` | no name prompt |
| MENU-PHOTO-STACK | Stack submenu | P1 | ⬜ | | |
| MENU-PHOTO-STACK | Stack submenu | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:dialog.autoStack` | |
| MENU-PHOTO-MERGE | Photo merge submenu | P2 | ⬜ | | |
| MENU-PHOTO-ENHANCE | Enhance… | P2 | ⬜ | | |
| MENU-PHOTO-AUTO | Auto settings | P0 | ✅ | `cmd:develop.auto` | |
@ -668,7 +662,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
| KEY-EXPORTPREV | Export with previous — ⌘E | P0 | 🟡 | `cmd:app.exportPrevious` | bound to ⌥⇧⌘E (Classic); ⌘E not bound |
| KEY-EXPORTDIALOG | Export dialog — ⇧E | P0 | ✅ | `cmd:dialog.export` | secondary binding (primary ⌘⇧E) |
| KEY-ENTERFULLSCREEN | Window full screen — ⇧⌘F | P1 | ⬜ | | |
| KEY-STACK | Group / ungroup stack — ⌘G / ⇧⌘G | P1 | ⬜ | | |
| KEY-STACK | Group / ungroup stack — ⌘G / ⇧⌘G | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup` | also S expand/collapse, ⇧S top of stack |
| KEY-GUIDEDUPRIGHT | Guided Upright — ⇧G | P1 | 🟡 | `cmd:geometry.upright` | button in the crop panel; ⇧G = Square Grid |
| KEY-HIDE | Hide / hide others — ⌘H / ⌥⌘H | P1 | 🟡 | | platform default (unverified) |
| KEY-QUIT | Quit — ⌘Q | P0 | 🟡 | | platform default (unverified) |
@ -727,17 +721,17 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ⬜ | | |
| LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | |
| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.detail` | no compare, survey, people; no grid cell styles beyond filenames |
| LRC-LIB-COMPARE | Compare view | P1 | ⬜ | | |
| LRC-LIB-SURVEY | Survey view | P2 | ⬜ | | |
| LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; zoom always linked |
| LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N |
| LRC-LIB-REFVIEW | Reference view | P2 | ⬜ | | |
| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | 🟡 | `cmd:library.source` | all, recently added, picks, recently deleted; no missing/problem sets |
| LRC-LIB-FOLDERS | Disk folder tree | P1 | ⬜ | | |
| LRC-LIB-COLLECTIONS | Collections & sets | P1 | 🟡 | `cmd:album.create` | albums + folders; no smart / quick / target collections |
| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ⬜ | `crates/catalog/src/query.rs` | fielded search tokens are a start |
| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | 🟡 | `cmd:album.createSmart`, `crates/catalog/src/query.rs` | the filter fields + date range; no rule editor, any/none groups or operators beyond ≥/=/≤ |
| LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | |
| LRC-LIB-FILTERBAR | Library filter bar | P1 | 🟡 | `cmd:library.filter` | engine filter + text tokens; no filter bar, presets, lock |
| LRC-LIB-STACKS | Stacks (full) | P1 | ⬜ | | |
| LRC-LIB-VC | Virtual copies | P1 | ⬜ | | |
| LRC-LIB-FILTERBAR | Library filter bar | P1 | 🟡 | `cmd:view.filterBar`, `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label/kind/edited/camera/lens/keyword, clear, save as smart album; no filter presets, lock, or multi-select columns |
| LRC-LIB-STACKS | Stacks (full) | P1 | 🟡 | `crates/catalog/src/stacks.rs` | group/ungroup/toggle/top/remove/auto by time; no split stack or move up/down |
| LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' |
| LRC-LIB-LABELS | Colour-label sets | P1 | ⬜ | | |
| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter | P1 | ⬜ | | flat keywords only (LR-LIB-KEYWORD) |
| LRC-LIB-METADATA | Metadata panel & presets | P1 | 🟡 | `cmd:photo.setMeta`, `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile` | no metadata presets, capture-time edit, copyright status |