Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60a98354d9 | ||
|
|
eeb136b9ee | ||
|
|
3f10ba4f3b | ||
|
|
7fb24029f2 | ||
|
|
5601d7879a | ||
|
|
bf224c2a8d | ||
|
|
16236ff779 | ||
|
|
2592ab47e9 | ||
|
|
3e791905b9 | ||
|
|
2897f85861 | ||
|
|
3517606ffc | ||
|
|
9e51ca0da8 | ||
|
|
85e38c7342 | ||
|
|
4836143f9e | ||
|
|
57dc811a6a | ||
|
|
9e327b4772 | ||
|
|
f6fcef4d5c | ||
|
|
3895cf6411 | ||
|
|
5cb74e7313 | ||
|
|
4165904bd1 | ||
|
|
6ea9b979c8 | ||
|
|
3a9b6ccee4 | ||
|
|
8980c3bb0e | ||
|
|
1ebce934ea | ||
|
|
a7fcd81850 | ||
|
|
c3c30dae3f | ||
|
|
47cf1ade6f | ||
|
|
00388f3fd2 | ||
|
|
1b4518c202 | ||
|
|
aaa46003d5 | ||
|
|
a9cd761d52 | ||
|
|
68267810b3 | ||
|
|
9b4ba1aa80 | ||
|
|
acc85c4147 | ||
|
|
0fbf20db51 | ||
|
|
2b66eb4800 | ||
|
|
92c7237c50 | ||
|
|
8b70d33d0f | ||
|
|
5e2ece0cf3 | ||
|
|
8148988eb8 |
No files matched your search
@@ -31,7 +31,7 @@ jobs:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
path: craft-fonts
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
@@ -192,7 +192,7 @@ jobs:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
@@ -287,7 +287,7 @@ jobs:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
@@ -336,7 +336,7 @@ jobs:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
|
||||
@@ -63,7 +63,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
- **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not.
|
||||
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
|
||||
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL);
|
||||
Japanese fonts come from craft-fonts (below).
|
||||
Japanese and Chinese fonts come from craft-fonts (below). **One exception (maintainer decision, 2026-10-07): Noto
|
||||
Sans/Serif CJK** (Google-branded, OFL, co-developed with Adobe as Source Han) is allowed via craft-fonts for Chinese
|
||||
text, because nearly every OFL Chinese face derives from it. Use it unmodified under its OFL; this does not open the
|
||||
door to Source Han under Adobe's name or to any other Adobe asset.
|
||||
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
|
||||
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
|
||||
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
|
||||
@@ -72,7 +75,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
|
||||
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
|
||||
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
|
||||
(`theme::font_definitions`) and the export watermark renderer use its Japanese faces as fallbacks after Inter. Unset,
|
||||
(`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset,
|
||||
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
|
||||
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
|
||||
without it; the FreeBSD CI job runs them with it. Rules: craftrules
|
||||
|
||||
@@ -63,7 +63,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
- **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not.
|
||||
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
|
||||
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL);
|
||||
Japanese fonts come from craft-fonts (below).
|
||||
Japanese and Chinese fonts come from craft-fonts (below). **One exception (maintainer decision, 2026-10-07): Noto
|
||||
Sans/Serif CJK** (Google-branded, OFL, co-developed with Adobe as Source Han) is allowed via craft-fonts for Chinese
|
||||
text, because nearly every OFL Chinese face derives from it. Use it unmodified under its OFL; this does not open the
|
||||
door to Source Han under Adobe's name or to any other Adobe asset.
|
||||
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
|
||||
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
|
||||
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
|
||||
@@ -72,7 +75,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
|
||||
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
|
||||
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
|
||||
(`theme::font_definitions`) and the export watermark renderer use its Japanese faces as fallbacks after Inter. Unset,
|
||||
(`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset,
|
||||
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
|
||||
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
|
||||
without it; the FreeBSD CI job runs them with it. Rules: craftrules
|
||||
|
||||
@@ -102,6 +102,14 @@ static ALLOC: dhat::Alloc = dhat::Alloc;
|
||||
|
||||
/// Why `--library DIR` can't be opened; for a library open in another program, how to work with
|
||||
/// that one instead (issue #99).
|
||||
/// Print the open library's warnings (an unlockable library, damaged settings files) on stderr:
|
||||
/// stdout carries command results / MCP protocol messages only.
|
||||
fn library_warnings(session: &mut Session, who: &str) {
|
||||
for w in session.take_library_warnings() {
|
||||
eprintln!("{who}: warning: {w}");
|
||||
}
|
||||
}
|
||||
|
||||
fn library_error(dir: &str, e: lightcraft_engine::EngineError) -> String {
|
||||
match e {
|
||||
lightcraft_engine::EngineError::LibraryInUse(why) => format!(
|
||||
@@ -317,6 +325,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let mut h = Headless::default();
|
||||
let r = h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
|
||||
eprintln!("lightcraft-cli mcp: opened library {dir} ({r:?})");
|
||||
library_warnings(&mut h.session, "lightcraft-cli mcp");
|
||||
h
|
||||
}
|
||||
None if demo => Headless::demo(),
|
||||
@@ -553,6 +562,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
Some(dir) => {
|
||||
let mut h = Headless::default();
|
||||
h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
|
||||
library_warnings(&mut h.session, "lightcraft-cli");
|
||||
h
|
||||
}
|
||||
None if demo => Headless::demo(),
|
||||
|
||||
@@ -523,11 +523,15 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
// The log file: opened after the arguments, so `--version`, `--help` and a usage error leave
|
||||
// no file behind. Records logged until now are written to it first.
|
||||
let mut log_file = None;
|
||||
if let Some(logger) = logger {
|
||||
// a --memory session writes nothing (issue #164), a log file included
|
||||
match log_dir().filter(|_| !in_memory) {
|
||||
Some(dir) => match logger.attach_dir(&dir) {
|
||||
Ok(path) => log::info!("LightCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
|
||||
Ok(path) => {
|
||||
log::info!("LightCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display());
|
||||
log_file = Some(path);
|
||||
}
|
||||
// The file sink has given up by now, so this goes to standard error only.
|
||||
Err(e) => log::warn!("no log file: {e}"),
|
||||
},
|
||||
@@ -535,6 +539,10 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
}
|
||||
let (prefs, prefs_warning, keep_prefs_file) = load_prefs(in_memory);
|
||||
// the saved language from the start, so a failed start is reported in it too
|
||||
if let Some(ui) = &prefs {
|
||||
lightcraft_ui_egui::i18n::set_language(ui.language);
|
||||
}
|
||||
// --library, else the library last opened from Settings, else the default location
|
||||
let library_dir = library_dir.or_else(|| {
|
||||
prefs
|
||||
@@ -564,7 +572,7 @@ fn main() -> eframe::Result {
|
||||
wgpu_options: window_wgpu_options(),
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
let started = eframe::run_native(
|
||||
"LightCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
@@ -618,7 +626,28 @@ fn main() -> eframe::Result {
|
||||
menu,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
);
|
||||
if let Err(e) = &started {
|
||||
startup_failed(&e.to_string(), log_file.as_deref());
|
||||
}
|
||||
started
|
||||
}
|
||||
|
||||
/// The window could not start (e.g. no usable graphics device): say so in the log and in a native
|
||||
/// message box naming the log file, instead of exiting without a trace — the release builds have
|
||||
/// no console on Windows, and an app started from Finder shows no standard error (issue #260).
|
||||
fn startup_failed(error: &str, log_file: Option<&std::path::Path>) {
|
||||
log::error!("LightCraft could not open its window: {error}");
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return; // tests and scripts: no dialog to click away
|
||||
}
|
||||
let (title, text) = lightcraft_ui_egui::i18n::startup_failed_message(error, log_file.map(|p| p.display().to_string()).as_deref());
|
||||
let _ = rfd::MessageDialog::new()
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.set_title(title)
|
||||
.set_description(text)
|
||||
.set_buttons(rfd::MessageButtons::Ok)
|
||||
.show();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -68,6 +68,32 @@ impl DevelopSettings {
|
||||
!self.disabled_sections.iter().any(|s| s == section)
|
||||
}
|
||||
|
||||
/// The settings as rendered: an Edit-panel section whose eye is off contributes nothing (issue
|
||||
/// #316). Light (with its tone curve), Color (white balance, presence, the mixers, colour grading
|
||||
/// and point colour) and Detail are reset to their defaults here; Effects, Optics, Geometry and
|
||||
/// Calibration the pipeline switches off where it applies them. Borrowed when every section is on.
|
||||
pub fn effective(&self) -> std::borrow::Cow<'_, DevelopSettings> {
|
||||
let off = |section: &str| !self.section_enabled(section);
|
||||
if !(off("light") || off("color") || off("detail")) {
|
||||
return std::borrow::Cow::Borrowed(self);
|
||||
}
|
||||
let mut d = self.clone();
|
||||
if off("light") {
|
||||
d.reset_section(Section::Light);
|
||||
d.reset_section(Section::Curve);
|
||||
}
|
||||
if off("color") {
|
||||
for section in [Section::Color, Section::Mixer, Section::BwMix, Section::Grading, Section::PointColor] {
|
||||
d.reset_section(section);
|
||||
}
|
||||
d.point_colors.clear();
|
||||
}
|
||||
if off("detail") {
|
||||
d.reset_section(Section::Detail);
|
||||
}
|
||||
std::borrow::Cow::Owned(d)
|
||||
}
|
||||
|
||||
pub fn set_section_enabled(&mut self, section: &str, on: bool) {
|
||||
self.disabled_sections.retain(|s| s != section);
|
||||
if !on {
|
||||
|
||||
@@ -83,6 +83,27 @@ pub struct UndoEntry {
|
||||
pub folder: Option<FolderMove>,
|
||||
}
|
||||
|
||||
/// The one photo an undo step changes, when it changes exactly one through its edit, rating, flag,
|
||||
/// label, metadata or versions (`depth` bounds nested batches).
|
||||
fn single_photo(op: &Op, depth: usize) -> Option<PhotoId> {
|
||||
match op {
|
||||
Op::SetDevelop { id, .. }
|
||||
| Op::SetRating { id, .. }
|
||||
| Op::SetFlag { id, .. }
|
||||
| Op::SetLabel { id, .. }
|
||||
| Op::SetMeta { id, .. }
|
||||
| Op::SetVersions { id, .. }
|
||||
| Op::SetHistory { id, .. }
|
||||
| Op::PushHistory { id, .. } => Some(*id),
|
||||
Op::Batch { ops } if depth < 8 => {
|
||||
let mut ids = ops.iter().map(|o| single_photo(o, depth + 1));
|
||||
let first = ids.next()??;
|
||||
ids.all(|id| id == Some(first)).then_some(first)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder renamed or moved on disk as part of an undo step.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FolderMove {
|
||||
@@ -428,6 +449,7 @@ impl Session {
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
self.show_undone(&e.op);
|
||||
self.pending_log.push(e.op);
|
||||
self.redo.push(UndoEntry { label: e.label.clone(), op: redo, folder: e.folder.as_ref().map(FolderMove::reversed) });
|
||||
Ok(e.label)
|
||||
@@ -442,11 +464,30 @@ impl Session {
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
self.show_undone(&e.op);
|
||||
self.pending_log.push(e.op);
|
||||
self.undo.push(UndoEntry { label: e.label.clone(), op: undo, folder: e.folder.as_ref().map(FolderMove::reversed) });
|
||||
Ok(e.label)
|
||||
}
|
||||
|
||||
/// After an undo or redo that changed one photo (its edit, rating, flag, label, metadata or
|
||||
/// versions), that photo becomes the active one, as in Lightroom Classic, so the change is on
|
||||
/// screen (issue #293). It joins the selection if it isn't in it; nothing changes for steps
|
||||
/// that touch several photos, albums or files, or for a photo not in the current view.
|
||||
fn show_undone(&mut self, op: &Op) {
|
||||
let Some(id) = single_photo(op, 0) else { return };
|
||||
if self.selection.active == Some(id) || !self.visible().contains(&id) {
|
||||
return;
|
||||
}
|
||||
if !self.selection.contains(id) {
|
||||
self.selection = Selection { ids: vec![id], active: Some(id) };
|
||||
} else {
|
||||
self.selection.active = Some(id);
|
||||
}
|
||||
self.active_mask = None;
|
||||
self.active_spot = None;
|
||||
}
|
||||
|
||||
/// Apply an undo/redo op, first renaming the step's folder and moving the files its renames
|
||||
/// imply (all or nothing). Files that can't be moved back after a failure are reported and the
|
||||
/// library follows them (a logged change outside the undo history), so none goes missing; the
|
||||
|
||||
@@ -395,7 +395,7 @@ impl Session {
|
||||
forgot_local: None,
|
||||
presets_written,
|
||||
view_written,
|
||||
settings_warnings: settings.warnings,
|
||||
settings_warnings: unlocked_warning(lock.as_ref()).into_iter().chain(settings.warnings).collect(),
|
||||
warnings_reported: 0,
|
||||
blocked: settings.blocked,
|
||||
lock: lock.take(),
|
||||
@@ -570,7 +570,8 @@ impl Session {
|
||||
lib.files.write_atomic("prefs.json", &v).map_err(|e| EngineError::Other(format!("prefs: {e}")))
|
||||
}
|
||||
|
||||
/// Settings-file warnings of the open library not handed out yet (the UI shows each once).
|
||||
/// Warnings about the open library not handed out yet: its settings files, and a lock that
|
||||
/// couldn't be taken (the UI shows each once; the CLI and MCP print them on stderr).
|
||||
pub fn take_library_warnings(&mut self) -> Vec<String> {
|
||||
let Some(lib) = self.library.as_mut() else { return vec![] };
|
||||
let new = lib.settings_warnings[lib.warnings_reported..].to_vec();
|
||||
@@ -625,3 +626,15 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The warning for a library opened without its one-program-at-a-time lock (issue #171): its
|
||||
/// file system can't lock `catalog.lock` (some network shares). It opens anyway, as documented —
|
||||
/// refusing would lock the user out — but the user must learn that a second program could open it.
|
||||
fn unlocked_warning(lock: Option<&LibraryLock>) -> Option<String> {
|
||||
lock.filter(|l| !l.held()).map(|_| {
|
||||
"This library could not be locked (its catalog.lock file can't be locked where it is stored, e.g. on some network \
|
||||
shares), so it is open without protection against a second program: use it in one LightCraft app or command at \
|
||||
a time, or changes made in one of them can be lost."
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
@@ -210,8 +210,11 @@ fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration) {
|
||||
}
|
||||
let Some(mut job) = queue.pop_front() else { continue };
|
||||
let is_detail = matches!(job.kind, Kind::Detail { .. });
|
||||
let _done = Done(if is_detail { &shared.detail } else { &shared.pending });
|
||||
// counts the job as pending until just before its reply goes out: a caller woken by the
|
||||
// reply must not still see the worker busy with it
|
||||
let done = Done(if is_detail { &shared.detail } else { &shared.pending });
|
||||
if queue.iter().any(|later| supersedes(later, &job)) {
|
||||
drop(done);
|
||||
let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true });
|
||||
continue;
|
||||
}
|
||||
@@ -229,6 +232,7 @@ fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration) {
|
||||
state.cache = None;
|
||||
}
|
||||
shared.loaded.store(state.model.is_some(), Ordering::SeqCst);
|
||||
drop(done);
|
||||
let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -925,3 +925,29 @@ fn saved_presets_with_unknown_keys_still_export() {
|
||||
assert_eq!(o.watermark.map(|w| w.text), Some("©".to_string()));
|
||||
assert!(ExportOptions::from_params(&s.export_params(&json!({"preset": "old", "qualty": 80})).unwrap()).is_err());
|
||||
}
|
||||
|
||||
/// Undo and redo bring the photo they change on screen (issue #293): after editing one photo and
|
||||
/// moving on, Undo makes it the active photo again; a step that changes several photos leaves the
|
||||
/// selection alone.
|
||||
#[test]
|
||||
fn undo_and_redo_show_the_photo_they_change() {
|
||||
let mut s = demo();
|
||||
let vis = s.visible_cloned();
|
||||
let (a, b) = (vis[0], vis[1]);
|
||||
s.execute("library.select", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
|
||||
s.execute("library.select", &json!({"ids": [b.0]})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.active(), Some(a), "undo shows the photo it changed");
|
||||
assert_eq!(s.selection.ids, vec![a]);
|
||||
s.execute("library.select", &json!({"ids": [b.0]})).unwrap();
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.active(), Some(a), "so does redo");
|
||||
// rating both selected photos is one step over two photos: undoing it leaves the selection
|
||||
s.execute("library.select", &json!({"ids": [a.0, b.0], "active": b.0})).unwrap();
|
||||
s.execute("photo.rate", &json!({"rating": 3})).unwrap();
|
||||
s.execute("library.select", &json!({"ids": [a.0, b.0], "active": a.0})).unwrap();
|
||||
let before = s.selection.clone();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.selection, before, "a step over several photos keeps the selection");
|
||||
}
|
||||
@@ -95,3 +95,28 @@ fn unreadable_settings_are_not_overwritten() {
|
||||
assert_eq!(files.files.get("presets.json").unwrap(), presets);
|
||||
assert_eq!(files.files.get("prefs.json").unwrap(), prefs);
|
||||
}
|
||||
|
||||
/// Issue #171: a library whose `catalog.lock` can't be locked (stood in for here by a directory
|
||||
/// of that name, which takes the same path as a file system without locks) still opens, and the
|
||||
/// user is told it is unprotected — in `library.info` and once through the library warnings the
|
||||
/// app's notices and the CLI show. A normally locked library says nothing.
|
||||
#[test]
|
||||
fn an_unlockable_library_opens_with_a_warning() {
|
||||
let lib = temp_dir("unlocked");
|
||||
std::fs::create_dir_all(lib.join("catalog.lock")).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let info = s.execute("library.info", &json!({})).unwrap();
|
||||
let listed = info["settingsWarnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(listed.iter().any(|w| w.as_str().is_some_and(|w| w.contains("without protection against a second program"))), "{info}");
|
||||
let warnings = s.take_library_warnings();
|
||||
assert_eq!(warnings.len(), 1, "{warnings:?}");
|
||||
drop(s);
|
||||
// the same library with a lockable lock file: no warning
|
||||
std::fs::remove_dir_all(lib.join("catalog.lock")).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert!(s.take_library_warnings().is_empty());
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
@@ -294,6 +294,9 @@ pub fn stage_bytes(stages: &StageCache) -> usize {
|
||||
pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>) -> Option<Rendered> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// sections switched off with their eye render as if at their defaults (issue #316)
|
||||
let effective = s.effective();
|
||||
let s: &DevelopSettings = &effective;
|
||||
// the kernel writes 8-bit output: high-bit-depth exports (and soft proofs) render on the CPU
|
||||
if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() {
|
||||
return None;
|
||||
|
||||
@@ -365,6 +365,9 @@ enum Src<'a> {
|
||||
}
|
||||
|
||||
fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, cache: Option<&StageCache>) -> Rendered {
|
||||
// sections switched off with their eye render as if at their defaults (issue #316)
|
||||
let effective = s.effective();
|
||||
let s: &DevelopSettings = &effective;
|
||||
// `Instant::now()` panics on wasm32-unknown-unknown: only read the clock when profiling.
|
||||
let lap = |what: &str, t: &mut Option<std::time::Instant>| {
|
||||
if let Some(t) = t {
|
||||
|
||||
@@ -237,6 +237,35 @@ fn exposure_after_spatial_filters_equals_exposing_the_source() {
|
||||
assert!(d <= 2, "max difference {d}");
|
||||
}
|
||||
|
||||
/// A section whose eye is off renders as if it were at its defaults (issue #316): Light, Color
|
||||
/// and Detail, which the pipeline doesn't switch off where it applies them.
|
||||
#[test]
|
||||
fn sections_switched_off_render_as_defaults() {
|
||||
let src = Rgb32f::from_fn(32, 24, |x, y| [0.05 + x as f32 / 40.0, 0.1 + y as f32 / 30.0, 0.3]);
|
||||
let info = SourceInfo::default();
|
||||
let req = RenderRequest::fit(32, 24);
|
||||
let plain = render(&src, &info, &DevelopSettings::default(), &req).image;
|
||||
let mut s = DevelopSettings::default();
|
||||
s.light.exposure = 1.5;
|
||||
s.light.contrast = 40.0;
|
||||
s.color.saturation = -60.0;
|
||||
s.color.vibrance = 30.0;
|
||||
s.detail.sharpen_amount = 120.0;
|
||||
let edited = render(&src, &info, &s, &req).image;
|
||||
assert_ne!(edited.data, plain.data);
|
||||
for section in ["light", "color", "detail"] {
|
||||
s.set_section_enabled(section, false);
|
||||
}
|
||||
assert_eq!(render(&src, &info, &s, &req).image.data, plain.data, "every edited section off = the unedited photo");
|
||||
// one section back on brings back only its own adjustments
|
||||
s.set_section_enabled("light", true);
|
||||
let mut light_only = DevelopSettings::default();
|
||||
light_only.light.exposure = 1.5;
|
||||
light_only.light.contrast = 40.0;
|
||||
assert_eq!(render(&src, &info, &s, &req).image.data, render(&src, &info, &light_only, &req).image.data);
|
||||
assert!(matches!(DevelopSettings::default().effective(), std::borrow::Cow::Borrowed(_)), "nothing is copied when every section is on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibration_shifts_colours_but_keeps_greys() {
|
||||
let info = SourceInfo { raw: true, ..Default::default() };
|
||||
|
||||
@@ -67,6 +67,8 @@
|
||||
"Imported {} photo{}": "{} Foto{} importiert",
|
||||
"Insert at the cursor (also written {})": "An Cursorposition einfügen (auch als {} geschrieben)",
|
||||
"Label Set: {name}": "Farbmarkierungssatz: {name}",
|
||||
"LightCraft could not open its window: {e}": "LightCraft konnte sein Fenster nicht öffnen: {e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft konnte sein Fenster nicht öffnen: {e}\n\nEinzelheiten stehen in der Protokolldatei: {path}",
|
||||
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT ODER Apache-2.0. Schriften: {} (OFL). Symbole: eigene Gestaltung.",
|
||||
"Matches: {}": "Treffer: {}",
|
||||
"Merging… {stage} {:.0}%": "Wird zusammengefügt… {stage} {:.0}%",
|
||||
|
||||
@@ -649,6 +649,7 @@
|
||||
"Horizontal": "Horizontal",
|
||||
"Horizontal text": "Horizontaler Text",
|
||||
"Hours": "Stunden",
|
||||
"How to install the model": "So installierst du das Modell",
|
||||
"Hue": "Farbton",
|
||||
"Hue Range": "Farbtonbereich",
|
||||
"ISO Speed": "ISO-Empfindlichkeit",
|
||||
@@ -694,6 +695,7 @@
|
||||
"Info overlay": "Infoüberlagerung",
|
||||
"Insert at the cursor": "An Cursorposition einfügen",
|
||||
"Inspect Photo": "Foto untersuchen",
|
||||
"Install the SAM 3 Model": "SAM-3-Modell installieren",
|
||||
"Instant ’70s": "Sofortbild der 70er",
|
||||
"Interface": "Oberfläche",
|
||||
"Interface language": "Sprache der Oberfläche",
|
||||
@@ -753,6 +755,7 @@
|
||||
"LightCraft Preset": "LightCraft-Vorgabe",
|
||||
"LightCraft Presets": "LightCraft-Vorgaben",
|
||||
"LightCraft Website": "LightCraft-Website",
|
||||
"LightCraft could not start": "LightCraft konnte nicht starten",
|
||||
"LightCraft couldn't find where to keep your library.": "LightCraft konnte keinen Speicherort für deine Bibliothek finden.",
|
||||
"LightCraft couldn't open the library at": "LightCraft konnte die Bibliothek nicht öffnen unter",
|
||||
"LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft stürzt beim Start auf Windows-PCs mit defektem Vulkan-Treiber nicht mehr ab (Issue #136, etwa bestimmte Intel-UHD-630-Treiber).",
|
||||
@@ -1454,7 +1457,7 @@
|
||||
"They move to Recently Deleted and can be restored; no file on disk is touched.": "Sie werden nach „Zuletzt gelöscht“ verschoben und können wiederhergestellt werden; keine Datei auf dem Datenträger wird verändert.",
|
||||
"Thirds": "Drittel",
|
||||
"This Computer": "Dieser Computer",
|
||||
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "Diese Version enthält noch keine Downloadquelle für das Modell: Lege die Dateien selbst im obigen Ordner ab (siehe docs/ai-masks.md).",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Diese Version kann das Modell noch nicht herunterladen. Zum Installieren von Hand lege model.safetensors, vocab.json und merges.txt aus Metas facebook/sam3 in den obigen Ordner: Objekt und Beschreiben funktionieren, sobald sie dort liegen.",
|
||||
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Dieser Ordner ist nicht verfügbar (Laufwerk getrennt?). Smart-Vorschauen werden erst wieder erstellt oder verwendet, wenn er verfügbar ist oder du einen anderen Ordner auswählst.",
|
||||
"This photo can't be opened": "Dieses Foto kann nicht geöffnet werden",
|
||||
"Threshold": "Schwellenwert",
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
"Imported {n} preset{}": "{n}件のプリセットを読み込みました{:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "{n}件のプリセットを読み込みました{:.0}。{f}件は読み込めません{:.0}",
|
||||
"Imported {} photo{}": "{}枚を読み込みました{:.0}",
|
||||
"LightCraft could not open its window: {e}": "LightCraftのウィンドウを開けませんでした: {e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraftのウィンドウを開けませんでした: {e}\n\n詳しくはログファイルをご覧ください: {path}",
|
||||
"Matches: {}": "一致条件:{}",
|
||||
"Merging… {stage} {:.0}%": "合成中… {stage} {:.0}%",
|
||||
"Move {what} to Recently Deleted?": "{what}を「最近削除した写真」に移動しますか?",
|
||||
|
||||
@@ -499,6 +499,7 @@
|
||||
"High Contrast B&W": "ハイコントラスト白黒",
|
||||
"Home": "ホーム",
|
||||
"Hours": "時間",
|
||||
"How to install the model": "モデルのインストール方法",
|
||||
"ISO Speed": "ISO感度",
|
||||
"ISO speed": "ISO感度",
|
||||
"Import Point Curve Presets…": "ポイントカーブのプリセットを読み込む…",
|
||||
@@ -512,6 +513,7 @@
|
||||
"Info Overlay: File": "情報オーバーレイ:ファイル",
|
||||
"Insert at the cursor": "カーソル位置に挿入",
|
||||
"Inspect Photo": "写真を調べる",
|
||||
"Install the SAM 3 Model": "SAM 3モデルのインストール",
|
||||
"Instant ’70s": "インスタント’70s",
|
||||
"Invert Component": "構成要素を反転",
|
||||
"JPEG (Large)": "JPEG(大)",
|
||||
@@ -531,6 +533,7 @@
|
||||
"Library State": "ライブラリの状態",
|
||||
"Library saved": "ライブラリを保存しました",
|
||||
"Licence:": "ライセンス:",
|
||||
"LightCraft could not start": "LightCraftを起動できませんでした",
|
||||
"Line delta": "行数の増減",
|
||||
"Linear": "リニア",
|
||||
"Lines +/−": "行数 +/−",
|
||||
@@ -1318,7 +1321,7 @@
|
||||
"The photo has these settings": "この写真に適用されている設定",
|
||||
"They can be restored from Recently Deleted until it is emptied.": "「最近削除した写真」を空にするまでは復元できます",
|
||||
"Thirds": "三分割",
|
||||
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "このビルドにはモデルのダウンロード元がまだありません。上のフォルダにファイルを手動で置いてください(docs/ai-masks.md を参照)",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "このビルドではまだモデルをダウンロードできません。手動でインストールするには、Meta の facebook/sam3 の model.safetensors、vocab.json、merges.txt を上のフォルダに置いてください。置いた時点で「オブジェクト」と「説明で選択」が使えるようになります。",
|
||||
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "このフォルダにアクセスできません。ドライブを接続するか別のフォルダを選ぶまで、スマートプレビューは作成・使用されません",
|
||||
"This photo can't be opened": "この写真を開けません",
|
||||
"Threshold": "しきい値",
|
||||
|
||||
@@ -46,6 +46,8 @@
|
||||
"Imported {n} preset{}, {f} file{} not readable": "Importadas {n} predefinição{:.0}, {f} arquivo{:.0} não legível",
|
||||
"Imported {} photo{}": "Importadas {} foto{:.0}",
|
||||
"Label Set: {name}": "Conjunto de rótulos: {name}",
|
||||
"LightCraft could not open its window: {e}": "O LightCraft não conseguiu abrir sua janela: {e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "O LightCraft não conseguiu abrir sua janela: {e}\n\nO arquivo de log tem os detalhes: {path}",
|
||||
"Matches: {}": "Correspondências: {}",
|
||||
"Merging… {stage} {:.0}%": "Mesclando… {stage} {:.0}%",
|
||||
"Move {what} to Recently Deleted?": "Mover {what} para Excluídos recentemente?",
|
||||
|
||||
@@ -623,6 +623,7 @@
|
||||
"Horizontal": "Horizontal",
|
||||
"Horizontal text": "Texto horizontal",
|
||||
"Hours": "Horas",
|
||||
"How to install the model": "Como instalar o modelo",
|
||||
"Hue": "Matiz",
|
||||
"Hue Range": "Faixa de matiz",
|
||||
"ISO Speed": "Sensibilidade ISO",
|
||||
@@ -663,6 +664,7 @@
|
||||
"Info overlay": "Sobreposição de informações",
|
||||
"Insert at the cursor": "Inserir no cursor",
|
||||
"Inspect Photo": "Inspecionar foto",
|
||||
"Install the SAM 3 Model": "Instalar o modelo SAM 3",
|
||||
"Instant ’70s": "Instantâneo anos 70",
|
||||
"Interface": "Interface",
|
||||
"Interface language": "Idioma da interface",
|
||||
@@ -721,6 +723,7 @@
|
||||
"LightCraft Preset": "Predefinição do LightCraft",
|
||||
"LightCraft Presets": "Predefinições do LightCraft",
|
||||
"LightCraft Website": "Site do LightCraft",
|
||||
"LightCraft could not start": "Não foi possível iniciar o LightCraft",
|
||||
"LightCraft couldn't find where to keep your library.": "O LightCraft não conseguiu encontrar onde guardar sua biblioteca.",
|
||||
"LightCraft couldn't open the library at": "O LightCraft não conseguiu abrir a biblioteca em",
|
||||
"LightCraft on GitHub": "LightCraft no GitHub",
|
||||
@@ -1400,7 +1403,7 @@
|
||||
"They move to Recently Deleted and can be restored; no file on disk is touched.": "Elas vão para Excluídos recentemente e podem ser restauradas; nenhum arquivo no disco é alterado.",
|
||||
"Thirds": "Terços",
|
||||
"This Computer": "Este computador",
|
||||
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "Esta versão ainda não tem um local de download para o modelo: coloque os arquivos você mesmo na pasta acima (veja docs/ai-masks.md).",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Esta versão ainda não consegue baixar o modelo. Para instalá-lo manualmente, coloque model.safetensors, vocab.json e merges.txt do facebook/sam3 da Meta na pasta acima: Objeto e Descrever funcionam assim que eles estiverem lá.",
|
||||
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Esta pasta não está disponível (unidade desconectada?). As pré-visualizações inteligentes não são geradas nem usadas até ela voltar ou você escolher outra pasta.",
|
||||
"This photo can't be opened": "Não foi possível abrir esta foto",
|
||||
"Threshold": "Limite",
|
||||
|
||||
@@ -71,6 +71,8 @@
|
||||
"Insert at the cursor (also written {})": "Вставить в позицию курсора (также пишется как {})",
|
||||
"Label Set: {name}": "Набор меток: {name}",
|
||||
"LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft пока не умеет декодировать этот вариант RAW ({}). Вы редактируете встроенный в файл JPEG-просмотр камеры, в котором уже применены стиль изображения камеры (например, «Монохром») и баланс белого.",
|
||||
"LightCraft could not open its window: {e}": "LightCraft не удалось открыть окно: {e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft не удалось открыть окно: {e}\n\nПодробности в файле журнала: {path}",
|
||||
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Шрифты: {} (OFL). Значки: оригинальные.",
|
||||
"Matches: {}": "Совпадения: {}",
|
||||
"Merging… {stage} {:.0}%": "Слияние… {stage} {:.0}%",
|
||||
|
||||
@@ -509,6 +509,7 @@
|
||||
"History & Activity (Y)": "История и активность (Y)",
|
||||
"Horizontal": "По горизонтали",
|
||||
"Horizontal text": "Горизонтальный текст",
|
||||
"How to install the model": "Как установить модель",
|
||||
"Hue": "Тон",
|
||||
"Hue Range": "Диапазон тонов",
|
||||
"If file exists": "Если файл существует",
|
||||
@@ -541,6 +542,7 @@
|
||||
"Info (I)": "Информация (I)",
|
||||
"Info overlay": "Информационная накладка",
|
||||
"Inspect Photo": "Просмотр фото",
|
||||
"Install the SAM 3 Model": "Установка модели SAM 3",
|
||||
"Instant ’70s": "Мгновенный стиль 70-х",
|
||||
"Interface": "Интерфейс",
|
||||
"Interface language": "Язык интерфейса",
|
||||
@@ -590,6 +592,7 @@
|
||||
"LightCraft Preset": "Пресет LightCraft",
|
||||
"LightCraft Presets": "Пресеты LightCraft",
|
||||
"LightCraft Website": "Сайт LightCraft",
|
||||
"LightCraft could not start": "Не удалось запустить LightCraft",
|
||||
"LightCraft couldn't find where to keep your library.": "LightCraft не удалось найти место для хранения вашей библиотеки.",
|
||||
"LightCraft couldn't open the library at": "LightCraft не удалось открыть библиотеку в",
|
||||
"LightCraft on GitHub": "LightCraft на GitHub",
|
||||
@@ -1150,6 +1153,7 @@
|
||||
"Theme": "Тема",
|
||||
"They can be restored from Recently Deleted until it is emptied.": "Их можно восстановить из «Недавно удалённых», пока этот раздел не очищен.",
|
||||
"Thirds": "Правило третей",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Эта сборка пока не умеет скачивать модель. Чтобы установить её вручную, положите model.safetensors, vocab.json и merges.txt из facebook/sam3 от Meta в папку выше: «Объект» и «Описать» заработают, как только файлы окажутся там.",
|
||||
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Эта папка недоступна (диск отключён?). Смарт-просмотры не создаются и не используются, пока она не появится снова или вы не выберете другую папку.",
|
||||
"This photo can't be opened": "Это фото не удаётся открыть",
|
||||
"Threshold": "Порог",
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
"Imported {n} preset{}": "已导入 {n} 个预设{0:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "已导入 {n} 个预设,{f} 个文件无法读取{0:.0}{1:.0}",
|
||||
"Imported {} photo{}": "已导入 {0} 张照片{1:.0}",
|
||||
"LightCraft could not open its window: {e}": "LightCraft 无法打开窗口:{e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft 无法打开窗口:{e}\n\n详细信息见日志文件:{path}",
|
||||
"Matches: {}": "匹配项:{0}",
|
||||
"Merging… {stage} {:.0}%": "合成中… {stage} {0:.0}%",
|
||||
"Move {what} to Recently Deleted?": "将 {what} 移到最近删除?",
|
||||
|
||||
@@ -505,6 +505,7 @@
|
||||
"Has GPS": "有 GPS",
|
||||
"Home": "主文件夹",
|
||||
"Hours": "小时",
|
||||
"How to install the model": "如何安装模型",
|
||||
"ISO Speed": "ISO 感光度",
|
||||
"ISO speed": "ISO 感光度",
|
||||
"Import as new": "作为新照片导入",
|
||||
@@ -513,6 +514,7 @@
|
||||
"Info Overlay: Exposure": "信息叠加:曝光",
|
||||
"Info Overlay: File": "信息叠加:文件",
|
||||
"Insert at the cursor": "在光标处插入",
|
||||
"Install the SAM 3 Model": "安装 SAM 3 模型",
|
||||
"Invert Component": "反相组件",
|
||||
"Join the ArtCraft community on Discord": "加入 Discord 上的 ArtCraft 社区",
|
||||
"Label": "标签",
|
||||
@@ -523,6 +525,7 @@
|
||||
"Lens Blur": "镜头模糊",
|
||||
"Library saved": "图库已保存",
|
||||
"Licence:": "许可证:",
|
||||
"LightCraft could not start": "LightCraft 无法启动",
|
||||
"Line delta": "行数差",
|
||||
"Linear": "线性",
|
||||
"Lines +/−": "行数 +/−",
|
||||
@@ -1387,7 +1390,7 @@
|
||||
"Theme": "主题",
|
||||
"They can be restored from Recently Deleted until it is emptied.": "在“最近删除”被清空之前,都可以从那里恢复。",
|
||||
"Thirds": "三分法",
|
||||
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "此版本尚未提供模型的下载地址:请自行将文件放入上方的文件夹(参见 docs/ai-masks.md)。",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚无法下载模型。如需手动安装,请将 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的文件夹:放入后即可使用“对象”和“描述”。",
|
||||
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "此文件夹不可用(驱动器已断开连接?)。在它恢复或您选择其他文件夹之前,不会构建或使用智能预览。",
|
||||
"This photo can't be opened": "无法打开此照片",
|
||||
"Threshold": "阈值",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"Imported {n} preset{}, {f} file{} not readable": "已匯入 {n} 個預設集{:.0},{f} 個檔案無法讀取{:.0}",
|
||||
"Imported {} photo{}": "已匯入 {} 張照片{:.0}",
|
||||
"Label Set: {name}": "標籤集:{name}",
|
||||
"LightCraft could not open its window: {e}": "LightCraft 無法開啟視窗:{e}",
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft 無法開啟視窗:{e}\n\n詳細資訊請見記錄檔:{path}",
|
||||
"Matches: {}": "符合條件:{}",
|
||||
"Merging… {stage} {:.0}%": "正在合併… {stage} {:.0}%",
|
||||
"Move {what} to Recently Deleted?": "要將{what}移至「最近刪除」嗎?",
|
||||
|
||||
@@ -505,6 +505,7 @@
|
||||
"Has GPS": "有 GPS",
|
||||
"Home": "個人資料夾",
|
||||
"Hours": "小時",
|
||||
"How to install the model": "如何安裝模型",
|
||||
"ISO Speed": "ISO 感光度",
|
||||
"ISO speed": "ISO 感光度",
|
||||
"Import as new": "以新照片匯入",
|
||||
@@ -513,6 +514,7 @@
|
||||
"Info Overlay: Exposure": "資訊疊加:曝光",
|
||||
"Info Overlay: File": "資訊疊加:檔案",
|
||||
"Insert at the cursor": "在游標處插入",
|
||||
"Install the SAM 3 Model": "安裝 SAM 3 模型",
|
||||
"Invert Component": "反轉元件",
|
||||
"Join the ArtCraft community on Discord": "加入 Discord 上的 ArtCraft 社群",
|
||||
"Label": "標籤",
|
||||
@@ -523,6 +525,7 @@
|
||||
"Lens Blur": "鏡頭模糊",
|
||||
"Library saved": "照片庫已儲存",
|
||||
"Licence:": "授權:",
|
||||
"LightCraft could not start": "LightCraft 無法啟動",
|
||||
"Line delta": "行數差",
|
||||
"Linear": "線性",
|
||||
"Lines +/−": "行數 +/−",
|
||||
@@ -1387,7 +1390,7 @@
|
||||
"Theme": "主題",
|
||||
"They can be restored from Recently Deleted until it is emptied.": "清空「最近刪除」之前,可以從中還原照片。",
|
||||
"Thirds": "三分法",
|
||||
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).": "此版本尚未提供模型的下載位置:請自行將檔案放入上方的資料夾(請參閱 docs/ai-masks.md)。",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚無法下載模型。如需手動安裝,請將 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的資料夾:放入後即可使用「物件」與「描述」。",
|
||||
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "此資料夾無法使用(磁碟可能已中斷連線)。恢復連線或選擇其他資料夾之前,不會建立或使用智慧型預覽。",
|
||||
"This photo can't be opened": "無法開啟此照片",
|
||||
"Threshold": "臨界值",
|
||||
|
||||
@@ -1053,6 +1053,55 @@ mod tests {
|
||||
assert_ne!(h.app.session.active(), Some(first), "advanced");
|
||||
}
|
||||
|
||||
/// Click a slider's value and type one (issue #322): Return sets it as one undo step, Esc
|
||||
/// keeps the old value, and the keys typed don't reach the shortcuts (1 = one star).
|
||||
#[test]
|
||||
fn slider_values_can_be_typed() {
|
||||
let mut h = demo([1300.0, 900.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
h.request("ui.set", json!({"view": "detail", "right": "edit"}), t);
|
||||
h.settle(SETTLE);
|
||||
let active = h.app.session.active().unwrap();
|
||||
let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure;
|
||||
let rating = |h: &Headless| h.app.session.catalog.photo(active).unwrap().rating;
|
||||
let (undo0, rating0) = (h.app.session.undo.len(), rating(&h));
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true);
|
||||
h.request("ui.text", json!({"text": "1,5"}), t);
|
||||
h.request("ui.key", json!({"key": "Enter"}), t);
|
||||
assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h));
|
||||
assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step");
|
||||
assert_eq!(rating(&h), rating0, "typing 1 set no rating");
|
||||
// Esc keeps the value
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true);
|
||||
h.request("ui.text", json!({"text": "-2"}), t);
|
||||
h.request("ui.key", json!({"key": "Escape"}), t);
|
||||
assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h));
|
||||
// something that isn't a number changes nothing
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true);
|
||||
h.request("ui.text", json!({"text": "bright"}), t);
|
||||
h.request("ui.key", json!({"key": "Enter"}), t);
|
||||
assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h));
|
||||
assert_eq!(h.app.session.undo.len(), undo0 + 1);
|
||||
}
|
||||
|
||||
/// The eye on a section header switches the section off and on again, one undo step each
|
||||
/// (issue #316).
|
||||
#[test]
|
||||
fn section_eye_switches_a_section_off() {
|
||||
let mut h = demo([1300.0, 900.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
h.request("ui.set", json!({"view": "detail", "right": "edit"}), t);
|
||||
h.settle(SETTLE);
|
||||
let light_on = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().section_enabled("light");
|
||||
for expected in [false, true] {
|
||||
// the eye shows while the pointer is on the header
|
||||
assert_eq!(h.request("ui.hoverWidget", json!({"id": "section:light"}), t)["ok"], true);
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "sectionEye:light"}), t)["ok"], true);
|
||||
assert_eq!(light_on(&h), expected);
|
||||
}
|
||||
assert!(h.app.ui.section_open("light"), "the click didn't fold the section");
|
||||
}
|
||||
|
||||
/// Return commits a tool panel back to Edit; elsewhere it does nothing.
|
||||
#[test]
|
||||
fn return_commits_the_crop_tool() {
|
||||
|
||||
@@ -189,6 +189,16 @@ include!(concat!(env!("OUT_DIR"), "/tr-formats.rs"));
|
||||
|
||||
/// A stock preset's or profile's name in the UI language; an imported or user-edited name stays
|
||||
/// exactly as its owner wrote it.
|
||||
/// The title and text of the message box shown when the desktop app's window can't start
|
||||
/// (issue #260), in the UI language: the error and, when there is one, the log file to look in.
|
||||
pub fn startup_failed_message(error: &str, log_file: Option<&str>) -> (String, String) {
|
||||
let text = match log_file {
|
||||
Some(path) => tr_format!("LightCraft could not open its window: {e}\n\nThe log file has the details: {path}", e = error, path = path),
|
||||
None => tr_format!("LightCraft could not open its window: {e}", e = error),
|
||||
};
|
||||
(tr("LightCraft could not start").to_string(), text)
|
||||
}
|
||||
|
||||
pub fn builtin_label(source: &str, builtin: bool) -> &str {
|
||||
if builtin { tr(source) } else { source }
|
||||
}
|
||||
@@ -404,6 +414,16 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Locale tags are matched leniently, so a system locale reaches a shipped language.
|
||||
/// The message box for a desktop start that failed (issue #260) names the error and the log
|
||||
/// file, in the UI language.
|
||||
#[test]
|
||||
fn startup_failure_message_names_the_error_and_the_log() {
|
||||
let (title, text) = startup_failed_message("no adapter", Some("/home/a/.config/lightcraft/logs/lightcraft.log"));
|
||||
assert_eq!(title, "LightCraft could not start");
|
||||
assert!(text.contains("no adapter") && text.ends_with("lightcraft.log"), "{text}");
|
||||
assert!(!startup_failed_message("no adapter", None).1.contains("log file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_tags_normalize() {
|
||||
assert_eq!(Locale::parse_tag("en"), Some(Locale::En));
|
||||
|
||||
@@ -99,6 +99,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Dialog::ConfirmDelete { .. } => "Delete Photos",
|
||||
Dialog::RemoveFolder { disk: true, .. } => "Remove Disk from Library",
|
||||
Dialog::RemoveFolder { .. } => "Remove Folder from Library",
|
||||
// (nothing to download from in this build: the dialog explains the manual install)
|
||||
Dialog::SamModel { .. } if sam_by_hand(&app.session.segmenter) => "Install the SAM 3 Model",
|
||||
Dialog::SamModel { .. } => "Download the SAM 3 Model?",
|
||||
Dialog::About => "About LightCraft",
|
||||
Dialog::Shortcuts => "Keyboard Shortcuts",
|
||||
@@ -829,7 +831,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let sam = &app.session.segmenter;
|
||||
let (sam_installed, sam_running, sam_failed) = (sam.installed(), sam.download_status().running, sam.download_status().error.is_some());
|
||||
// no download location in this build: nothing to offer but the manual install
|
||||
let sam_nowhere = !sam_installed && !sam_running && sam.mirrors().is_empty();
|
||||
let sam_nowhere = sam_by_hand(sam);
|
||||
let cancel = match &dlg {
|
||||
Dialog::SamModel { .. } if sam_running || sam_installed || sam_nowhere => "Close",
|
||||
Dialog::SamModel { .. } => "Not Now",
|
||||
@@ -925,6 +927,22 @@ pub fn keeps_open(app: &LightcraftApp, dlg: &Dialog) -> bool {
|
||||
matches!(dlg, Dialog::SamModel { .. }) && !app.session.segmenter.installed()
|
||||
}
|
||||
|
||||
/// No SAM 3 model, no download running and nowhere to download it from: installing it by hand is
|
||||
/// all the dialog can offer.
|
||||
fn sam_by_hand(sam: &lightcraft_engine::segment::Segmenter) -> bool {
|
||||
!sam.installed() && !sam.download_status().running && sam.mirrors().is_empty()
|
||||
}
|
||||
|
||||
/// The model installation guide.
|
||||
const SAM_HELP: &str = "https://github.com/storytold/lightcraft/blob/main/docs/ai-masks.md#getting-the-model";
|
||||
|
||||
/// Show the SAM 3 model folder in the file manager (created first, so there is something to show).
|
||||
fn show_model_folder(app: &mut LightcraftApp, dir: &std::path::Path) -> Result<(), String> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let reveal = app.services.reveal.as_mut().ok_or("not available here")?;
|
||||
reveal(&dir.to_string_lossy())
|
||||
}
|
||||
|
||||
/// The SAM 3 dialog: what the model is, its size and licence, and the download's progress.
|
||||
fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str>) {
|
||||
use lightcraft_engine::segment::{LICENSE_NAME, LICENSE_URL, MODEL_BYTES};
|
||||
@@ -971,12 +989,32 @@ fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str
|
||||
return;
|
||||
}
|
||||
if seg.mirrors().is_empty() {
|
||||
// nothing to download from: say how to install it by hand, with the folder and the guide a click away
|
||||
let dir = seg.dir.clone();
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr(
|
||||
"This build has no download location for the model yet: put the files in the folder above yourself (see docs/ai-masks.md).",
|
||||
"This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.",
|
||||
))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if let Some(dir) = dir.filter(|_| app.services.reveal.is_some()) {
|
||||
let r = ui.button(crate::i18n::tr(crate::menus::reveal_label()));
|
||||
crate::widgets::register(ui.ctx(), "button:samFolder", r.rect);
|
||||
if r.clicked()
|
||||
&& let Err(e) = show_model_folder(app, &dir)
|
||||
{
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
}
|
||||
let r = ui.link(crate::i18n::tr("How to install the model")).on_hover_text(SAM_HELP);
|
||||
crate::widgets::register(ui.ctx(), "link:samHelp", r.rect);
|
||||
if r.clicked()
|
||||
&& let Err(e) = crate::links::open(app, SAM_HELP)
|
||||
{
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(e) = error.map(str::to_string).or(d.error) {
|
||||
ui.label(egui::RichText::new(format!("{} {e}", crate::i18n::tr("The download didn't work:"))).color(egui::Color32::from_rgb(230, 90, 80)));
|
||||
|
||||
@@ -631,11 +631,21 @@ fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str,
|
||||
}
|
||||
if resp.clicked() {
|
||||
let v = if sel { serde_json::Value::Null } else { json!(key) };
|
||||
browse_all_photos(app, !sel);
|
||||
let _ = app.run("library.filter", json!({"date": v}));
|
||||
}
|
||||
open
|
||||
}
|
||||
|
||||
/// By Date and Keywords count every photo in the library, so choosing a row shows those photos
|
||||
/// from All Photos, not from whatever album or folder happened to be open, where they could be
|
||||
/// missing (issue #341). Only when choosing (`on`), not when clearing the row again.
|
||||
fn browse_all_photos(app: &mut LightcraftApp, on: bool) {
|
||||
if on && app.session.source != LibrarySource::All {
|
||||
let _ = app.run("library.source", json!({"kind": "all"}));
|
||||
}
|
||||
}
|
||||
|
||||
fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent: Option<AlbumId>, indent: f32) {
|
||||
let mut kids: Vec<&Album> = all.iter().filter(|a| a.parent == parent).collect();
|
||||
kids.sort_by_key(|a| (!a.folder, a.name.to_lowercase()));
|
||||
@@ -994,6 +1004,7 @@ fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode
|
||||
let resp = resp.on_hover_text(if n.children.is_empty() { n.path.clone() } else { format!("{} (includes the keywords below it)", n.path) });
|
||||
if resp.clicked() {
|
||||
let v = if sel { serde_json::Value::Null } else { json!(n.path) };
|
||||
browse_all_photos(app, !sel);
|
||||
let _ = app.run("library.filter", json!({"keyword": v}));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
|
||||
@@ -649,6 +649,20 @@ fn ai_masks_without_the_model_offer_the_download() {
|
||||
// no location configured in this build: no Download button (only Close), and an agent
|
||||
// confirming anyway gets the reason; the dialog stays
|
||||
h.step();
|
||||
// …but a way to install it by hand: the guide, and the model folder (created on demand)
|
||||
assert!(h.app.widgets.iter().any(|(id, _)| id == "link:samHelp"), "no installation guide link");
|
||||
let shown = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||||
let log = shown.clone();
|
||||
h.app.services.reveal = Some(Box::new(move |p: &str| {
|
||||
log.lock().unwrap().push(p.to_string());
|
||||
Ok(())
|
||||
}));
|
||||
h.step();
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:samFolder"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(dir.is_dir(), "the model folder is created to be shown");
|
||||
assert_eq!(*shown.lock().unwrap(), vec![dir.to_string_lossy().to_string()]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
let r = h.request("ui.dialog.confirm", json!({}), T);
|
||||
|
||||
@@ -563,3 +563,29 @@ fn filmstrip_marks_every_selected_photo() {
|
||||
}
|
||||
assert!(checked >= 3, "checked {checked} cells");
|
||||
}
|
||||
|
||||
/// By Date and Keywords count the whole library, so choosing a row shows its photos from All
|
||||
/// Photos even when another source (here Recently Deleted, which is empty) was open (issue #341).
|
||||
#[test]
|
||||
fn date_and_keyword_rows_show_their_photos_from_any_source() {
|
||||
// tall enough that the By Date rows are on screen below the other sections
|
||||
let mut h = demo([1400.0, 2000.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
let first = h.app.session.visible_cloned()[0];
|
||||
let year = h.app.session.catalog.photo(first).and_then(|p| p.captured.clone()).expect("demo photo date")[..4].to_string();
|
||||
// (Keywords rows go through the same helper, `browse_all_photos`)
|
||||
let (row, key) = (format!("source:date:{year}"), "date");
|
||||
let r = h.request("engine.execute", json!({"command": "library.source", "params": {"kind": "recentlyDeleted"}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
assert!(h.app.session.visible().is_empty(), "nothing deleted in the demo");
|
||||
let r = h.request("ui.clickWidget", json!({"id": row}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All, "{key}");
|
||||
assert!(!h.app.session.visible().is_empty(), "{key}: its photos are shown");
|
||||
// choosing the row again clears it, and stays in All Photos
|
||||
let r = h.request("ui.clickWidget", json!({"id": row}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
assert_eq!(h.app.session.filter, lightcraft_catalog::Filter::default(), "{key}");
|
||||
}
|
||||
@@ -141,8 +141,26 @@ pub struct SliderOut {
|
||||
pub reset: bool,
|
||||
}
|
||||
|
||||
/// What a value typed into a slider asks for (issue #322): a number such as `0.5`, `+12`, `-1,5`
|
||||
/// (comma or point) or `5600 K`, on the control's steps and clamped to its range. `None` when it
|
||||
/// isn't a finite number, so the slider keeps its value.
|
||||
pub fn typed_value(spec: &ControlSpec, text: &str) -> Option<f64> {
|
||||
let t = text.trim().replace(',', ".");
|
||||
let t = t.trim_end_matches(|c: char| c == '%' || c == 'K' || c == 'k' || c.is_whitespace());
|
||||
let v: f64 = t.parse().ok().filter(|v: &f64| v.is_finite())?;
|
||||
let step = spec.step.max(1e-9);
|
||||
Some(((v / step).round() * step).clamp(spec.min, spec.max))
|
||||
}
|
||||
|
||||
/// A slider's value as shown next to its label.
|
||||
fn shown_value(spec: &ControlSpec, v: f64) -> String {
|
||||
let shown = if spec.id == "wb.temp" { format!("{v:.0}") } else { spec.format(v).replace("+0.00", "0").replace("-0.00", "0") };
|
||||
if shown == "+0" || shown == "-0" { "0".to_string() } else { shown }
|
||||
}
|
||||
|
||||
/// A Lightroom slider row (label + value above a track with a hollow ring thumb).
|
||||
/// Double-click the label or thumb to reset. Shift-drag = fine adjustment.
|
||||
/// Double-click the label or thumb to reset. Shift-drag = fine adjustment. Click the value to
|
||||
/// type one: Return or clicking away applies it, Esc keeps the old one.
|
||||
pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_override: Option<&str>) -> SliderOut {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let w = ui.available_width();
|
||||
@@ -158,12 +176,58 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
|
||||
resp.widget_info(|| egui::WidgetInfo::slider(enabled, value, label_text.clone()));
|
||||
let label_resp = ui.interact(label_rect, id.with("label"), Sense::click());
|
||||
register(ui.ctx(), format!("slider:{}", spec.id), track_rect);
|
||||
// the value: click it to type an exact one (over the label, so it takes the click)
|
||||
let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max);
|
||||
let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click() } else { Sense::hover() });
|
||||
register(ui.ctx(), format!("sliderValue:{}", spec.id), value_rect);
|
||||
let typing_id = id.with("typing");
|
||||
let field_id = id.with("typingField");
|
||||
// the text being typed and how many frames the field has been up: it takes the keyboard on its
|
||||
// first frames (not on the click's own, whose release would take the focus straight back)
|
||||
let mut typing: Option<(String, u8)> = ui.data(|m| m.get_temp(typing_id));
|
||||
if enabled && typing.is_none() && value_resp.clicked() {
|
||||
typing = Some((shown_value(spec, value).trim_start_matches('+').to_string(), 0));
|
||||
}
|
||||
let mut out = SliderOut::default();
|
||||
let span = (spec.max - spec.min).max(1e-9);
|
||||
let to_x = |v: f64| track_rect.left() + ((v - spec.min) / span).clamp(0.0, 1.0) as f32 * track_rect.width();
|
||||
let from_x = |x: f32| spec.min + ((x - track_rect.left()) / track_rect.width()).clamp(0.0, 1.0) as f64 * span;
|
||||
let mut v = value;
|
||||
if resp.double_clicked() || label_resp.double_clicked() {
|
||||
if let Some((mut text, frames)) = typing.take() {
|
||||
let field = egui::TextEdit::singleline(&mut text)
|
||||
.id(field_id)
|
||||
.font(t.font(12.5))
|
||||
.horizontal_align(egui::Align::RIGHT)
|
||||
.desired_width(value_rect.width())
|
||||
.margin(egui::Margin::ZERO);
|
||||
// a child over the value, so the field never moves the rows around it
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(value_rect).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
let te = child.add(field);
|
||||
if frames < 2 {
|
||||
te.request_focus();
|
||||
// the old value is selected, so typing replaces it (set again once the field has the
|
||||
// focus, which places the cursor)
|
||||
let mut state = egui::text_edit::TextEditState::load(ui.ctx(), field_id).unwrap_or_default();
|
||||
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count()));
|
||||
state.cursor.set_char_range(Some(all));
|
||||
state.store(ui.ctx(), field_id);
|
||||
typing = Some((text.clone(), frames + 1));
|
||||
ui.data_mut(|m| m.insert_temp(typing_id, (text, frames + 1)));
|
||||
} else if !te.has_focus() || !enabled {
|
||||
// Esc (or the slider turning off) keeps the old value; Return or clicking away applies
|
||||
let cancelled = !enabled || ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
if let Some(nv) = typed_value(spec, &text).filter(|nv| !cancelled && (nv - value).abs() > 1e-12) {
|
||||
out.value = Some(nv);
|
||||
out.drag_started = true;
|
||||
out.drag_stopped = true;
|
||||
v = nv;
|
||||
}
|
||||
ui.data_mut(|m| m.remove::<(String, u8)>(typing_id));
|
||||
} else {
|
||||
typing = Some((text.clone(), frames));
|
||||
ui.data_mut(|m| m.insert_temp(typing_id, (text, frames)));
|
||||
}
|
||||
} else if resp.double_clicked() || label_resp.double_clicked() {
|
||||
out.reset = true;
|
||||
out.value = Some(spec.default);
|
||||
v = spec.default;
|
||||
@@ -201,7 +265,7 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
|
||||
out.drag_stopped = true;
|
||||
}
|
||||
// ↑ / ↓ while the pointer rests on the row nudge the value (⇧: five times as much)
|
||||
if enabled && out.value.is_none() && ui.rect_contains_pointer(row) {
|
||||
if enabled && out.value.is_none() && typing.is_none() && ui.rect_contains_pointer(row) {
|
||||
let (up, down, shift) = ui.input_mut(|i| {
|
||||
let shift = i.modifiers.shift;
|
||||
let m = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE };
|
||||
@@ -223,9 +287,12 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
|
||||
let text_c = if enabled { t.text_label } else { t.text_disabled };
|
||||
let p = ui.painter();
|
||||
p.text(label_rect.left_center(), Align2::LEFT_CENTER, crate::i18n::tr(label_override.unwrap_or(spec.label)), t.font(12.5), text_c);
|
||||
let shown = if spec.id == "wb.temp" { format!("{v:.0}") } else { spec.format(v).replace("+0.00", "0").replace("-0.00", "0") };
|
||||
let shown = if shown == "+0" || shown == "-0" { "0".to_string() } else { shown };
|
||||
p.text(label_rect.right_center(), Align2::RIGHT_CENTER, shown, t.font(12.5), text_c);
|
||||
if typing.is_none() {
|
||||
p.text(label_rect.right_center(), Align2::RIGHT_CENTER, shown_value(spec, v), t.font(12.5), text_c);
|
||||
if enabled && value_resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
|
||||
}
|
||||
}
|
||||
let ring = 7.0;
|
||||
let tx = to_x(v);
|
||||
paint_track(ui, track_rect, &spec.track, &t, tx, ring);
|
||||
@@ -262,15 +329,18 @@ pub fn section_header(ui: &mut Ui, id: &str, title: &str, open: bool, enabled: O
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title));
|
||||
register(ui.ctx(), format!("section:{id}"), r);
|
||||
let p = ui.painter();
|
||||
if resp.hovered() {
|
||||
if ui.rect_contains_pointer(r) {
|
||||
p.rect_filled(r, 0.0, t.chrome.gamma_multiply(1.06));
|
||||
}
|
||||
let chev = Rect::from_center_size(pos2(r.left() + 30.0, r.center().y), vec2(14.0, 14.0));
|
||||
paint(p, chev, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label);
|
||||
p.text(pos2(r.left() + 44.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(14.0), t.text);
|
||||
let mut toggled = None;
|
||||
// shown while the pointer is anywhere on the header: `resp.hovered()` turns false as soon as the
|
||||
// pointer is over the eye itself (the eye is on top), which hid the eye before it could be
|
||||
// clicked (issue #316)
|
||||
if let Some(on) = enabled
|
||||
&& (resp.hovered() || !on)
|
||||
&& (ui.rect_contains_pointer(r) || !on)
|
||||
{
|
||||
let eye = Rect::from_center_size(pos2(r.right() - 34.0, r.center().y), vec2(18.0, 18.0));
|
||||
let er = ui.interact(eye, ui.id().with(("eye", id)), Sense::click());
|
||||
@@ -467,6 +537,23 @@ mod tests {
|
||||
assert_eq!(nudged(&spec("wb.temp", 2000.0, 50000.0, 1.0), 6500.0, 1.0), 6550.0);
|
||||
assert_eq!(nudged(&spec("light.contrast", -100.0, 100.0, 1.0), 99.0, 5.0), 100.0, "clamped");
|
||||
}
|
||||
|
||||
/// Typed values (issue #322): signs, a decimal comma, units; on the control's steps, in range.
|
||||
#[test]
|
||||
fn typed_values_are_stepped_and_clamped() {
|
||||
let exposure = lightcraft_develop::controls::find("light.exposure").unwrap();
|
||||
let contrast = lightcraft_develop::controls::find("light.contrast").unwrap();
|
||||
let temp = lightcraft_develop::controls::find("wb.temp").unwrap();
|
||||
assert_eq!(typed_value(exposure, "1.5"), Some(1.5));
|
||||
assert_eq!(typed_value(exposure, " +0,25 "), Some(0.25));
|
||||
assert_eq!(typed_value(exposure, "-12"), Some(exposure.min), "clamped");
|
||||
assert_eq!(typed_value(contrast, "33.4"), Some(33.0), "on the control's steps");
|
||||
assert_eq!(typed_value(contrast, "-20 %"), Some(-20.0));
|
||||
assert_eq!(typed_value(temp, "5600 K"), Some(5600.0));
|
||||
for bad in ["", "abc", "1.2.3", "NaN", "inf", "--1"] {
|
||||
assert_eq!(typed_value(contrast, bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+3
-1
@@ -80,7 +80,9 @@ The model is fetched from an ordered list of mirrors: base URLs where `<base>/mo
|
||||
> **Maintainers:** the built-in list is **empty** until LightCraft's own CDN locations exist
|
||||
> (see the `TODO(maintainer)` there): add them in order of preference, host the three files
|
||||
> unchanged, and pin `vocab.json` / `merges.txt` (size + SHA-256) in `SAM3_FILES` at the same
|
||||
> time. Until then the in-app download needs a user-configured mirror, and the dialog says so.
|
||||
> time. Until then the in-app download needs a user-configured mirror, and the dialog says so:
|
||||
> it names the three files to put in the model folder by hand, with a button that shows that folder
|
||||
> (created if needed) and a link to this guide.
|
||||
|
||||
Hugging Face's `facebook/sam3` can't be a default: it is **gated** (each person must accept the
|
||||
licence there, wait for approval and download with their own token). Someone with access can
|
||||
|
||||
+4
-3
@@ -275,7 +275,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-EDIT-GEOM-GRID | Grid while transforming | P2 | ✅ | `crates/ui-egui/src/panels/detail.rs` | a fine grid over the photo while a geometry slider is dragged |
|
||||
| LR-EDIT-LENSBLUR | Lens blur | P2 | ⬜ | | settings field reserved, not rendered |
|
||||
| LR-EDIT-CALIB | Calibration [Classic] | P1 | ✅ | `ctl:calibration.*` | shadows tint, red/green/blue primary hue and saturation; read/written in XMP |
|
||||
| LR-EDIT-SECTION-TOGGLE | Section on/off | P1 | ✅ | `cmd:develop.sectionEnabled` | |
|
||||
| LR-EDIT-SECTION-TOGGLE | Section on/off | P1 | ✅ | `cmd:develop.sectionEnabled`, `crates/develop/src/lib.rs` (`effective`) | the eye on a section header hides its adjustments: Light (with the curve), Color (white balance, presence, mixers, grading, point colour), Effects, Detail, Optics, Geometry, Calibration; masks stay on (issue #316 fixed Light, Color and Detail, which had no effect) |
|
||||
| LR-EDIT-RESET | Reset all / section / slider | P0 | ✅ | `cmd:develop.reset`, `cmd:develop.resetSection`, `cmd:develop.resetControl`, `crates/ui-egui/src/widgets.rs` (double-click) | no "reset to open" |
|
||||
| LR-EDIT-SHOWORIG | Show original | P0 | ✅ | `cmd:view.showOriginal` | |
|
||||
|
||||
@@ -342,7 +342,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-MASK-SUBJECT | Select subject | P2 | 🟡 | `cmd:mask.add` (`subject`), `crates/pipeline/src/masks.rs` | saliency heuristic, no segmentation model |
|
||||
| LR-MASK-SKY | Select sky | P2 | 🟡 | `cmd:mask.add` (`sky`) | heuristic |
|
||||
| LR-MASK-BACKGROUND | Select background | P2 | 🟡 | `cmd:mask.add` (`background`) | inverse of the subject heuristic |
|
||||
| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand; see docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) |
|
||||
| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, shows the model folder and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) |
|
||||
| LR-MASK-PEOPLE | People parts | P2 | ⬜ | | |
|
||||
| LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty |
|
||||
| LR-MASK-BRUSH | Brush mask | P0 | ✅ | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure |
|
||||
@@ -706,6 +706,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
|
||||
| KEY-STRAIGHTEN | Straighten while held — ⌘ drag | P1 | ✅ | `cmd:crop.straighten`, `crates/ui-egui/src/panels/detail.rs` (`straighten_overlay`) | ⌘-drag in Crop draws a level line; the crop tool stays active |
|
||||
| KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs` | |
|
||||
| KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`nudged`), `cmd:develop.adjust` | ↑/↓ over any slider: ≈ 1/200 of its range (exposure 0.05, most sliders 1, temperature 50 K); ⇧ ×5; one undo step each |
|
||||
| KEY-SLIDER-TYPE | Type a slider value | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`typed_value`) | click the value next to any slider's name and type one (`1.5`, `+0,25`, `-20`, `5600 K`): Return or clicking away applies it on the control's steps and range as one undo step, Esc keeps the old value; issue #322 |
|
||||
| KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | `cmd:app.shortcuts` | |
|
||||
| KEY-HELP | Help — F1 | P2 | ✅ | `cmd:app.help` | |
|
||||
| KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | | |
|
||||
@@ -728,7 +729,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
||||
| LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N |
|
||||
| LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo |
|
||||
| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) |
|
||||
| LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning; no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) |
|
||||
| LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) |
|
||||
| LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) |
|
||||
| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here |
|
||||
| LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) |
|
||||
|
||||
+5
-1
@@ -53,7 +53,11 @@ their runtime dependencies.
|
||||
|
||||
Release builds fetch the pinned [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)
|
||||
revision and require it (`CRAFT_FONTS_REQUIRED=1`). Keep that pin deliberate when updating the
|
||||
workflow.
|
||||
workflow, and bump it (in `release.yml`, five jobs, and `freebsd.yml`) whenever craft-fonts adds a face a
|
||||
shipped language needs: a stale pin still builds, it just ships tofu (issue #319: v0.4.0 pinned a revision
|
||||
from before Noto Sans CJK SC, so Simplified Chinese had no glyphs). Before a release, check the pin against
|
||||
craft-fonts' `fonts/manifest.txt` and run `CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n`,
|
||||
whose glyph-coverage test fails when a language's characters have no face.
|
||||
|
||||
## Signing credentials
|
||||
|
||||
|
||||
@@ -50,7 +50,21 @@
|
||||
- The interface is available in German and Russian (Edit ▸ Language), alongside English, Chinese (Simplified and
|
||||
Traditional), Japanese and Brazilian Portuguese.
|
||||
|
||||
### Editing
|
||||
- Type an exact value into any slider (issue #322): click the number next to its name, type (`1.5`, `-20`, `5600`)
|
||||
and press Return; Esc keeps the old value.
|
||||
|
||||
### Editing
|
||||
- The eye on the Light, Color and Detail section headers now hides their adjustments, as it already did for Effects,
|
||||
Optics, Geometry and Calibration (issue #316).
|
||||
|
||||
### Library
|
||||
- Choosing a date under By Date or a keyword under Keywords shows those photos from All Photos, as their counts
|
||||
promise, instead of filtering whatever album or folder was open, which often showed nothing (issue #341).
|
||||
|
||||
### Reliability
|
||||
- If the desktop app can't open its window (for example when no graphics device can be used), it now says so in a
|
||||
message box that names the log file, instead of quitting without a trace (issue #260).
|
||||
- On macOS, single-key shortcuts that appear in the menu bar now work: E, C, H, M, ⇧P, I, K, D, ratings 0–5,
|
||||
labels 6–9, P / U and the rest did nothing, because macOS only passes ⌘ / ⌃ combinations and function keys
|
||||
to the menu bar and the app ignored those keys, assuming the menu bar would handle them. Keys outside the menus
|
||||
|
||||
Reference in new issue
Block a user