Merge branch 'worktree-agent-aada35d252224c5cd'
# Conflicts: # crates/engine/src/media.rs # crates/ui-egui/src/state.rs
This commit is contained in:
commit
59140d8265
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ×);
|
||||
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]]]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!({}))?;
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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), "⌫");
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!({}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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!({}));
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
110
docs/parity.md
110
docs/parity.md
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue