mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 03:27:14 -06:00
Merge pull request #71 from storytold/fix/issue-31-rename-tag-help
Issue #31: Import: tag help beside the Rename field
This commit is contained in:
@@ -735,7 +735,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Photos…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+I"),
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), destination?: folder for copies, organize?: date|month|flat, rename?: file-name template for copies ({name} {seq:N} {date:%Y%m%d} {camera} {title}), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG), local?: bool (browsing: the photos stay out of the library, like library.browse), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}",
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), destination?: folder for copies, organize?: date|month|flat, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG), local?: bool (browsing: the photos stay out of the library, like library.browse), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let paths = strs(p, "paths");
|
||||
|
||||
@@ -61,7 +61,10 @@ fn set_names_ops(s: &crate::Session, names: &[String; 5]) -> Vec<Op> {
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| {
|
||||
cmd!(query "photo.renameTokens", "Rename Template Tags", [], None, "{} → {tokens: [{tag, aliases, meaning, example}], dateDirectives: [{directive, meaning}], notes: [..], sample} — the file-name template tags shared by photo.rename, library.import (rename) and app.export (naming); examples are for a sample photo", always, |_, _| {
|
||||
Ok(crate::rename::token_help_json())
|
||||
}),
|
||||
cmd!(query "photo.renamePreview", "Rename Preview", [], None, "{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable", has_selection, |s, p| {
|
||||
let (ids, template, start) = rename_args(s, p, "photo.renamePreview")?;
|
||||
Ok(serde_json::to_value(s.plan_rename(&ids, &template, start)).unwrap_or_default())
|
||||
}),
|
||||
@@ -70,7 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rename Photos",
|
||||
[],
|
||||
None,
|
||||
"{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable",
|
||||
"{template: e.g. `{date}_{name}`, `Trip-{seq:3}` (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each), start?: first sequence number (1), ids?} — renames the files on disk (sidecars too, never overwriting: collisions get -1, -2…); undoable",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let (ids, template, start) = rename_args(s, p, "photo.rename")?;
|
||||
|
||||
@@ -82,6 +82,113 @@ fn format_date(iso: &str, fmt: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// One template token, for help texts and tag pickers. [`TOKENS`] is the single list every UI and
|
||||
/// command description shows; a test checks that [`expand_tokens`] knows each of them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct TokenHelp {
|
||||
/// The token as typed, e.g. `{seq:3}`.
|
||||
pub tag: &'static str,
|
||||
/// Other spellings with the same meaning.
|
||||
pub aliases: &'static [&'static str],
|
||||
pub meaning: &'static str,
|
||||
}
|
||||
|
||||
/// Every template token, in the order the help shows them.
|
||||
pub const TOKENS: &[TokenHelp] = &[
|
||||
TokenHelp { tag: "{name}", aliases: &["{filename}"], meaning: "Original file name without its extension" },
|
||||
TokenHelp { tag: "{num}", aliases: &[], meaning: "The number at the end of the original name (IMG_0042 → 0042)" },
|
||||
TokenHelp { tag: "{seq}", aliases: &["{n}"], meaning: "Sequence number, counting from the start number" },
|
||||
TokenHelp { tag: "{seq:3}", aliases: &["{n:3}"], meaning: "Sequence number zero-padded to N digits (1–9)" },
|
||||
TokenHelp { tag: "{date}", aliases: &[], meaning: "Capture date as YYYYMMDD" },
|
||||
TokenHelp { tag: "{date:%Y%m%d_%H%M%S}", aliases: &[], meaning: "Capture date and time in your own format (directives below)" },
|
||||
TokenHelp { tag: "{folder}", aliases: &[], meaning: "Name of the folder the original is in" },
|
||||
TokenHelp { tag: "{camera}", aliases: &[], meaning: "Camera make and model" },
|
||||
TokenHelp { tag: "{lens}", aliases: &[], meaning: "Lens" },
|
||||
TokenHelp { tag: "{iso}", aliases: &[], meaning: "ISO speed" },
|
||||
TokenHelp { tag: "{rating}", aliases: &[], meaning: "Star rating (0–5)" },
|
||||
TokenHelp { tag: "{title}", aliases: &[], meaning: "Title metadata" },
|
||||
TokenHelp { tag: "{creator}", aliases: &[], meaning: "Creator metadata" },
|
||||
TokenHelp { tag: "{ext}", aliases: &[], meaning: "Original extension without the dot" },
|
||||
];
|
||||
|
||||
/// The `%` directives of `{date:…}`.
|
||||
pub const DATE_DIRECTIVES: &[(&str, &str)] = &[
|
||||
("%Y", "year, 4 digits"),
|
||||
("%y", "year, 2 digits"),
|
||||
("%m", "month 01–12"),
|
||||
("%d", "day 01–31"),
|
||||
("%H", "hour 00–23"),
|
||||
("%M", "minute"),
|
||||
("%S", "second"),
|
||||
("%%", "a literal %"),
|
||||
];
|
||||
|
||||
/// How templates behave, one sentence each (shown under the token list).
|
||||
pub const TEMPLATE_NOTES: &[&str] = &[
|
||||
"The original extension is always added; {ext} only puts it inside the name as well.",
|
||||
"A blank template keeps the original names.",
|
||||
"{date} is the capture time; a photo without one uses the time it was imported.",
|
||||
"Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.",
|
||||
"Unknown tags stay as typed — check the preview for a {typo}.",
|
||||
"Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….",
|
||||
];
|
||||
|
||||
/// The tokens as one line (`{name} {num} {seq} …`), for compact hints.
|
||||
pub fn token_summary() -> String {
|
||||
TOKENS.iter().map(|t| t.tag).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// A fixed photo the help's examples are computed from (`IMG_0042.CR3`, 14 Jan 2026 05:58:48).
|
||||
pub fn sample_photo() -> Photo {
|
||||
let mut p =
|
||||
Photo::new(PhotoId(0), Source::File { path: "/Card/DCIM/IMG_0042.CR3".into() }, "IMG_0042.CR3", "CR3", 6000, 4000, "2026-01-20T10:00:00");
|
||||
p.captured = Some("2026-01-14T05:58:48".into());
|
||||
p.rating = 4;
|
||||
p.meta.camera = "Canon EOS R5".into();
|
||||
p.meta.lens = "RF24-70mm F2.8".into();
|
||||
p.meta.iso = Some(400);
|
||||
p.meta.title = "Harbour".into();
|
||||
p.meta.creator = "Ann Lee".into();
|
||||
p
|
||||
}
|
||||
|
||||
/// The token as expanded for [`sample_photo`] (sequence number 1), for the help's example column.
|
||||
pub fn token_example(tag: &str) -> String {
|
||||
expand_tokens(tag, &sample_photo(), 1, 1)
|
||||
}
|
||||
|
||||
/// The `{…}` tags in `template` that aren't tokens (they stay as typed), for a warning.
|
||||
pub fn unknown_tokens(template: &str) -> Vec<String> {
|
||||
let p = sample_photo();
|
||||
let mut out = Vec::new();
|
||||
let mut rest = template;
|
||||
while let Some(i) = rest.find('{') {
|
||||
let Some(j) = rest[i..].find('}') else { break };
|
||||
let tag = &rest[i..i + j + 1];
|
||||
if expand_tokens(tag, &p, 1, 1) == tag && !out.iter().any(|t| t == tag) {
|
||||
out.push(tag.to_string());
|
||||
}
|
||||
rest = &rest[i + j + 1..];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every token with its meaning and example, the date directives and the notes, as JSON (the
|
||||
/// `photo.renameTokens` command).
|
||||
pub fn token_help_json() -> serde_json::Value {
|
||||
let tokens: Vec<serde_json::Value> = TOKENS
|
||||
.iter()
|
||||
.map(|t| serde_json::json!({"tag": t.tag, "aliases": t.aliases, "meaning": t.meaning, "example": token_example(t.tag)}))
|
||||
.collect();
|
||||
let directives: Vec<serde_json::Value> = DATE_DIRECTIVES.iter().map(|(d, m)| serde_json::json!({"directive": d, "meaning": m})).collect();
|
||||
serde_json::json!({
|
||||
"tokens": tokens,
|
||||
"dateDirectives": directives,
|
||||
"notes": TEMPLATE_NOTES,
|
||||
"sample": "IMG_0042.CR3, captured 2026-01-14 05:58:48",
|
||||
})
|
||||
}
|
||||
|
||||
/// Expand `template` for `p` (sequence number `seq`) into a file name with `p`'s extension.
|
||||
pub fn expand(template: &str, p: &Photo, seq: usize) -> String {
|
||||
let (stem, ext) = split_ext(&p.file_name);
|
||||
@@ -358,6 +465,41 @@ impl Session {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The help lists exactly what `expand_tokens` understands: every listed tag (and alias) is
|
||||
/// replaced, never left literal, and the help's examples match the documented behaviour.
|
||||
#[test]
|
||||
fn token_help_matches_the_implementation() {
|
||||
let p = sample_photo();
|
||||
for t in TOKENS {
|
||||
for tag in std::iter::once(&t.tag).chain(t.aliases) {
|
||||
let v = expand_tokens(tag, &p, 1, 1);
|
||||
assert!(!v.contains('{'), "{tag} is not a known token (expanded to {v:?})");
|
||||
assert!(!v.is_empty(), "{tag}: the sample photo should give an example");
|
||||
}
|
||||
}
|
||||
assert_eq!(token_example("{seq:3}"), "001");
|
||||
assert_eq!(token_example("{date}"), "20260114");
|
||||
assert_eq!(token_example("{date:%Y%m%d_%H%M%S}"), "20260114_055848");
|
||||
assert_eq!(token_example("{ext}"), "CR3");
|
||||
assert_eq!(expand("{date:%Y%m%d_%H%M%S}_{seq:3}", &p, 1), "20260114_055848_001.CR3");
|
||||
for (d, _) in DATE_DIRECTIVES {
|
||||
let v = format_date("2026-01-14T05:58:48", d);
|
||||
assert!(!v.contains('%') || *d == "%%", "{d} is not a known directive");
|
||||
}
|
||||
assert_eq!(unknown_tokens("{date}_{camra}-{seq:2}{x}{camra}"), vec!["{camra}".to_string(), "{x}".to_string()]);
|
||||
assert!(unknown_tokens("{name}{ext}{date:%Y}").is_empty());
|
||||
let json = token_help_json();
|
||||
assert_eq!(json["tokens"].as_array().unwrap().len(), TOKENS.len());
|
||||
assert_eq!(json["tokens"][0]["example"], "IMG_0042");
|
||||
// the command descriptions that list the tokens list all of them
|
||||
for id in ["photo.rename", "photo.renamePreview", "library.import"] {
|
||||
let spec = crate::cmd::command_specs().iter().find(|c| c.id == id).unwrap();
|
||||
for t in TOKENS.iter().filter(|t| !t.tag.contains(':')) {
|
||||
assert!(spec.params.contains(t.tag), "{id} does not mention {}", t.tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templates() {
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 1 }, "IMG_0042.CR2", "CR2", 1, 1, "2026-01-01T00:00:00");
|
||||
|
||||
@@ -1259,6 +1259,52 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Import review, Copy: Tags beside the Rename field lists the template tags; clicking one
|
||||
/// inserts it at the text cursor (not appended, not replacing the template).
|
||||
#[test]
|
||||
fn import_rename_tags_insert_at_the_cursor() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-ui-import-tags-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 3, 9, 255]; 64] };
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::write(dir.join("IMG_0007.png"), png).unwrap();
|
||||
let lib = dir.join("lib");
|
||||
let mut session = lightcraft_engine::Session::with_demo().with_fs();
|
||||
session.open_library(&lib, false).unwrap();
|
||||
let mut app = LightcraftApp::new(session, crate::Services { png: None, ..Default::default() });
|
||||
app.ui.view = crate::state::ViewMode::PhotoGrid;
|
||||
let mut h = Headless::new(app, [1300.0, 1000.0], 1.0);
|
||||
let t = Duration::from_secs(10);
|
||||
let r =
|
||||
h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.join("IMG_0007.png").to_string_lossy()]}}), t);
|
||||
assert_eq!(r["result"]["scanning"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:importCopy"}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog {
|
||||
opts.rename = "Trip-_x".into();
|
||||
}
|
||||
// the cursor sits after "Trip-"
|
||||
let id = egui::Id::new("import-rename");
|
||||
let mut st = egui::text_edit::TextEditState::default();
|
||||
st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(5))));
|
||||
st.store(&h.view.ctx, id);
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:importRenameTags"}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let seq3 = lightcraft_engine::rename::TOKENS.iter().position(|x| x.tag == "{seq:3}").unwrap();
|
||||
let r = h.request("ui.clickWidget", json!({"id": format!("button:importRenameTag-{seq3}")}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import dialog") };
|
||||
assert_eq!(opts.rename, "Trip-{seq:3}_x", "inserted at the cursor");
|
||||
// a second tag goes after the first (the cursor moved past it)
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:importRenameTag-0"}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import dialog") };
|
||||
assert_eq!(opts.rename, "Trip-{seq:3}{name}_x");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Settings (⌘,): tabs switch, app settings change the UI state, library settings go through
|
||||
/// the engine; the delete confirmation guards ⌫.
|
||||
#[test]
|
||||
|
||||
@@ -449,26 +449,39 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
let r = ui.checkbox(&mut d.dng, "Copy as DNG");
|
||||
register(ui.ctx(), "check:importDng", r.rect);
|
||||
});
|
||||
field(ui, "Rename", |ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.rename).hint_text("keep names — or e.g. {date}_{seq:3}").desired_width(f32::INFINITY));
|
||||
let rename_id = egui::Id::new("import-rename");
|
||||
let tags_open = field(ui, "Rename", |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
let w = (ui.available_width() - 50.0).max(80.0);
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.rename).id(rename_id).hint_text("keep names — or e.g. {date}_{seq:3}").desired_width(w));
|
||||
register(ui.ctx(), "field:importRename", r.rect);
|
||||
tag_toggle(ui, "importRename")
|
||||
});
|
||||
if tags_open {
|
||||
tag_help(ui, "importRename", &mut d.rename, rename_id);
|
||||
}
|
||||
if !d.rename.trim().is_empty()
|
||||
&& let Some(c) = d.candidates.iter().zip(&d.checked).find(|(c, on)| **on && c.duplicate.is_none()).map(|(c, _)| c)
|
||||
{
|
||||
let mut q = lightcraft_catalog::Photo::new(
|
||||
lightcraft_catalog::PhotoId(0),
|
||||
lightcraft_catalog::Source::Demo { scene: 0 },
|
||||
lightcraft_catalog::Source::File { path: c.path.clone() },
|
||||
&c.name,
|
||||
&c.format,
|
||||
0,
|
||||
0,
|
||||
"",
|
||||
// a photo without a capture time is dated by the import (now)
|
||||
&(app.session.clock)(),
|
||||
);
|
||||
q.captured = c.captured.clone();
|
||||
// the probe's metadata, so {camera}, {title}… preview as they will import
|
||||
if let Some(info) = app.session.import_probes.get(&c.path) {
|
||||
q.meta = info.meta.clone();
|
||||
}
|
||||
let example = lightcraft_engine::rename::expand(d.rename.trim(), &q, 1);
|
||||
ui.label(egui::RichText::new(format!("{} → {example}", c.name)).color(Tokens::get(ui.ctx()).text_dim));
|
||||
}
|
||||
unknown_tags_warning(ui, &d.rename);
|
||||
}
|
||||
let albums: Vec<(u64, String)> = {
|
||||
let mut v: Vec<(u64, String)> =
|
||||
@@ -602,6 +615,85 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial
|
||||
}
|
||||
}
|
||||
|
||||
/// The "Tags" toggle beside a template field (`button:<key>Tags`); returns whether the tag help
|
||||
/// ([`tag_help`]) is open.
|
||||
pub(crate) fn tag_toggle(ui: &mut egui::Ui, key: &str) -> bool {
|
||||
let id = egui::Id::new((key, "tags-open"));
|
||||
let mut open = ui.ctx().data(|d| d.get_temp::<bool>(id)).unwrap_or(false);
|
||||
let r = crate::widgets::text_button(ui, &format!("{key}Tags"), "Tags", open).on_hover_text("Show the template tags; click one to insert it");
|
||||
if r.clicked() {
|
||||
open = !open;
|
||||
ui.ctx().data_mut(|d| d.insert_temp(id, open));
|
||||
}
|
||||
open
|
||||
}
|
||||
|
||||
/// "Unknown tag {x} stays as typed" under a template field, when it has one.
|
||||
pub(crate) fn unknown_tags_warning(ui: &mut egui::Ui, template: &str) {
|
||||
let unknown = lightcraft_engine::rename::unknown_tokens(template);
|
||||
if !unknown.is_empty() {
|
||||
let s = if unknown.len() == 1 { "" } else { "s" };
|
||||
let text = format!("Unknown tag{s} {} stay{} as typed (see Tags)", unknown.join(" "), if unknown.len() == 1 { "s" } else { "" });
|
||||
ui.label(egui::RichText::new(text).color(Tokens::get(ui.ctx()).caution));
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert `tag` into `text` at the text cursor of the field `edit_id` (replacing its selection);
|
||||
/// appended when the field never had a cursor. The cursor ends up after the tag.
|
||||
pub(crate) fn insert_at_cursor(ctx: &egui::Context, edit_id: egui::Id, text: &mut String, tag: &str) {
|
||||
use egui::text::{CCursor, CCursorRange};
|
||||
let mut state = egui::text_edit::TextEditState::load(ctx, edit_id).unwrap_or_default();
|
||||
let n = text.chars().count();
|
||||
let (a, b) = state.cursor.char_range().map_or((n, n), |r| {
|
||||
let (x, y) = (r.primary.index.0.min(n), r.secondary.index.0.min(n));
|
||||
(x.min(y), x.max(y))
|
||||
});
|
||||
let byte = |c: usize| text.char_indices().nth(c).map_or(text.len(), |(i, _)| i);
|
||||
let (ba, bb) = (byte(a), byte(b));
|
||||
text.replace_range(ba..bb, tag);
|
||||
state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(a + tag.chars().count()))));
|
||||
state.store(ctx, edit_id);
|
||||
ctx.memory_mut(|m| m.request_focus(edit_id));
|
||||
}
|
||||
|
||||
/// The template tag help: every tag (from the engine's one list, `rename::TOKENS`) with its
|
||||
/// meaning and an example, the `{date:…}` directives and how templates behave. Clicking a tag
|
||||
/// (`button:<key>Tag-<i>`) inserts it at the cursor of the field `edit_id`.
|
||||
pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id: egui::Id) {
|
||||
use lightcraft_engine::rename::{DATE_DIRECTIVES, TEMPLATE_NOTES, TOKENS, token_example};
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Frame::new().fill(t.inset).corner_radius(4.0).inner_margin(6.0).show(ui, |ui| {
|
||||
ui.label(
|
||||
egui::RichText::new("Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.").color(t.text_dim).small(),
|
||||
);
|
||||
egui::ScrollArea::vertical().id_salt((key, "tags")).max_height(170.0).show(ui, |ui| {
|
||||
egui::Grid::new((key, "tag-grid")).num_columns(3).spacing([10.0, 2.0]).show(ui, |ui| {
|
||||
for (i, tok) in TOKENS.iter().enumerate() {
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(tok.tag).monospace().color(t.text)).small()).on_hover_text(
|
||||
if tok.aliases.is_empty() {
|
||||
"Insert at the cursor".to_string()
|
||||
} else {
|
||||
format!("Insert at the cursor (also written {})", tok.aliases.join(", "))
|
||||
},
|
||||
);
|
||||
register(ui.ctx(), format!("button:{key}Tag-{i}"), r.rect);
|
||||
if r.clicked() {
|
||||
insert_at_cursor(ui.ctx(), edit_id, text, tok.tag);
|
||||
}
|
||||
ui.label(egui::RichText::new(tok.meaning).color(t.text_label));
|
||||
ui.label(egui::RichText::new(token_example(tok.tag)).monospace().color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
let directives: Vec<String> = DATE_DIRECTIVES.iter().map(|(d, m)| format!("{d} {m}")).collect();
|
||||
ui.label(egui::RichText::new(format!("{{date:…}} directives: {}", directives.join(" · "))).color(t.text_dim).small());
|
||||
for n in TEMPLATE_NOTES {
|
||||
ui.label(egui::RichText::new(format!("• {n}")).color(t.text_dim).small());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A labelled row (fixed label column).
|
||||
fn field<R>(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
||||
@@ -289,10 +289,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Dialog::Rename { template, start } => {
|
||||
let n = app.session.targets(&json!({})).len();
|
||||
ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim));
|
||||
field(ui, "Template", |ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(template).hint_text("{name}").desired_width(f32::INFINITY));
|
||||
let template_id = egui::Id::new("rename-template");
|
||||
let tags_open = field(ui, "Template", |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
let w = (ui.available_width() - 50.0).max(80.0);
|
||||
let r = ui.add(egui::TextEdit::singleline(template).id(template_id).hint_text("{name}").desired_width(w));
|
||||
crate::widgets::register(ui.ctx(), "field:renameTemplate", r.rect);
|
||||
crate::import::tag_toggle(ui, "renameTemplate")
|
||||
});
|
||||
if tags_open {
|
||||
crate::import::tag_help(ui, "renameTemplate", template, template_id);
|
||||
}
|
||||
crate::import::unknown_tags_warning(ui, template);
|
||||
field(ui, "Presets", |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
for (i, (label, tpl)) in
|
||||
@@ -305,7 +313,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
});
|
||||
field(ui, "Start at", |ui| ui.add(egui::DragValue::new(start).range(0..=999_999)));
|
||||
ui.label(
|
||||
egui::RichText::new("Tokens: {name} {num} {seq} {seq:3} {date} {date:%Y-%m-%d} {folder} {camera} {lens} {iso} {rating} {title} {creator}. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…")
|
||||
egui::RichText::new("Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…")
|
||||
.color(t.text_dim),
|
||||
);
|
||||
let preview = app.session.execute("photo.renamePreview", &json!({"template": template, "start": start})).unwrap_or_default();
|
||||
@@ -576,9 +584,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
use lightcraft_engine::export::DngCompression as Z;
|
||||
choices(ui, "Compression", "exportDngCompression", &[(Z::Lossless, "Lossless"), (Z::Deflate, "ZIP"), (Z::Uncompressed, "None")], &mut opts.dng_compression);
|
||||
}
|
||||
field(ui, "File name", |ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut opts.naming).hint_text("{name}-{seq} · {date} · {title} · {folder}").desired_width(f32::INFINITY))
|
||||
let naming_id = egui::Id::new("export-naming");
|
||||
let tags_open = field(ui, "File name", |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
let w = (ui.available_width() - 50.0).max(80.0);
|
||||
ui.add(egui::TextEdit::singleline(&mut opts.naming).id(naming_id).hint_text("{name}-{seq} · {date} · {title} · {folder}").desired_width(w));
|
||||
crate::import::tag_toggle(ui, "exportNaming")
|
||||
});
|
||||
if tags_open {
|
||||
crate::import::tag_help(ui, "exportNaming", &mut opts.naming, naming_id);
|
||||
}
|
||||
crate::import::unknown_tags_warning(ui, &opts.naming);
|
||||
if opts.naming.contains("{seq") {
|
||||
let mut v = opts.start_number as f64;
|
||||
if num(ui, &START_NUMBER, &mut v) {
|
||||
|
||||
+2
-2
@@ -137,7 +137,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| 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` | |
|
||||
| LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) |
|
||||
| LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming); preview; renames files on disk with their XMP sidecars, never overwriting (-1, -2… suffixes), rolls back on failure; undo/redo move the files; virtual copies follow |
|
||||
| LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars, never overwriting (-1, -2… suffixes), rolls back on failure; undo/redo move the files; virtual copies follow |
|
||||
| LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time” |
|
||||
| LR-LIB-SHOWFINDER | Reveal original in file manager | P0 | ✅ | `cmd:app.showInFinder` | ⌘R (see MENU-FILE-SHOWFINDER) |
|
||||
| LR-LIB-COVER | Album cover | P2 | ✅ | `cmd:album.setCover` | |
|
||||
@@ -687,7 +687,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
||||
|
||||
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|
||||
|---|---|---|---|---|---|
|
||||
| LRC-LIB-IMPORT | Full import dialog | P1 | ✅ | `cmd:library.import` (`mode`, `destination`, `organize`, `rename`, `metadataPreset`, `preset`, `keywords`, `album`), `crates/ui-egui/src/import.rs` | review grid with duplicates skipped; add in place or copy (library Originals or any folder; by day / by month / one folder; rename template with live example, numbered across the import); develop preset, metadata preset, keywords, album on import |
|
||||
| LRC-LIB-IMPORT | Full import dialog | P1 | ✅ | `cmd:library.import` (`mode`, `destination`, `organize`, `rename`, `metadataPreset`, `preset`, `keywords`, `album`), `crates/ui-egui/src/import.rs` | review grid with duplicates skipped; add in place or copy (library Originals or any folder; by day / by month / one folder; rename template with live example, Tags picker (insert at cursor) and unknown-tag warning, numbered across the import); develop preset, metadata preset, keywords, album on import |
|
||||
| LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ✅ | `cmd:library.autoImport` | see LR-IMP-AUTO |
|
||||
| LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | |
|
||||
| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info); no people view |
|
||||
|
||||
Reference in New Issue
Block a user