mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 05:28:25 -06:00
MENU-EDIT-SELECTBY + LR-PRE-MANAGE: select by flag/rating/label, manage presets and versions
- library.selectBy {flag, rating, ratingOp, label, add}; Edit → Select by submenu.
- preset.rename / preset.update (current settings, same groups) / preset.move; preset row
menu: update, rename, move to group (existing or new), delete.
- version.rename / version.update; version button menu: restore, update, rename, set as
before, delete. history.clear (undoable); history row menu: create version from step, clear.
- Keyword chip menu: remove, show photos with keyword, rename / delete keyword.
- A generic one-field dialog (TextPrompt) runs a command with the typed value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1a48b5bcc7
commit
852e8fca45
@@ -48,6 +48,28 @@ fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)
|
||||
d.crop.geometry = crop_fit_angle(w, h, angle, a);
|
||||
}
|
||||
|
||||
/// The user preset named by `p.id` (built-ins can't be changed).
|
||||
fn user_preset<'a>(s: &'a mut Session, p: &Value, c: &str) -> Result<&'a mut Preset> {
|
||||
let pid = str_param(p, "id").ok_or_else(|| bad(c, "missing id"))?;
|
||||
let pr = s.presets.iter_mut().find(|x| x.id == pid).ok_or_else(|| bad(c, format!("unknown preset `{pid}`")))?;
|
||||
if pr.builtin {
|
||||
return Err(bad(c, "built-in presets can't be changed"));
|
||||
}
|
||||
Ok(pr)
|
||||
}
|
||||
|
||||
/// Change version `p.index` of the active photo (`f` gets it and the current settings).
|
||||
fn edit_version(s: &mut Session, p: &Value, c: &str, label: &str, f: impl FnOnce(&mut Version, std::sync::Arc<DevelopSettings>)) -> Result<Value> {
|
||||
let id = active(s, c)?;
|
||||
let i = f64_req(p, "index", c)? as usize;
|
||||
let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?;
|
||||
let mut versions = ph.versions.clone();
|
||||
let v = versions.get_mut(i).ok_or_else(|| bad(c, "no such version"))?;
|
||||
f(v, ph.develop.clone());
|
||||
s.commit(label, Op::SetVersions { id, versions })?;
|
||||
ok()
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -594,6 +616,42 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
pr.favorite = bool_or(p, "favorite", !pr.favorite);
|
||||
ok()
|
||||
}),
|
||||
cmd!("preset.rename", "Rename Preset", [], None, "{id, name}", always, |s, p| {
|
||||
let c = "preset.rename";
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(c, "missing name"))?.to_string();
|
||||
user_preset(s, p, c)?.name = name;
|
||||
ok()
|
||||
}),
|
||||
cmd!("preset.move", "Move Preset to Group", [], None, "{id, group}", always, |s, p| {
|
||||
let c = "preset.move";
|
||||
let group = str_param(p, "group").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(c, "missing group"))?.to_string();
|
||||
user_preset(s, p, c)?.group = group;
|
||||
ok()
|
||||
}),
|
||||
cmd!(
|
||||
"preset.update",
|
||||
"Update Preset with Current Settings",
|
||||
[],
|
||||
None,
|
||||
"{id, groups?: [settingsGroup] (default: the groups the preset already sets)} — from the active photo",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "preset.update";
|
||||
let id = active(s, c)?;
|
||||
let current = s.develop_of(id).unwrap_or_default().to_json();
|
||||
let groups = groups_param(p);
|
||||
let pr = user_preset(s, p, c)?;
|
||||
pr.settings = match groups {
|
||||
Some(g) => lightcraft_develop::extract_groups(&serde_json::from_value(current).unwrap_or_default(), &g),
|
||||
// the same keys as before, with the current values
|
||||
None => {
|
||||
let keys: Vec<String> = pr.settings.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();
|
||||
Value::Object(keys.into_iter().filter_map(|k| current.get(&k).cloned().map(|v| (k, v))).collect())
|
||||
}
|
||||
};
|
||||
ok()
|
||||
}
|
||||
),
|
||||
// ---- versions & history
|
||||
cmd!("version.create", "Create Version", ["Photo"], Some("Cmd+Shift+S"), "{name?}", has_active, |s, p| {
|
||||
let id = active(s, "version.create")?;
|
||||
@@ -622,6 +680,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.commit("Delete Version", Op::SetVersions { id, versions })?;
|
||||
ok()
|
||||
}),
|
||||
cmd!("version.rename", "Rename Version", [], None, "{index, name}", has_active, |s, p| {
|
||||
let c = "version.rename";
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(c, "missing name"))?.to_string();
|
||||
edit_version(s, p, c, "Rename Version", |v, _| v.name = name)
|
||||
}),
|
||||
cmd!("version.update", "Update Version with Current Settings", [], None, "{index}", has_active, |s, p| {
|
||||
edit_version(s, p, "version.update", "Update Version", |v, current| v.settings = current)
|
||||
}),
|
||||
cmd!("history.clear", "Clear History", [], None, "{} — keeps the current settings as the only step", has_active, |s, _| {
|
||||
let id = active(s, "history.clear")?;
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let history = vec![lightcraft_catalog::HistoryStep { label: "Cleared History".into(), settings: d }];
|
||||
s.commit("Clear History", Op::SetHistory { id, history })?;
|
||||
ok()
|
||||
}),
|
||||
cmd!("history.restore", "Go to History Step", [], None, "{index}", has_active, |s, p| {
|
||||
let id = active(s, "history.restore")?;
|
||||
let i = f64_req(p, "index", "history.restore")? as usize;
|
||||
|
||||
@@ -87,6 +87,44 @@ fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
|
||||
/// `library.selectBy`: select the photos in view that match every given criterion.
|
||||
fn select_by(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.selectBy";
|
||||
let flag: Option<lightcraft_catalog::Flag> = match p.get("flag") {
|
||||
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad(C, "flag is pick, reject or none"))?),
|
||||
None => None,
|
||||
};
|
||||
let rating = p.get("rating").and_then(Value::as_u64).map(|r| r.min(5) as u8);
|
||||
let op = p.get("ratingOp").and_then(Value::as_str).unwrap_or("gte");
|
||||
let label: Option<Option<lightcraft_catalog::ColorLabel>> = match p.get("label").and_then(Value::as_str) {
|
||||
Some("none") => Some(None),
|
||||
Some(l) => Some(Some(serde_json::from_value(json!(l)).map_err(|_| bad(C, format!("unknown label `{l}`")))?)),
|
||||
None => None,
|
||||
};
|
||||
if flag.is_none() && rating.is_none() && label.is_none() {
|
||||
return Err(bad(C, "give flag, rating and/or label"));
|
||||
}
|
||||
let matches = |ph: &lightcraft_catalog::Photo| {
|
||||
flag.is_none_or(|f| ph.flag == f)
|
||||
&& rating.is_none_or(|r| match op {
|
||||
"eq" => ph.rating == r,
|
||||
"lte" => ph.rating <= r,
|
||||
_ => ph.rating >= r,
|
||||
})
|
||||
&& label.is_none_or(|l| ph.label == l)
|
||||
};
|
||||
let mut ids: Vec<PhotoId> = if bool_or(p, "add", false) { s.selection.ids.clone() } else { Vec::new() };
|
||||
for id in s.visible_cloned() {
|
||||
if !ids.contains(&id) && s.catalog.photo(id).is_some_and(|ph| matches(ph)) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
let active = s.selection.active.filter(|a| ids.contains(a)).or(ids.first().copied());
|
||||
let n = ids.len();
|
||||
s.selection = Selection { ids, active };
|
||||
Ok(json!({"selected": n}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
// ---- view source / filter / sort
|
||||
@@ -202,6 +240,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.selection = Selection { active: s.selection.active.filter(|a| vis.contains(a)).or(vis.first().copied()), ids: vis };
|
||||
Ok(json!({"selected": s.selection.ids.len()}))
|
||||
}),
|
||||
cmd!(
|
||||
"library.selectBy",
|
||||
"Select by Flag, Rating or Label",
|
||||
[],
|
||||
None,
|
||||
"{flag?: pick|reject|none, rating?: 0..5, ratingOp?: gte|eq|lte (default gte), label?: red|…|none, add?: bool (extend the selection)} — among the photos in view",
|
||||
always,
|
||||
select_by
|
||||
),
|
||||
cmd!("library.selectNone", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always, |s, _| {
|
||||
let a = s.selection.active;
|
||||
s.selection = Selection { ids: a.into_iter().collect(), active: a };
|
||||
|
||||
@@ -295,3 +295,47 @@ fn before_is_the_import_state_and_can_be_set_copied_and_swapped() {
|
||||
let k2 = s.render_job(id, 64, 64, true, true).unwrap().key;
|
||||
assert_ne!(k1, k2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_versions_history_and_select_by() {
|
||||
let mut s = demo();
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": 0.5}})).unwrap();
|
||||
let pid = s.execute("preset.create", &json!({"name": "Warm", "groups": ["light"]})).unwrap()["id"].as_str().unwrap().to_string();
|
||||
let preset = |s: &Session| s.presets.iter().find(|p| p.id == pid).cloned().unwrap();
|
||||
s.execute("preset.rename", &json!({"id": pid, "name": "Warmer"})).unwrap();
|
||||
s.execute("preset.move", &json!({"id": pid, "group": "Mine"})).unwrap();
|
||||
assert_eq!((preset(&s).name.as_str(), preset(&s).group.as_str()), ("Warmer", "Mine"));
|
||||
// update keeps the preset's groups and takes the current values
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": 1.25, "color.vibrance": 40}})).unwrap();
|
||||
s.execute("preset.update", &json!({"id": pid})).unwrap();
|
||||
assert_eq!(preset(&s).settings["light"]["exposure"], 1.25);
|
||||
assert!(preset(&s).settings.get("color").is_none(), "only the groups it already had");
|
||||
let builtin = s.presets.iter().find(|p| p.builtin).unwrap().id.clone();
|
||||
assert!(s.execute("preset.rename", &json!({"id": builtin, "name": "x"})).is_err());
|
||||
// versions
|
||||
s.execute("version.create", &json!({"name": "A"})).unwrap();
|
||||
s.execute("version.rename", &json!({"index": 0, "name": "First"})).unwrap();
|
||||
s.execute("develop.set", &json!({"values": {"light.exposure": -2.0}})).unwrap();
|
||||
s.execute("version.update", &json!({"index": 0})).unwrap();
|
||||
let v = s.catalog.photo(s.active().unwrap()).unwrap().versions[0].clone();
|
||||
assert_eq!((v.name.as_str(), v.settings.light.exposure), ("First", -2.0));
|
||||
// history
|
||||
s.execute("history.clear", &json!({})).unwrap();
|
||||
let h = &s.catalog.photo(s.active().unwrap()).unwrap().history;
|
||||
assert_eq!(h.len(), 1);
|
||||
assert_eq!(h[0].settings.light.exposure, -2.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.catalog.photo(s.active().unwrap()).unwrap().history.len() > 1, "undoable");
|
||||
// select by: the demo has rated and picked photos
|
||||
let vis = s.visible_cloned();
|
||||
let picks = vis.iter().filter(|id| s.catalog.photo(**id).unwrap().flag == lightcraft_catalog::Flag::Pick).count();
|
||||
let r = s.execute("library.selectBy", &json!({"flag": "pick"})).unwrap();
|
||||
assert_eq!(r["selected"].as_u64(), Some(picks as u64));
|
||||
assert!(picks > 0);
|
||||
let four = vis.iter().filter(|id| s.catalog.photo(**id).unwrap().rating >= 4).count();
|
||||
assert_eq!(s.execute("library.selectBy", &json!({"rating": 4})).unwrap()["selected"].as_u64(), Some(four as u64));
|
||||
let r = s.execute("library.selectBy", &json!({"rating": 0, "ratingOp": "eq", "add": true})).unwrap();
|
||||
assert!(r["selected"].as_u64().unwrap() >= four as u64);
|
||||
assert!(s.execute("library.selectBy", &json!({})).is_err());
|
||||
assert!(s.execute("library.selectBy", &json!({"label": "mauve"})).is_err());
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
||||
"---",
|
||||
"library.selectAll",
|
||||
"library.selectNone",
|
||||
"@Select by",
|
||||
"---",
|
||||
"view.focusSearch",
|
||||
"---",
|
||||
@@ -343,6 +344,23 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
|
||||
v.extend(groups.into_iter().map(|(label, g, k)| item("library.sort", json!({"group": k}), label, None, true, Some(cur.group == g))));
|
||||
v
|
||||
}
|
||||
"Select by" => {
|
||||
let mut v: Vec<MenuNode> = [("pick", "Picks"), ("reject", "Rejects"), ("none", "Unflagged")]
|
||||
.into_iter()
|
||||
.map(|(f, label)| item("library.selectBy", json!({"flag": f}), label, None, true, None))
|
||||
.collect();
|
||||
v.push(MenuNode::Separator);
|
||||
v.extend(
|
||||
(1..=5u8).map(|r| item("library.selectBy", json!({"rating": r}), format!("{} and higher", "★".repeat(r as usize)), None, true, None)),
|
||||
);
|
||||
v.push(item("library.selectBy", json!({"rating": 0, "ratingOp": "eq"}), "Unrated", None, true, None));
|
||||
v.push(MenuNode::Separator);
|
||||
v.extend(lightcraft_catalog::ColorLabel::ALL.iter().map(|l| {
|
||||
let name = format!("{l:?}");
|
||||
item("library.selectBy", json!({"label": name.to_lowercase()}), format!("{name} Label"), None, true, None)
|
||||
}));
|
||||
v
|
||||
}
|
||||
"Export with Preset" => {
|
||||
let sel = !app.session.selection.ids.is_empty() || has;
|
||||
let mut v: Vec<MenuNode> = Vec::new();
|
||||
@@ -679,6 +697,30 @@ mod tests {
|
||||
assert_eq!((last["format"].as_str(), last["width"].as_u64(), last.get("preset")), (Some("png"), Some(40), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_by_submenu_and_text_prompt() {
|
||||
let mut app = app();
|
||||
let bar = menu_bar(&app);
|
||||
let edit = &bar.iter().find(|(t, _)| t == "Edit").unwrap().1;
|
||||
let Some(MenuNode::Submenu { children, .. }) = edit.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Select by"))
|
||||
else {
|
||||
panic!("no Select by submenu")
|
||||
};
|
||||
let Some(MenuNode::Item { id, params, .. }) = children.first() else { panic!() };
|
||||
let r = run_item(&mut app, id, params.clone()).unwrap();
|
||||
assert!(r["selected"].as_u64().is_some_and(|n| n > 0), "{r}");
|
||||
// the one-field prompt runs its command with the typed value
|
||||
app.session.execute("version.create", &json!({"name": "A"})).unwrap();
|
||||
crate::panels::dialogs::prompt(&mut app, "Rename Version", "Version name", "A", "version.rename", json!({"index": 0}), "name");
|
||||
if let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog {
|
||||
*value = " Final ".into();
|
||||
}
|
||||
let d = app.ui.dialog.take().unwrap();
|
||||
crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap();
|
||||
let id = app.session.active().unwrap();
|
||||
assert_eq!(app.session.catalog.photo(id).unwrap().versions[0].name, "Final");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_text_per_platform() {
|
||||
assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⌘⇧Z");
|
||||
|
||||
@@ -18,7 +18,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
});
|
||||
let mut close = false;
|
||||
let mut confirm = false;
|
||||
let title = match &dlg {
|
||||
let title: String = match &dlg {
|
||||
Dialog::TextPrompt { title, .. } => title.as_str(),
|
||||
Dialog::NewAlbum { folder: true, .. } => "Create Folder",
|
||||
Dialog::NewAlbum { .. } => "Create Album",
|
||||
Dialog::RenameAlbum { .. } => "Rename Album",
|
||||
@@ -39,7 +40,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Dialog::ConfirmDelete { .. } => "Delete Photos",
|
||||
Dialog::About => "About LightCraft",
|
||||
Dialog::Shortcuts => "Keyboard Shortcuts",
|
||||
};
|
||||
}
|
||||
.to_string();
|
||||
let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(16, 12));
|
||||
let shown = egui::Window::new(title)
|
||||
.collapsible(false)
|
||||
@@ -220,6 +222,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
});
|
||||
}
|
||||
Dialog::TextPrompt { value, hint, .. } => {
|
||||
let r = ui.add(egui::TextEdit::singleline(value).hint_text(hint.as_str()).desired_width(f32::INFINITY));
|
||||
r.request_focus();
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
confirm = true;
|
||||
}
|
||||
}
|
||||
Dialog::NewAlbum { name, .. } | Dialog::RenameAlbum { name, .. } => {
|
||||
let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY));
|
||||
r.request_focus();
|
||||
@@ -569,6 +578,11 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
|
||||
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::TextPrompt { value, command, params, key, .. } => {
|
||||
let mut p = params.clone();
|
||||
p[key.as_str()] = json!(value.trim());
|
||||
app.run(command, p)
|
||||
}
|
||||
Dialog::CaptureTime { mode, time, days, hours, minutes, zone } => app.run(
|
||||
"photo.setCaptureTime",
|
||||
match mode.as_str() {
|
||||
@@ -612,6 +626,12 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a one-field dialog that runs `command` with `params` + `{key: typed value}`.
|
||||
pub fn prompt(app: &mut LightcraftApp, title: &str, hint: &str, value: &str, command: &str, params: serde_json::Value, key: &str) {
|
||||
app.ui.dialog =
|
||||
Some(Dialog::TextPrompt { title: title.into(), hint: hint.into(), value: value.into(), command: command.into(), params, key: key.into() });
|
||||
}
|
||||
|
||||
/// The Export dialog's choices as `app.export` params (without the folder).
|
||||
fn export_dialog_params(
|
||||
opts: &lightcraft_engine::export::ExportOptions,
|
||||
|
||||
@@ -148,10 +148,54 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
ui.data_mut(|d| d.insert_temp(amt_id, (pid.clone(), 100.0)));
|
||||
app.toast(ui.ctx(), format!("Preset: {name}"));
|
||||
}
|
||||
let (builtin, group) = (pr.builtin, pr.group.clone());
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button(if fav { "Remove from Favorites" } else { "Add to Favorites" }).clicked() {
|
||||
let _ = app.run("preset.favorite", json!({"id": pid}));
|
||||
}
|
||||
if builtin {
|
||||
return;
|
||||
}
|
||||
ui.separator();
|
||||
if ui.add_enabled(app.session.active().is_some(), egui::Button::new("Update with Current Settings")).clicked() {
|
||||
match app.run("preset.update", json!({"id": pid})) {
|
||||
Ok(_) => app.toast(ui.ctx(), format!("Updated “{name}”")),
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
}
|
||||
}
|
||||
if ui.button("Rename…").clicked() {
|
||||
crate::panels::dialogs::prompt(
|
||||
app,
|
||||
"Rename Preset",
|
||||
"Preset name",
|
||||
&name,
|
||||
"preset.rename",
|
||||
json!({"id": pid}),
|
||||
"name",
|
||||
);
|
||||
}
|
||||
ui.menu_button("Move to Group", |ui| {
|
||||
let mut groups: Vec<String> = app.session.presets.iter().filter(|p| !p.builtin).map(|p| p.group.clone()).collect();
|
||||
groups.sort();
|
||||
groups.dedup();
|
||||
for g in groups.iter().filter(|g| **g != group) {
|
||||
if ui.button(g).clicked() {
|
||||
let _ = app.run("preset.move", json!({"id": pid, "group": g}));
|
||||
}
|
||||
}
|
||||
if ui.button("New Group…").clicked() {
|
||||
crate::panels::dialogs::prompt(
|
||||
app,
|
||||
"Move Preset to New Group",
|
||||
"Group name",
|
||||
"",
|
||||
"preset.move",
|
||||
json!({"id": pid}),
|
||||
"group",
|
||||
);
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if ui.button("Delete Preset").clicked() {
|
||||
let _ = app.run("preset.delete", json!({"id": pid}));
|
||||
}
|
||||
|
||||
@@ -446,9 +446,25 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
for k in &p.meta.keywords {
|
||||
let r =
|
||||
ui.add(egui::Button::new(egui::RichText::new(format!("{} ×", k.replace('|', " › "))).color(t.text_label)).corner_radius(10.0));
|
||||
register(ui.ctx(), format!("keywordChip:{k}"), r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]}));
|
||||
}
|
||||
r.context_menu(|ui| {
|
||||
if ui.button("Remove from Photo").clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]}));
|
||||
}
|
||||
if ui.button("Show Photos with Keyword").clicked() {
|
||||
let _ = app.run("library.filter", json!({"keyword": k}));
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Rename Keyword…").clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: k.clone(), to: k.clone() });
|
||||
}
|
||||
if ui.button("Delete Keyword").clicked() {
|
||||
let _ = app.run("keyword.delete", json!({"keyword": k}));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
@@ -486,9 +502,29 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
ui.add_space(8.0);
|
||||
for (i, v) in p.versions.iter().enumerate() {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(&v.name).on_hover_text(&v.created).clicked() {
|
||||
let b = ui.button(&v.name).on_hover_text(&v.created);
|
||||
register(ui.ctx(), format!("version:{i}"), b.rect);
|
||||
if b.clicked() {
|
||||
let _ = app.run("version.restore", json!({"index": i}));
|
||||
}
|
||||
b.context_menu(|ui| {
|
||||
if ui.button("Restore").clicked() {
|
||||
let _ = app.run("version.restore", json!({"index": i}));
|
||||
}
|
||||
if ui.button("Update with Current Settings").clicked() {
|
||||
let _ = app.run("version.update", json!({"index": i}));
|
||||
}
|
||||
if ui.button("Rename…").clicked() {
|
||||
crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name");
|
||||
}
|
||||
if ui.button("Set as Before").clicked() {
|
||||
let _ = app.run("beforeAfter.setBefore", json!({"source": "version", "name": v.name}));
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Delete").clicked() {
|
||||
let _ = app.run("version.delete", json!({"index": i}));
|
||||
}
|
||||
});
|
||||
if ui.small_button("×").clicked() {
|
||||
let _ = app.run("version.delete", json!({"index": i}));
|
||||
}
|
||||
@@ -523,6 +559,14 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
if ui.button("Copy History Step Settings to Before").clicked() {
|
||||
let _ = app.run("beforeAfter.setBefore", json!({"source": "history", "index": i}));
|
||||
}
|
||||
if ui.button("Create Version from Step").clicked() {
|
||||
let _ = app.run("history.restore", json!({"index": i}));
|
||||
let _ = app.run("version.create", json!({"name": h.label}));
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Clear History").clicked() {
|
||||
let _ = app.run("history.clear", json!({}));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -287,6 +287,16 @@ pub enum Dialog {
|
||||
id: u64,
|
||||
name: String,
|
||||
},
|
||||
/// One text field; OK runs `command` with `params` plus `{key: value}` (rename a preset or a
|
||||
/// version, move a preset to a new group…).
|
||||
TextPrompt {
|
||||
title: String,
|
||||
hint: String,
|
||||
value: String,
|
||||
command: String,
|
||||
params: serde_json::Value,
|
||||
key: String,
|
||||
},
|
||||
/// Edit Capture Time: `mode` "set" (`time`; the others shift along), "shift" (by `days`,
|
||||
/// `hours`, `minutes`) or "zone" (time-zone shift by `zone` hours).
|
||||
CaptureTime {
|
||||
|
||||
+11
-11
@@ -32,7 +32,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
| I. Remove / healing (REM) | 6 | 1 | 3 | 2 | 4/4 (100%) | 2/3 (67%) |
|
||||
| J. Red eye (EYE) | 1 | 0 | 1 | 0 | — | 1/1 (100%) |
|
||||
| K. Masking (MASK) | 11 | 5 | 7 | 0 | 8/8 (100%) | 3/5 (60%) |
|
||||
| L. Presets (PRE) | 3 | 2 | 2 | 1 | 2/2 (100%) | 1/2 (50%) |
|
||||
| L. Presets (PRE) | 4 | 1 | 2 | 1 | 2/2 (100%) | 2/2 (100%) |
|
||||
| M. Versions & history (VER) | 3 | 1 | 1 | 0 | 1/1 (100%) | 2/3 (67%) |
|
||||
| N. Copy / paste / sync (SYNC) | 4 | 0 | 1 | 0 | 3/3 (100%) | 1/1 (100%) |
|
||||
| O. Merge (MERGE) | 3 | 0 | 1 | 0 | — | — |
|
||||
@@ -45,10 +45,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
| V. Preferences (PREF) | 4 | 1 | 3 | 3 | 1/1 (100%) | 3/4 (75%) |
|
||||
| W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — |
|
||||
| X. Cross-cutting behaviours (BEHAV) | 11 | 2 | 4 | 1 | 8/8 (100%) | 3/5 (60%) |
|
||||
| Y. Menus | 61 | 11 | 13 | 8 | 46/47 (98%) | 13/23 (57%) |
|
||||
| Y. Menus | 65 | 9 | 11 | 8 | 46/47 (98%) | 17/23 (74%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 59 | 12 | 9 | 1 | 49/52 (94%) | 10/23 (43%) |
|
||||
| Lightroom Classic extras | 6 | 20 | 53 | 9 | — | 5/21 (24%) |
|
||||
| **Total** | 283 | 71 | 146 | 37 | 192/198 (97%) | 81/144 (56%) |
|
||||
| Lightroom Classic extras | 7 | 19 | 53 | 9 | — | 6/21 (29%) |
|
||||
| **Total** | 289 | 67 | 144 | 37 | 192/198 (97%) | 87/144 (60%) |
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
@@ -336,7 +336,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
|---|---|---|---|---|---|
|
||||
| LR-PRE-PANEL | Presets panel | P0 | ✅ | `cmd:panel.presets`, `cmd:preset.apply`, `crates/ui-egui/src/panels/presets.rs` | grouped list with amount; resting on a preset previews it in the loupe (no history entry); optional live thumbnails (⋯ → Show Thumbnails) |
|
||||
| LR-PRE-CREATE | Create preset | P0 | ✅ | `cmd:dialog.createPreset`, `cmd:preset.create` (`groups`) | name, group and a checklist of settings groups (crop, masks, remove, red eye off by default; All / None) |
|
||||
| LR-PRE-MANAGE | Manage presets | P1 | 🟡 | `cmd:preset.delete`, `cmd:preset.favorite`, `cmd:preset.import`, `cmd:preset.export` | no rename, update-with-current, move group, hide groups |
|
||||
| LR-PRE-MANAGE | Manage presets | P1 | ✅ | `cmd:preset.delete`, `cmd:preset.favorite`, `cmd:preset.import`, `cmd:preset.export`, `cmd:preset.rename`, `cmd:preset.update`, `cmd:preset.move` | rename, update with current settings, move to a group (existing or new); no hiding of groups |
|
||||
| LR-PRE-AMOUNT | Preset amount | P1 | ✅ | `cmd:preset.apply` (`amount` 0–200) | |
|
||||
| LR-PRE-ADAPTIVE | Adaptive presets | P2 | ⬜ | | |
|
||||
| LR-PRE-PREMIUM | Built-in presets (own) | P2 | 🟡 | `crates/engine/src/presets.rs` | 18 own-authored presets |
|
||||
@@ -348,7 +348,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-VER-CREATE | Create version | P1 | ✅ | `cmd:version.create` | |
|
||||
| LR-VER-PANEL | Versions panel | P1 | 🟡 | `cmd:panel.versions`, `cmd:version.restore`, `cmd:version.delete` | no rename, update, hover preview, named/auto tabs |
|
||||
| LR-VER-PANEL | Versions panel | P1 | 🟡 | `cmd:panel.versions`, `cmd:version.restore`, `cmd:version.delete`, `cmd:version.rename`, `cmd:version.update` | rename, update, set as before; no hover preview, named/auto tabs |
|
||||
| LR-VER-AUTO | Automatic versions | P2 | ⬜ | | `auto` flag reserved in the model |
|
||||
| LR-VER-HISTORY | Edit history | P1 | ✅ | `cmd:panel.activity`, `cmd:history.list`, `cmd:history.restore` | |
|
||||
| LR-VER-UNDO | Undo / redo | P0 | ✅ | `cmd:edit.undo`, `cmd:edit.redo` | |
|
||||
@@ -524,7 +524,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
|
||||
| MENU-EDIT-PASTESELECTED | Paste Selected Settings | P0 | ✅ | `cmd:dialog.pasteSettings`, `cmd:develop.paste` (`groups`) | checklist prefilled with the copied groups; only copied groups can be pasted |
|
||||
| MENU-EDIT-SELECTALL | Select All | P0 | ✅ | `cmd:library.selectAll` | |
|
||||
| MENU-EDIT-SELECTNONE | Select None | P0 | ✅ | `cmd:library.selectNone` | |
|
||||
| MENU-EDIT-SELECTBY | Select by flag / rating | P1 | ⬜ | | |
|
||||
| MENU-EDIT-SELECTBY | Select by flag / rating | P1 | ✅ | `cmd:library.selectBy` | Edit → Select by: picks, rejects, unflagged, ★…★★★★★ and higher, unrated, colour labels; `add` extends the selection |
|
||||
| MENU-EDIT-FIND | Find… | P0 | ✅ | `cmd:view.focusSearch`, `crates/ui-egui/src/panels/topbar.rs` | ⌘F focuses the search field |
|
||||
| MENU-VIEW-PHOTOGRID | Photo Grid | P0 | ✅ | `cmd:view.photoGrid` | |
|
||||
| MENU-VIEW-SQUAREGRID | Square Grid | P0 | ✅ | `cmd:view.squareGrid` | |
|
||||
@@ -583,10 +583,10 @@ visible menu bar yet: the menu model is only exposed through the control channel
|
||||
| MENU-CTX-DETAIL | Loupe context menu | P1 | 🟡 | `crates/ui-egui/src/panels/detail.rs` | same as grid; no zoom submenu |
|
||||
| MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export` | add selected, export album (dialog / preset), move to a folder or the top level, rename, delete; smart albums: update rules |
|
||||
| MENU-CTX-MASK | Mask / component menu | P0 | 🟡 | `crates/ui-egui/src/panels/masking.rs` (`mask_menu`) | mask rows: duplicate (and invert), invert, show/hide, move, rename, delete; components: add/subtract only (no per-component menu) |
|
||||
| MENU-CTX-PRESET | Preset menu | P1 | 🟡 | `crates/ui-egui/src/panels/presets.rs` | favourite, delete, export group; no rename/update/move |
|
||||
| MENU-CTX-PRESET | Preset menu | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs` | favourite, update with current settings, rename, move to group, delete; group: export |
|
||||
| MENU-CTX-PROFILE | Profile favourites | P2 | ⬜ | | |
|
||||
| MENU-CTX-VERSION | Version menu | P1 | ⬜ | | delete button only |
|
||||
| MENU-CTX-KEYWORD | Keyword chip menu | P1 | 🟡 | `crates/ui-egui/src/panels/right.rs` | click removes; no rename/delete keyword |
|
||||
| MENU-CTX-VERSION | Version menu | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (`versions`) | restore, update with current settings, rename, set as before, delete |
|
||||
| MENU-CTX-KEYWORD | Keyword chip menu | P1 | ✅ | `crates/ui-egui/src/panels/right.rs`, `cmd:keyword.delete` | remove from photo, show photos with keyword, rename keyword, delete keyword |
|
||||
|
||||
## Z. Keyboard shortcuts (desktop)
|
||||
|
||||
@@ -710,7 +710,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
||||
| LRC-LIB-PREVIEWS | Build / discard previews | P1 | 🟡 | `cmd:library.clearPreviews`, `crates/preview/src/lib.rs` | disk thumbnail cache; no build-1:1 / smart previews |
|
||||
| LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ⬜ | | |
|
||||
| LRC-DEV-SNAPSHOTS | Named snapshots | P1 | ✅ | `cmd:version.create`, `cmd:version.restore` | = versions |
|
||||
| LRC-DEV-HISTORY | Full history panel | P1 | 🟡 | `cmd:history.list`, `cmd:history.restore` | no clear, no snapshot-from-step |
|
||||
| LRC-DEV-HISTORY | Full history panel | P1 | ✅ | `cmd:history.list`, `cmd:history.restore`, `cmd:history.clear` | row menu: copy step to before, create version from step, clear history |
|
||||
| LRC-DEV-SOFTPROOF | Soft proofing | P2 | ⬜ | | |
|
||||
| LRC-DEV-AUTOSYNC | Sync / auto sync / paste previous | P1 | 🟡 | `cmd:develop.sync` | no auto sync or paste-from-previous |
|
||||
| LRC-DEV-MATCHEXP | Match total exposures | P2 | ⬜ | | |
|
||||
|
||||
Reference in New Issue
Block a user