mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
1356 lines
66 KiB
Rust
1356 lines
66 KiB
Rust
//! The Settings dialog (⌘,): General, Import, Performance, Display, Interface, Slideshow, Faces,
|
|
//! AI Denoise.
|
|
//!
|
|
//! Changes apply immediately (no OK/Cancel). Where they are stored:
|
|
//! - **app settings** ([`crate::state::AppSettings`]: startup view, delete confirmation, GPU,
|
|
//! preview size, filmstrip/grid badges, last library, monitor profile) and Auto Advance live in
|
|
//! the UI state,
|
|
//! saved by the host in its config folder (`ui.json`);
|
|
//! - **library settings** (import defaults, XMP sidecars, thumbnail cache size, slideshow
|
|
//! playback) go through the `library.preferences` / `library.xmpPreferences` /
|
|
//! `slideshow.playback` commands into the library's `prefs.json`,
|
|
//! so they travel with the library.
|
|
|
|
use egui::RichText;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::LightcraftApp;
|
|
use crate::state::{GridBadges, PREVIEW_LIMITS, StartupView};
|
|
use crate::theme::Tokens;
|
|
use crate::widgets::register;
|
|
|
|
/// (id, label) of the tabs, in order.
|
|
pub const TABS: &[(&str, &str)] = &[
|
|
("general", "General"),
|
|
("import", "Import"),
|
|
("performance", "Performance"),
|
|
("display", "Display"),
|
|
("interface", "Interface"),
|
|
("slideshow", "Slideshow"),
|
|
("faces", "Faces"),
|
|
("denoise", "AI Denoise"),
|
|
];
|
|
|
|
/// Thumbnail cache sizes offered (MB).
|
|
const CACHE_SIZES: [u32; 5] = [512, 1024, 2048, 4096, 8192];
|
|
|
|
const LABEL_W: f32 = 150.0;
|
|
|
|
/// The dialog body for `tab` (the tab bar switches `tab`).
|
|
pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) {
|
|
let t = Tokens::get(ui.ctx());
|
|
ui.set_width((ui.ctx().content_rect().width() - 64.0).clamp(240.0, 560.0));
|
|
ui.horizontal_wrapped(|ui| {
|
|
ui.spacing_mut().item_spacing.x = 4.0;
|
|
for (id, label) in TABS {
|
|
if crate::widgets::text_button(ui, &format!("settingsTab-{id}"), label, tab == id).clicked() {
|
|
*tab = id.to_string();
|
|
}
|
|
}
|
|
});
|
|
ui.separator();
|
|
ui.add_space(2.0);
|
|
match tab.as_str() {
|
|
"import" => import_tab(app, ui, &t),
|
|
"performance" => performance_tab(app, ui, &t),
|
|
"display" => display_tab(app, ui, &t),
|
|
"interface" => interface_tab(app, ui, &t),
|
|
"slideshow" => slideshow_tab(app, ui, &t),
|
|
"faces" => super::faces::settings_tab(app, ui, &t),
|
|
"denoise" => super::denoise::settings_tab(app, ui, &t),
|
|
_ => general_tab(app, ui, &t),
|
|
}
|
|
}
|
|
|
|
pub(super) fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) {
|
|
ui.add_space(4.0);
|
|
ui.label(RichText::new(crate::i18n::tr(text)).font(t.semibold(12.5)).color(t.text));
|
|
}
|
|
|
|
pub(super) fn row<R>(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
|
ui.horizontal(|ui| {
|
|
ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
|
ui.set_min_width(LABEL_W);
|
|
ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_label));
|
|
});
|
|
add(ui)
|
|
})
|
|
.inner
|
|
}
|
|
|
|
pub(super) fn hint(ui: &mut egui::Ui, t: &Tokens, text: &str) {
|
|
ui.label(RichText::new(crate::i18n::tr(text)).size(11.0).color(t.text_dim));
|
|
}
|
|
|
|
/// A checkbox addressable as `check:{id}`; true when toggled.
|
|
pub(super) fn check(ui: &mut egui::Ui, id: &str, value: &mut bool, label: &str) -> bool {
|
|
let r = ui.checkbox(value, crate::i18n::tr(label));
|
|
register(ui.ctx(), format!("check:{id}"), r.rect);
|
|
r.changed()
|
|
}
|
|
|
|
/// Mutually exclusive buttons (`button:{id}-{index}`).
|
|
pub(super) fn choices<V: PartialEq + Copy>(ui: &mut egui::Ui, id: &str, options: &[(V, &str)], value: &mut V) -> bool {
|
|
let mut changed = false;
|
|
ui.spacing_mut().item_spacing.x = 4.0;
|
|
for (i, (v, l)) in options.iter().enumerate() {
|
|
if crate::widgets::text_button(ui, &format!("{id}-{i}"), l, *value == *v).clicked() && *value != *v {
|
|
*value = *v;
|
|
changed = true;
|
|
}
|
|
}
|
|
changed
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------------- General
|
|
|
|
fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
|
row(ui, t, crate::i18n::tr("Language"), |ui| {
|
|
let languages: Vec<_> = crate::i18n::Locale::ALL.iter().map(|language| (*language, language.name())).collect();
|
|
// Thirteen language names do not fit one line in a narrow window: wrap them.
|
|
ui.horizontal_wrapped(|ui| {
|
|
choices(ui, "settingsLanguage", &languages, &mut app.ui.language);
|
|
});
|
|
crate::i18n::set_language(app.ui.language);
|
|
});
|
|
heading(ui, t, crate::i18n::tr("Library"));
|
|
let location = match &app.session.library {
|
|
Some(l) => l.dir.display().to_string(),
|
|
None => crate::i18n::tr("In memory — nothing is saved").to_string(),
|
|
};
|
|
row(ui, t, crate::i18n::tr("Location"), |ui| {
|
|
ui.label(RichText::new(location).color(t.text));
|
|
});
|
|
row(ui, t, "", |ui| {
|
|
let can = app.services.pick_folder.is_some();
|
|
let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Open Library…")));
|
|
register(ui.ctx(), "button:settingsOpenLibrary", r.rect);
|
|
if r.clicked() {
|
|
let _ = app.run("app.openLibrary", json!({}));
|
|
}
|
|
let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("New Library…")));
|
|
register(ui.ctx(), "button:settingsNewLibrary", r.rect);
|
|
if r.clicked() {
|
|
let _ = app.run("app.newLibrary", json!({}));
|
|
}
|
|
if !can {
|
|
hint(ui, t, crate::i18n::tr("not available here"));
|
|
}
|
|
});
|
|
heading(ui, t, crate::i18n::tr("Startup"));
|
|
row(ui, t, crate::i18n::tr("Open in"), |ui| {
|
|
choices(
|
|
ui,
|
|
"settingsStartup",
|
|
&[(StartupView::Last, "Last view"), (StartupView::Grid, "Photo Grid"), (StartupView::Detail, "Detail")],
|
|
&mut app.ui.settings.startup_view,
|
|
);
|
|
});
|
|
heading(ui, t, crate::i18n::tr("Culling"));
|
|
check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging");
|
|
check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted");
|
|
heading(ui, t, crate::i18n::tr("External Editing"));
|
|
row(ui, t, crate::i18n::tr("PhotoCraft"), |ui| {
|
|
crate::text_field::TextField::singleline("field:photocraftPath", &mut app.ui.settings.photocraft_path)
|
|
.hint(crate::i18n::tr("Found automatically"))
|
|
.width(220.0)
|
|
.show(ui);
|
|
});
|
|
edit_file_rows(ui, t, "photocraftFile", &mut app.ui.settings.photocraft_file);
|
|
hint(
|
|
ui,
|
|
t,
|
|
crate::i18n::tr(
|
|
"Photo ▸ Edit In ▸ Edit in PhotoCraft (⇧⌘E, or right-click a photo) renders a copy with your edits, stacks it with the original and opens it in PhotoCraft. Save there and the copy updates here. As PSD, PhotoCraft saves straight back to it. Leave the location empty unless you use a portable copy.",
|
|
),
|
|
);
|
|
row(ui, t, crate::i18n::tr("Other editor"), |ui| {
|
|
crate::text_field::TextField::singleline("field:externalEditor", &mut app.ui.settings.external_editor)
|
|
.hint(crate::i18n::tr("System default"))
|
|
.width(220.0)
|
|
.show(ui);
|
|
});
|
|
edit_file_rows(ui, t, "externalFile", &mut app.ui.settings.external_file);
|
|
hint(ui, t, crate::i18n::tr("Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere)."));
|
|
hint(
|
|
ui,
|
|
t,
|
|
crate::i18n::tr(
|
|
"To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).",
|
|
),
|
|
);
|
|
}
|
|
|
|
/// The file Edit In hands an editor: format, colour space and bit depth (`button:{id}Format-…`).
|
|
fn edit_file_rows(ui: &mut egui::Ui, t: &Tokens, id: &str, file: &mut crate::state::EditFile) {
|
|
use crate::state::{EditFormat, EditSpace};
|
|
row(ui, t, "", |ui| {
|
|
choices(ui, &format!("{id}Format"), &[(EditFormat::Psd, "PSD"), (EditFormat::Tiff, "TIFF")], &mut file.format);
|
|
ui.separator();
|
|
choices(ui, &format!("{id}Depth"), &[(16u8, "16-bit"), (8, "8-bit")], &mut file.bit_depth);
|
|
});
|
|
row(ui, t, "", |ui| {
|
|
choices(
|
|
ui,
|
|
&format!("{id}Space"),
|
|
&[(EditSpace::AdobeRgb, "Adobe RGB"), (EditSpace::ProPhoto, "ProPhoto"), (EditSpace::DisplayP3, "P3"), (EditSpace::Srgb, "sRGB")],
|
|
&mut file.color_space,
|
|
);
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------- Import
|
|
|
|
/// A preset picker: `None` = `none_label`. Returns the new choice when it changed.
|
|
fn preset_combo(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, current: Option<&str>, none_label: &str) -> Option<Option<String>> {
|
|
let name = |pid: &str| {
|
|
app.session
|
|
.presets
|
|
.iter()
|
|
.find(|p| p.id == pid)
|
|
.map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string())
|
|
.unwrap_or_else(|| crate::i18n::tr_format!("{pid} (missing)", pid = pid))
|
|
};
|
|
let text = current.map(name).unwrap_or_else(|| crate::i18n::tr(none_label).to_string());
|
|
let mut out = None;
|
|
let r = crate::widgets::combo_box(id).width(240.0).selected_text(text).show_ui(ui, |ui| {
|
|
if ui.selectable_label(current.is_none(), crate::i18n::tr(none_label)).clicked() {
|
|
out = Some(None);
|
|
}
|
|
if let Some(pid) = crate::panels::presets::picker_rows(ui, id, &app.session.presets, current) {
|
|
out = Some(Some(pid));
|
|
}
|
|
});
|
|
register(ui.ctx(), format!("combo:{id}"), r.response.rect);
|
|
out.filter(|v| v.as_deref() != current)
|
|
}
|
|
|
|
/// Cameras of the raws in the library plus those with a stored default, sorted.
|
|
fn cameras(app: &LightcraftApp) -> Vec<String> {
|
|
let mut v: Vec<String> = app
|
|
.session
|
|
.catalog
|
|
.photos()
|
|
.filter(|p| p.kind == lightcraft_catalog::MediaKind::Raw && !p.meta.camera.is_empty())
|
|
.map(|p| p.meta.camera.clone())
|
|
.chain(app.session.import_defaults.cameras.iter().map(|c| c.camera.clone()))
|
|
.collect();
|
|
v.sort_by_key(|c| c.to_lowercase());
|
|
v.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
|
|
v
|
|
}
|
|
|
|
fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
|
let d = app.session.import_defaults.clone();
|
|
heading(ui, t, crate::i18n::tr("Raw defaults"));
|
|
hint(ui, t, crate::i18n::tr("Settings new raw photos start from. Changing them doesn't touch photos already in the library."));
|
|
row(ui, t, crate::i18n::tr("Raw photos"), |ui| {
|
|
if let Some(v) = preset_combo(app, ui, "settingsRawPreset", d.raw_preset.as_deref(), "LightCraft Default") {
|
|
let _ = app.run("library.preferences", json!({"import": {"rawPreset": v}}));
|
|
}
|
|
});
|
|
let mut per = d.per_camera;
|
|
row(ui, t, "", |ui| {
|
|
if check(ui, "settings.perCamera", &mut per, "Use camera-specific defaults") {
|
|
let _ = app.run("library.preferences", json!({"import": {"perCamera": per}}));
|
|
}
|
|
});
|
|
if per {
|
|
let cams = cameras(app);
|
|
if cams.is_empty() {
|
|
hint(ui, t, crate::i18n::tr("No raw photos yet: cameras appear here once their photos are in the library."));
|
|
}
|
|
egui::ScrollArea::vertical().max_height(150.0).id_salt("settingsCameras").show(ui, |ui| {
|
|
for (i, cam) in cams.iter().enumerate() {
|
|
let entry = d.cameras.iter().find(|c| c.camera.eq_ignore_ascii_case(cam));
|
|
row(ui, t, cam, |ui| {
|
|
// "Raw default" = no entry; otherwise the entry's preset (None = LightCraft Default)
|
|
const RAW_DEFAULT: &str = "\u{1}raw";
|
|
let current = match entry {
|
|
None => Some(RAW_DEFAULT),
|
|
Some(e) => e.preset.as_deref(),
|
|
};
|
|
let mut pick = None;
|
|
let label = match current {
|
|
Some(RAW_DEFAULT) => crate::i18n::tr("Same as raw default").to_string(),
|
|
None => crate::i18n::tr("LightCraft Default").to_string(),
|
|
Some(pid) => app
|
|
.session
|
|
.presets
|
|
.iter()
|
|
.find(|p| p.id == pid)
|
|
.map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string())
|
|
.unwrap_or_else(|| pid.to_string()),
|
|
};
|
|
let id = format!("settingsCamera-{i}");
|
|
let r = crate::widgets::combo_box(&id).width(240.0).selected_text(label).show_ui(ui, |ui| {
|
|
if ui.selectable_label(current == Some(RAW_DEFAULT), crate::i18n::tr("Same as raw default")).clicked() {
|
|
pick = Some(json!({"camera": cam, "remove": true}));
|
|
}
|
|
if ui.selectable_label(current.is_none(), crate::i18n::tr("LightCraft Default")).clicked() {
|
|
pick = Some(json!({"camera": cam, "preset": null}));
|
|
}
|
|
if let Some(pid) = crate::panels::presets::picker_rows(ui, &id, &app.session.presets, current) {
|
|
pick = Some(json!({"camera": cam, "preset": pid}));
|
|
}
|
|
});
|
|
register(ui.ctx(), format!("combo:{id}"), r.response.rect);
|
|
if let Some(c) = pick {
|
|
let _ = app.run("library.preferences", json!({"camera": c}));
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
heading(ui, t, crate::i18n::tr("Other images (JPEG, PNG, TIFF, HEIC…)"));
|
|
row(ui, t, crate::i18n::tr("Non-raw photos"), |ui| {
|
|
if let Some(v) = preset_combo(app, ui, "settingsOtherPreset", d.other_preset.as_deref(), "None") {
|
|
let _ = app.run("library.preferences", json!({"import": {"otherPreset": v}}));
|
|
}
|
|
});
|
|
heading(ui, t, crate::i18n::tr("Metadata"));
|
|
hint(ui, t, crate::i18n::tr("Added to photos you import that don't already have it."));
|
|
for (key, label, hint_text, value) in
|
|
[("copyright", "Copyright", "© 2026 Your Name", d.copyright.clone()), ("creator", "Creator", "Your Name", d.creator.clone())]
|
|
{
|
|
row(ui, t, label, |ui| {
|
|
let id = egui::Id::new(("settingsMeta", key));
|
|
let mut text: String = ui.data(|m| m.get_temp(id)).unwrap_or(value.clone());
|
|
let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(hint_text).desired_width(240.0));
|
|
register(ui.ctx(), format!("field:settings.{key}"), r.rect);
|
|
if r.lost_focus() && text.trim() != value {
|
|
let _ = app.run("library.preferences", json!({"import": {key: text.trim()}}));
|
|
}
|
|
if r.has_focus() {
|
|
ui.data_mut(|m| m.insert_temp(id, text));
|
|
} else {
|
|
ui.data_mut(|m| m.remove::<String>(id));
|
|
}
|
|
});
|
|
}
|
|
if !app.session.metadata_presets.is_empty() {
|
|
row(ui, t, crate::i18n::tr("Metadata preset"), |ui| {
|
|
let cur = d.metadata_preset.clone();
|
|
let mut pick = None;
|
|
let r = crate::widgets::combo_box("settingsMetaPreset")
|
|
.width(240.0)
|
|
.selected_text(cur.clone().unwrap_or_else(|| crate::i18n::tr("None").into()))
|
|
.show_ui(ui, |ui| {
|
|
if ui.selectable_label(cur.is_none(), crate::i18n::tr("None")).clicked() {
|
|
pick = Some(String::new());
|
|
}
|
|
for m in &app.session.metadata_presets {
|
|
if ui.selectable_label(cur.as_deref() == Some(m.name.as_str()), &m.name).clicked() {
|
|
pick = Some(m.name.clone());
|
|
}
|
|
}
|
|
});
|
|
register(ui.ctx(), "combo:settingsMetaPreset", r.response.rect);
|
|
if let Some(n) = pick {
|
|
let _ = app.run("library.preferences", json!({"import": {"metadataPreset": n}}));
|
|
}
|
|
});
|
|
}
|
|
heading(ui, t, crate::i18n::tr("XMP sidecars"));
|
|
let mut xmp = app.session.xmp;
|
|
if check(ui, "settings.autoWriteXmp", &mut xmp.auto_write, "Automatically write changes into XMP sidecars") {
|
|
let _ = app.run("library.xmpPreferences", json!({"autoWrite": xmp.auto_write}));
|
|
}
|
|
row(ui, t, crate::i18n::tr("Sidecar names"), |ui| {
|
|
use lightcraft_engine::sidecar::SidecarNaming as N;
|
|
let mut n = xmp.naming;
|
|
if choices(ui, "settingsXmpNaming", &[(N::Stem, "IMG_1.xmp"), (N::Full, "IMG_1.CR3.xmp")], &mut n) {
|
|
let naming = if n == N::Full { "full" } else { "stem" };
|
|
let _ = app.run("library.xmpPreferences", json!({"naming": naming}));
|
|
}
|
|
});
|
|
if !cfg!(target_arch = "wasm32") {
|
|
heading(ui, t, crate::i18n::tr("Cameras and Cards"));
|
|
let mut on_card = d.show_on_card;
|
|
if check(ui, "settings.importOnCard", &mut on_card, "Show the import review when a camera or card is connected") {
|
|
let _ = app.run("library.preferences", json!({"import": {"showOnCard": on_card}}));
|
|
}
|
|
heading(ui, t, crate::i18n::tr("Auto Import"));
|
|
hint(ui, t, crate::i18n::tr("Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete."));
|
|
row(ui, t, crate::i18n::tr("Watched folder"), |ui| {
|
|
ui.label(RichText::new(d.auto_folder.clone().unwrap_or_else(|| crate::i18n::tr("Off").into())).color(t.text));
|
|
let can = app.services.pick_folder.is_some();
|
|
let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Choose…")));
|
|
register(ui.ctx(), "button:settingsAutoFolder", r.rect);
|
|
if r.clicked()
|
|
&& let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f())
|
|
&& let Err(e) = app.run("library.autoImport", json!({"folder": f}))
|
|
{
|
|
app.toast(ui.ctx(), e);
|
|
}
|
|
if d.auto_folder.is_some() && ui.button(crate::i18n::tr("Turn Off")).clicked() {
|
|
let _ = app.run("library.autoImport", json!({"folder": null}));
|
|
}
|
|
});
|
|
if d.auto_folder.is_some() {
|
|
row(ui, t, "", |ui| {
|
|
let mut copy = d.auto_copy;
|
|
if ui.checkbox(&mut copy, crate::i18n::tr("Copy into the library (else use the files where they are)")).changed() {
|
|
let _ = app.run("library.autoImport", json!({"copy": copy}));
|
|
}
|
|
});
|
|
row(ui, t, crate::i18n::tr("Album"), |ui| {
|
|
let id = egui::Id::new("auto-album");
|
|
let mut name = ui.data(|m| m.get_temp::<String>(id)).unwrap_or_else(|| d.auto_album.clone().unwrap_or_default());
|
|
let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text(crate::i18n::tr("None")).desired_width(180.0));
|
|
if r.lost_focus() {
|
|
let _ = app.run("library.autoImport", json!({"album": name.trim()}));
|
|
}
|
|
ui.data_mut(|m| m.insert_temp(id, name));
|
|
});
|
|
}
|
|
}
|
|
if app.session.library.is_none() {
|
|
hint(ui, t, crate::i18n::tr("In-memory session: these settings last until LightCraft quits."));
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------------- Performance
|
|
|
|
fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
|
use lightcraft_engine::gpu;
|
|
heading(ui, t, crate::i18n::tr("Rendering"));
|
|
check(ui, "settings.gpu", &mut app.ui.settings.gpu, "Use the GPU for rendering");
|
|
let status = if !gpu::available() {
|
|
match gpu::unavailable_reason() {
|
|
Some(why) => crate::i18n::tr_format!("Rendering on the CPU: {why}", why = why),
|
|
None => "No usable GPU found: rendering on the CPU".to_string(),
|
|
}
|
|
} else {
|
|
format!("GPU: {}", gpu::adapter_name().unwrap_or_else(|| "starting…".into()))
|
|
};
|
|
// One line, the whole text on hover: the reason can be a long driver message (no adapter
|
|
// among the backends …), and the GPU state is process-wide, so it may change between frames;
|
|
// a hint that wrapped would move every control below it under the pointer.
|
|
ui.add(egui::Label::new(RichText::new(crate::i18n::tr(&status)).size(11.0).color(t.text_dim)).truncate()).on_hover_text(&status);
|
|
row(ui, t, crate::i18n::tr("Preview size"), |ui| {
|
|
let opts: Vec<(u32, String)> =
|
|
PREVIEW_LIMITS.iter().map(|e| (*e, if *e == 0 { crate::i18n::tr("Automatic").to_string() } else { format!("{e} px") })).collect();
|
|
let opts: Vec<(u32, &str)> = opts.iter().map(|(e, l)| (*e, l.as_str())).collect();
|
|
choices(ui, "settingsPreview", &opts, &mut app.ui.settings.preview_limit);
|
|
});
|
|
hint(
|
|
ui,
|
|
t,
|
|
crate::i18n::tr(
|
|
"Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.",
|
|
),
|
|
);
|
|
row(ui, t, crate::i18n::tr("Memory for caches"), |ui| {
|
|
let auto = crate::i18n::tr_format!("Automatic ({} MB)", lightcraft_engine::memory::default_budget() >> 20);
|
|
let opts = [(0u32, auto.as_str()), (512, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB")];
|
|
choices(ui, "settingsMemory", &opts, &mut app.ui.settings.memory_mb);
|
|
});
|
|
heading(ui, t, crate::i18n::tr("Thumbnail cache"));
|
|
let cur = (app.session.cache_bytes() >> 20) as u32;
|
|
row(ui, t, crate::i18n::tr("Size limit"), |ui| {
|
|
let opts = [(512u32, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB"), (8192, "8 GB")];
|
|
let mut v = if CACHE_SIZES.contains(&cur) { cur } else { 2048 };
|
|
if choices(ui, "settingsCache", &opts, &mut v) {
|
|
let _ = app.run("library.preferences", json!({"cacheMb": v}));
|
|
}
|
|
});
|
|
let used = app.session.media.rendered.disk().map(|d| d.size());
|
|
row(ui, t, crate::i18n::tr("In use"), |ui| {
|
|
ui.label(RichText::new(used.map(|b| format!("{:.1} MB", b as f64 / 1048576.0)).unwrap_or_else(|| "memory only".into())).color(t.text));
|
|
let r = ui.button(crate::i18n::tr("Clear Cache"));
|
|
register(ui.ctx(), "button:settingsClearCache", r.rect);
|
|
if r.clicked() {
|
|
let _ = app.run("library.clearPreviews", json!({}));
|
|
}
|
|
});
|
|
heading(ui, t, crate::i18n::tr("Local folders"));
|
|
row(ui, t, crate::i18n::tr("Forget unchanged photos"), |ui| {
|
|
let opts = [(0u32, "Never"), (7, "After 7 days"), (30, "After 30 days"), (90, "After 90 days"), (365, "After a year")];
|
|
let mut v = app.session.forget_local_days;
|
|
if choices(ui, "settingsForgetLocal", &opts, &mut v) {
|
|
let _ = app.run("library.preferences", json!({"forgetLocalDays": v}));
|
|
}
|
|
});
|
|
hint(
|
|
ui,
|
|
t,
|
|
crate::i18n::tr(
|
|
"Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.",
|
|
),
|
|
);
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
smart_previews(app, ui, t);
|
|
}
|
|
|
|
/// Where this library keeps its smart previews (the offline-editing proxies, which can be large):
|
|
/// the effective folder, what is in it, and choosing another one.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
|
// listing a big folder every frame would be slow: refresh every 2 s and after a change
|
|
let cache = egui::Id::new("smart-location");
|
|
let now = ui.input(|i| i.time);
|
|
let loc: Value = match ui.data(|d| d.get_temp::<(f64, Value)>(cache)) {
|
|
Some((at, v)) if now - at < 2.0 => v,
|
|
_ => {
|
|
let v = app.run("library.smartPreviewsLocation", json!({})).unwrap_or(Value::Null);
|
|
ui.data_mut(|d| d.insert_temp(cache, (now, v.clone())));
|
|
v
|
|
}
|
|
};
|
|
if loc.is_null() {
|
|
return;
|
|
}
|
|
heading(ui, t, crate::i18n::tr("Smart previews"));
|
|
let path = loc["path"].as_str().unwrap_or_default().to_string();
|
|
let available = loc["available"].as_bool().unwrap_or(true);
|
|
let custom = loc["custom"].as_bool().unwrap_or(false);
|
|
row(ui, t, crate::i18n::tr("Folder"), |ui| {
|
|
let text = format!("{path}{}", if custom { "" } else { " (library default)" });
|
|
ui.add(egui::Label::new(RichText::new(text.clone()).color(if available { t.text } else { t.reject })).truncate()).on_hover_text(text);
|
|
});
|
|
let (count, bytes) = (loc["count"].as_u64().unwrap_or(0), loc["bytes"].as_u64().unwrap_or(0));
|
|
if available {
|
|
hint(
|
|
ui,
|
|
t,
|
|
&crate::i18n::tr_format!(
|
|
"{count} smart preview{} · {:.1} MB",
|
|
if count == 1 { "" } else { "s" },
|
|
bytes as f64 / 1048576.0,
|
|
count = count
|
|
),
|
|
);
|
|
} else {
|
|
hint(
|
|
ui,
|
|
t,
|
|
crate::i18n::tr(
|
|
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.",
|
|
),
|
|
);
|
|
}
|
|
// what happens to the previews already built when the folder changes
|
|
let mode_id = egui::Id::new("smart-existing");
|
|
let mut mode: u8 = ui.data(|d| d.get_temp(mode_id)).unwrap_or(0);
|
|
if count > 0 {
|
|
row(ui, t, crate::i18n::tr("Existing previews"), |ui| {
|
|
if choices(ui, "settingsSmartExisting", &[(0u8, "Move them"), (1, "Leave them"), (2, "Delete them")], &mut mode) {
|
|
ui.data_mut(|d| d.insert_temp(mode_id, mode));
|
|
}
|
|
});
|
|
}
|
|
let existing = ["move", "leave", "discard"][mode.min(2) as usize];
|
|
let mut result = None;
|
|
ui.horizontal(|ui| {
|
|
ui.add_space(LABEL_W);
|
|
if app.services.pick_folder.is_some() {
|
|
let r = ui.button(crate::i18n::tr("Choose Folder…"));
|
|
register(ui.ctx(), "button:settingsSmartChoose", r.rect);
|
|
if r.clicked()
|
|
&& let Some(dir) = app.services.pick_folder.as_mut().and_then(|f| f())
|
|
{
|
|
result = Some(app.run("library.smartPreviewsLocation", json!({"path": dir, "existing": existing})));
|
|
}
|
|
}
|
|
let r = ui.add_enabled(custom, egui::Button::new(crate::i18n::tr("Use Library Folder")));
|
|
register(ui.ctx(), "button:settingsSmartReset", r.rect);
|
|
if r.clicked() {
|
|
result = Some(app.run("library.smartPreviewsLocation", json!({"reset": true, "existing": existing})));
|
|
}
|
|
});
|
|
if let Some(r) = result {
|
|
ui.data_mut(|d| d.remove::<(f64, Value)>(cache));
|
|
match r {
|
|
Ok(v) => {
|
|
let failed = v["failed"].as_array().map_or(0, Vec::len);
|
|
let msg = match (existing, v["handled"].as_u64().unwrap_or(0)) {
|
|
(_, 0) => "Smart previews folder changed".to_string(),
|
|
("move", n) => format!("Smart previews folder changed; moved {n}"),
|
|
("discard", n) => format!("Smart previews folder changed; deleted {n}"),
|
|
_ => "Smart previews folder changed".to_string(),
|
|
};
|
|
app.toast(ui.ctx(), if failed > 0 { crate::i18n::tr_format!("{msg} ({failed} failed)", failed = failed, msg = msg) } else { msg });
|
|
}
|
|
Err(e) => app.toast(ui.ctx(), e),
|
|
}
|
|
}
|
|
hint(ui, t, crate::i18n::tr("Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library."));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------- Interface
|
|
|
|
/// The monitor profile (`app.displayProfile`): previews are shown through the display's ICC
|
|
/// profile, so wide-gamut and calibrated displays show colours as they are.
|
|
fn display_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
|
heading(ui, t, crate::i18n::tr("Monitor profile"));
|
|
let current = app.renderer.display().cloned();
|
|
let screen = current.as_ref().is_some_and(|d| d.path == crate::SCREEN_PROFILE);
|
|
row(ui, t, crate::i18n::tr("Profile"), |ui| {
|
|
let name = match ¤t {
|
|
Some(d) => d.profile.description.clone(),
|
|
None => crate::i18n::tr("None (the display is treated as sRGB)").to_string(),
|
|
};
|
|
ui.label(RichText::new(name).color(t.text));
|
|
if screen {
|
|
hint(ui, t, crate::i18n::tr("this screen's profile"));
|
|
}
|
|
});
|
|
row(ui, t, "", |ui| {
|
|
let can = app.services.pick_display_profile.is_some();
|
|
let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Choose Profile…")));
|
|
register(ui.ctx(), "button:settingsDisplayProfile", r.rect);
|
|
if r.clicked()
|
|
&& let Some(path) = app.services.pick_display_profile.as_mut().and_then(|pick| pick().into_iter().next())
|
|
{
|
|
app.ui.settings.display_profile = path;
|
|
}
|
|
// with no profile chosen, previews follow the screen's own profile where the host knows it
|
|
let none = if app.services.screen_profile.is_some() { crate::i18n::tr("Use Screen Profile") } else { crate::i18n::tr("Use sRGB") };
|
|
let r = ui.add_enabled(!app.ui.settings.display_profile.is_empty(), egui::Button::new(none));
|
|
register(ui.ctx(), "button:settingsDisplayProfileNone", r.rect);
|
|
if r.clicked() {
|
|
app.ui.settings.display_profile.clear();
|
|
}
|
|
if !can {
|
|
hint(ui, t, crate::i18n::tr("not available here"));
|
|
}
|
|
});
|
|
if let Some(e) = &app.display_error {
|
|
ui.label(RichText::new(e).size(11.0).color(t.reject));
|
|
}
|
|
if let Some(d) = ¤t {
|
|
let info = d.describe();
|
|
if !screen {
|
|
row(ui, t, crate::i18n::tr("File"), |ui| {
|
|
ui.label(RichText::new(&d.path).size(11.0).color(t.text_dim));
|
|
});
|
|
}
|
|
row(ui, t, crate::i18n::tr("Type"), |ui| {
|
|
let kind = match d.profile.kind {
|
|
lightcraft_engine::display::DisplayKind::MatrixTrc => crate::i18n::tr("Matrix/TRC"),
|
|
lightcraft_engine::display::DisplayKind::Lut => crate::i18n::tr("LUT-based"),
|
|
};
|
|
ui.label(RichText::new(kind).color(t.text));
|
|
});
|
|
row(ui, t, crate::i18n::tr("Primaries (x, y)"), |ui| {
|
|
let p = &info["primaries"];
|
|
let xy = |(k, l): (&str, &str)| format!("{l} {:.3}, {:.3}", p[k][0].as_f64().unwrap_or(0.0), p[k][1].as_f64().unwrap_or(0.0));
|
|
let text = [("red", "R"), ("green", "G"), ("blue", "B")].map(xy).join(" ");
|
|
ui.label(RichText::new(text).size(11.0).color(t.text_dim));
|
|
});
|
|
}
|
|
hint(
|
|
ui,
|
|
t,
|
|
crate::i18n::tr(
|
|
"Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.",
|
|
),
|
|
);
|
|
#[cfg(target_os = "linux")]
|
|
hint(ui, t, crate::i18n::tr("Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc."));
|
|
}
|
|
|
|
fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
|
let system = app.system_theme(ui.ctx());
|
|
appearance(ui, t, &mut app.ui.settings, system);
|
|
heading(ui, t, crate::i18n::tr("Filmstrip"));
|
|
check(ui, "settings.filmNames", &mut app.ui.settings.film_names, "Show file names");
|
|
check(ui, "settings.filmBadges", &mut app.ui.settings.film_badges, "Show ratings, flags and edit badges");
|
|
heading(ui, t, crate::i18n::tr("Grid"));
|
|
row(ui, t, crate::i18n::tr("Ratings & flags"), |ui| {
|
|
choices(
|
|
ui,
|
|
"settingsGridBadges",
|
|
&[(GridBadges::Auto, "When rated or hovered"), (GridBadges::Always, "Always"), (GridBadges::Never, "Never")],
|
|
&mut app.ui.settings.grid_badges,
|
|
);
|
|
});
|
|
check(ui, "settings.showFilenames", &mut app.ui.show_filenames, "Square Grid: show file names and formats");
|
|
heading(ui, t, crate::i18n::tr("Keywords"));
|
|
row(ui, t, crate::i18n::tr("Keywords of the selection"), |ui| {
|
|
use crate::state::KeywordChips as K;
|
|
choices(ui, "settingsKeywordChips", &[(K::Paths, "Whole names"), (K::Grouped, "Grouped by parent")], &mut app.ui.settings.keyword_chips);
|
|
});
|
|
hint(
|
|
ui,
|
|
t,
|
|
crate::i18n::tr("Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened."),
|
|
);
|
|
heading(ui, t, crate::i18n::tr("Detail"));
|
|
check(ui, "settings.navigator", &mut app.ui.navigator, "Show the Navigator while zoomed in");
|
|
row(ui, t, crate::i18n::tr("Info overlay"), |ui| {
|
|
use crate::state::InfoOverlay as I;
|
|
choices(ui, "settingsInfo", &[(I::Off, "Off"), (I::Basic, "File & date"), (I::Exposure, "Exposure")], &mut app.ui.info_overlay);
|
|
});
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------------- Slideshow
|
|
|
|
/// A playback duration slider (`slider:{id}`). While it is dragged the value changes in memory
|
|
/// (the slideshow reads it); letting go saves it through `slideshow.playback`.
|
|
fn duration_slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: f64, range: std::ops::RangeInclusive<f64>) {
|
|
let mut v = value;
|
|
let r = ui.add(egui::Slider::new(&mut v, range).logarithmic(key == "slideDuration").suffix(" s").max_decimals(1));
|
|
register(ui.ctx(), format!("slider:{id}"), r.rect);
|
|
if r.changed()
|
|
&& let Ok(next) = app.session.slideshow.merged(&json!({ key: v }))
|
|
{
|
|
app.session.slideshow = next;
|
|
}
|
|
if r.drag_stopped() || (r.changed() && !r.dragged()) {
|
|
let _ = app.run("slideshow.playback", json!({ key: v }));
|
|
}
|
|
}
|
|
|
|
/// Width of the Slideshow tab's settings column (the slide preview is beside it).
|
|
const SLIDESHOW_COLUMN_W: f32 = 480.0;
|
|
|
|
fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
|
// the settings scroll on the left, the composed slide stays in view on the right
|
|
let top_down = egui::Layout::top_down(egui::Align::Min);
|
|
// as tall as the window allows (the dialog's title, tabs and Close button take ~260 pt)
|
|
let tall = (ui.ctx().content_rect().height() - 260.0).clamp(300.0, 760.0);
|
|
ui.horizontal_top(|ui| {
|
|
ui.allocate_ui_with_layout(egui::vec2(SLIDESHOW_COLUMN_W, tall), top_down, |ui| {
|
|
ui.set_width(SLIDESHOW_COLUMN_W);
|
|
egui::ScrollArea::vertical().id_salt("settingsSlideshowScroll").max_height(tall).auto_shrink([false, true]).show(ui, |ui| {
|
|
super::slide_templates::section(app, ui, t);
|
|
ui.add_space(6.0);
|
|
playback_section(app, ui, t);
|
|
ui.add_space(6.0);
|
|
super::slide_layout::sections(app, ui, t);
|
|
ui.add_space(6.0);
|
|
super::slide_overlays::sections(app, ui, t);
|
|
ui.add_space(6.0);
|
|
super::slide_titles::sections(app, ui, t);
|
|
ui.add_space(6.0);
|
|
export_section(app, ui, t);
|
|
});
|
|
});
|
|
ui.add_space(12.0);
|
|
ui.allocate_ui_with_layout(egui::vec2(super::slide_layout::PREVIEW_W, tall), top_down, |ui| {
|
|
ui.set_width(super::slide_layout::PREVIEW_W);
|
|
super::slide_layout::preview(app, ui, t)
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Export: PDF, JPEG sequence or video of the slideshow (View ▸ Export Slideshow…).
|
|
fn export_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
|
heading(ui, t, crate::i18n::tr("Export"));
|
|
let running = app.session.slideshow_export.as_ref().is_some_and(|r| r.running());
|
|
ui.horizontal(|ui| {
|
|
for (id, label, format) in [
|
|
("slideshowExportPdf", "Export PDF…", "pdf"),
|
|
("slideshowExportJpeg", "Export JPEG…", "jpeg"),
|
|
("slideshowExportVideo", "Export Video…", "video"),
|
|
] {
|
|
let r = ui.add_enabled(!running, egui::Button::new(crate::i18n::tr(label)));
|
|
register(ui.ctx(), format!("button:{id}"), r.rect);
|
|
if r.clicked() {
|
|
let _ = app.run("dialog.slideshowExport", json!({"format": format}));
|
|
}
|
|
}
|
|
});
|
|
hint(ui, t, crate::i18n::tr("The photos in view as slides, with the settings above. Progress shows in the activity stack."));
|
|
}
|
|
|
|
fn playback_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
|
use lightcraft_engine::slideshow::{FADE_MAX, SLIDE_MAX, SLIDE_MIN};
|
|
heading(ui, t, crate::i18n::tr("Playback"));
|
|
let s = app.session.slideshow.clone();
|
|
row(ui, t, crate::i18n::tr("Slide duration"), |ui| {
|
|
duration_slider(app, ui, "slideshowSlide", "slideDuration", s.slide_duration, SLIDE_MIN..=SLIDE_MAX)
|
|
});
|
|
row(ui, t, crate::i18n::tr("Fade duration"), |ui| {
|
|
duration_slider(app, ui, "slideshowFade", "fadeDuration", s.fade_duration, 0.0..=FADE_MAX.min(s.slide_duration))
|
|
});
|
|
hint(ui, t, crate::i18n::tr("A slide stays up for its duration, its fade in included. A fade of 0 cuts."));
|
|
row(ui, t, crate::i18n::tr("Fade"), |ui| {
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
enum Fade {
|
|
Cross,
|
|
Black,
|
|
White,
|
|
Color,
|
|
}
|
|
let mut f = match s.fade_color {
|
|
None => Fade::Cross,
|
|
Some([0, 0, 0]) => Fade::Black,
|
|
Some([255, 255, 255]) => Fade::White,
|
|
Some(_) => Fade::Color,
|
|
};
|
|
let opts = [(Fade::Cross, "Crossfade"), (Fade::Black, "Through black"), (Fade::White, "Through white"), (Fade::Color, "Through a colour")];
|
|
if choices(ui, "settingsSlideshowFade", &opts, &mut f) {
|
|
let c = match f {
|
|
Fade::Cross => Value::Null,
|
|
Fade::Black => json!("#000000"),
|
|
Fade::White => json!("#ffffff"),
|
|
Fade::Color => json!([64, 64, 64]),
|
|
};
|
|
let _ = app.run("slideshow.playback", json!({"fadeColor": c}));
|
|
}
|
|
if f == Fade::Color
|
|
&& let Some(mut c) = s.fade_color
|
|
{
|
|
let r = egui::color_picker::color_edit_button_srgb(ui, &mut c);
|
|
register(ui.ctx(), "button:slideshowFadeColor", r.rect);
|
|
if r.changed() {
|
|
let _ = app.run("slideshow.playback", json!({"fadeColor": c}));
|
|
}
|
|
}
|
|
});
|
|
let mut v = s.random_order;
|
|
if check(ui, "settings.slideshowRandom", &mut v, "Random order") {
|
|
let _ = app.run("slideshow.playback", json!({"randomOrder": v}));
|
|
}
|
|
let mut v = s.repeat;
|
|
if check(ui, "settings.slideshowRepeat", &mut v, "Repeat (off: the slideshow ends after the last photo)") {
|
|
let _ = app.run("slideshow.playback", json!({"repeat": v}));
|
|
}
|
|
let mut v = s.prepare_previews;
|
|
if check(ui, "settings.slideshowPrepare", &mut v, "Prepare previews in advance") {
|
|
let _ = app.run("slideshow.playback", json!({"preparePreviews": v}));
|
|
}
|
|
hint(
|
|
ui,
|
|
t,
|
|
crate::i18n::tr(
|
|
"Renders the next slides while the current one is up, so fades never wait. Off: each slide renders when it is due (less memory and work).",
|
|
),
|
|
);
|
|
hint(
|
|
ui,
|
|
t,
|
|
crate::i18n::tr("Slideshow in the View menu plays the photos in view from the selected one. These settings are saved with the library."),
|
|
);
|
|
}
|
|
|
|
/// Appearance Mode (`button:settingsAppearance-{0,1,2}`: Auto, Dark, Light) above the light and
|
|
/// dark theme cards (`radio:theme-{id}`), each with a preview of the chosen theme. The card in
|
|
/// use is outlined. Applied on the next frame ([`LightcraftApp::sync_theme`]).
|
|
fn appearance(ui: &mut egui::Ui, t: &Tokens, s: &mut crate::state::AppSettings, system: Option<egui::Theme>) {
|
|
use crate::state::{AppearanceMode as M, DarkTheme, LightTheme};
|
|
heading(ui, t, crate::i18n::tr("Appearance"));
|
|
ui.label(RichText::new(crate::i18n::tr("Appearance Mode")).color(t.text_label));
|
|
ui.horizontal_wrapped(|ui| {
|
|
choices(ui, "settingsAppearance", &[(M::Auto, "Sync with System"), (M::Dark, "Dark Mode"), (M::Light, "Light Mode")], &mut s.appearance_mode);
|
|
});
|
|
ui.add_space(4.0);
|
|
let light_active = s.theme(system) == s.light_theme.kind();
|
|
let available = ui.available_width();
|
|
let mut cards = |ui: &mut egui::Ui, width: f32| {
|
|
ui.spacing_mut().item_spacing.x = 12.0;
|
|
let light: Vec<_> = LightTheme::ALL.iter().map(|l| (*l, l.kind())).collect();
|
|
theme_card(ui, t, "Light Theme", light_active, &light, &mut s.light_theme, &mut s.favorite_themes, width);
|
|
let dark: Vec<_> = DarkTheme::ALL.iter().map(|d| (*d, d.kind())).collect();
|
|
theme_card(ui, t, "Dark Theme", !light_active, &dark, &mut s.dark_theme, &mut s.favorite_themes, width);
|
|
};
|
|
if available < 440.0 {
|
|
ui.vertical(|ui| cards(ui, available));
|
|
} else {
|
|
ui.horizontal_top(|ui| cards(ui, ((available - 12.0) / 2.0).floor()));
|
|
}
|
|
ui.add_space(2.0);
|
|
}
|
|
|
|
/// One family's card: its title (and "Active" when it is the one shown), a preview of the
|
|
/// selected theme and a radio button per theme.
|
|
fn theme_card<V: PartialEq + Copy>(
|
|
ui: &mut egui::Ui,
|
|
t: &Tokens,
|
|
title: &str,
|
|
active: bool,
|
|
options: &[(V, crate::theme::ThemeKind)],
|
|
value: &mut V,
|
|
favorites: &mut Vec<String>,
|
|
width: f32,
|
|
) {
|
|
egui::Frame::new()
|
|
.fill(t.inset)
|
|
.stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border }))
|
|
.corner_radius(6)
|
|
.inner_margin(8)
|
|
.show(ui, |ui| {
|
|
ui.vertical(|ui| {
|
|
let inner = (width - 18.0).clamp(120.0, 240.0);
|
|
ui.set_width(inner);
|
|
ui.horizontal(|ui| {
|
|
ui.label(RichText::new(crate::i18n::tr(title)).font(t.semibold(12.0)).color(t.text));
|
|
if active {
|
|
ui.label(RichText::new(crate::i18n::tr("In Use")).size(11.0).color(t.accent));
|
|
}
|
|
});
|
|
let shown = options.iter().find(|(v, _)| v == value).or(options.first()).map(|(_, k)| *k).unwrap_or_default();
|
|
theme_preview(ui, shown, inner);
|
|
for (v, kind) in options {
|
|
let row = egui::Rect::from_min_size(ui.cursor().min, egui::vec2(ui.available_width(), 24.0));
|
|
let hovered = ui.ctx().pointer_hover_pos().is_some_and(|p| row.contains(p));
|
|
ui.horizontal(|ui| {
|
|
let r = ui.radio_value(value, *v, crate::i18n::tr(kind.label()));
|
|
register(ui.ctx(), format!("radio:theme-{}", kind.id()), r.rect);
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
let favorite = favorites.iter().any(|id| id == kind.id());
|
|
let r = ui
|
|
.add_visible(favorite || hovered, egui::Button::new("").min_size(egui::vec2(24.0, 24.0)).frame(false))
|
|
.on_hover_text(crate::i18n::tr(if favorite { "Remove from favorites" } else { "Add to favorites" }));
|
|
r.widget_info(|| {
|
|
egui::WidgetInfo::labeled(
|
|
egui::WidgetType::Button,
|
|
true,
|
|
format!(
|
|
"{}: {}",
|
|
crate::i18n::tr(if favorite { "Remove from favorites" } else { "Add to favorites" }),
|
|
crate::i18n::tr(kind.label())
|
|
),
|
|
)
|
|
});
|
|
register(ui.ctx(), format!("button:themeFavorite-{}", kind.id()), r.rect);
|
|
if favorite || hovered {
|
|
crate::icons::paint(
|
|
ui.painter(),
|
|
r.rect.shrink(4.0),
|
|
if favorite { crate::icons::Icon::StarFilled } else { crate::icons::Icon::Star },
|
|
if favorite { t.accent } else { t.text_dim },
|
|
);
|
|
}
|
|
if r.clicked() {
|
|
if favorite {
|
|
favorites.retain(|id| id != kind.id());
|
|
} else {
|
|
favorites.push(kind.id().into());
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/// A small LightCraft window painted with `kind`'s tokens: the top bar with its search field, the
|
|
/// photo with the filmstrip under it, the Edit panel (histogram and sliders), the tool strip and
|
|
/// the bottom bar.
|
|
fn theme_preview(ui: &mut egui::Ui, kind: crate::theme::ThemeKind, width: f32) {
|
|
use egui::{Color32, Rect, Stroke, StrokeKind, pos2, vec2};
|
|
let p = Tokens::for_kind(kind);
|
|
let (rect, _) = ui.allocate_exact_size(vec2(width, (width * 0.56).round().min(130.0)), egui::Sense::hover());
|
|
let painter = ui.painter_at(rect);
|
|
let bar = |y: f32, x: f32, w: f32, c: Color32| painter.rect_filled(Rect::from_min_size(pos2(x, y), vec2(w, 2.0)), 1.0, c);
|
|
painter.rect_filled(rect, 3.0, p.canvas);
|
|
// top bar: sidebar and back icons, the centred search field, icons on the right
|
|
let top = Rect::from_min_size(rect.min, vec2(rect.width(), 13.0));
|
|
painter.rect_filled(top, 0.0, p.chrome);
|
|
for x in [6.0, 14.0, 20.0] {
|
|
painter.rect_filled(Rect::from_min_size(top.min + vec2(x, 4.5), vec2(4.0, 4.0)), 1.0, p.icon);
|
|
}
|
|
let search = Rect::from_center_size(top.center(), vec2(rect.width() * 0.34, 8.0));
|
|
painter.rect(search, 2.0, p.field, Stroke::new(1.0, p.field_border), StrokeKind::Inside);
|
|
bar(search.center().y - 1.0, search.center().x - 9.0, 18.0, p.text_dim);
|
|
for i in 0..4 {
|
|
painter.rect_filled(Rect::from_min_size(pos2(top.right() - 10.0 - i as f32 * 9.0, top.top() + 4.5), vec2(4.0, 4.0)), 2.0, p.icon);
|
|
}
|
|
// bottom bar: view buttons, rating stars, a button
|
|
let bottom = Rect::from_min_max(pos2(rect.left(), rect.bottom() - 12.0), rect.max);
|
|
painter.rect_filled(bottom, 0.0, p.chrome);
|
|
for x in [6.0, 13.0, 20.0] {
|
|
painter.rect_filled(Rect::from_min_size(bottom.min + vec2(x, 4.0), vec2(4.0, 4.0)), 1.0, p.icon);
|
|
}
|
|
let stars_x = bottom.center().x - 30.0;
|
|
for i in 0..5 {
|
|
painter.circle_filled(pos2(stars_x + i as f32 * 5.0, bottom.center().y), 1.4, p.star);
|
|
}
|
|
let copy = Rect::from_min_size(pos2(stars_x + 28.0, bottom.top() + 2.5), vec2(30.0, 7.0));
|
|
painter.rect(copy, 3.0, p.button, Stroke::new(1.0, p.button_border), StrokeKind::Inside);
|
|
// tool strip on the right edge, the first tool (Edit) active
|
|
let strip = Rect::from_min_max(pos2(rect.right() - 12.0, top.bottom()), pos2(rect.right(), bottom.top()));
|
|
painter.rect_filled(strip, 0.0, p.chrome);
|
|
painter.line_segment([strip.left_top(), strip.left_bottom()], Stroke::new(1.0, p.divider));
|
|
for i in 0..5 {
|
|
let r = Rect::from_min_size(pos2(strip.left() + 3.0, strip.top() + 4.0 + i as f32 * 9.0), vec2(6.0, 6.0));
|
|
if i == 0 {
|
|
painter.rect_filled(r.expand(1.5), 1.5, p.tool_active);
|
|
}
|
|
painter.rect_stroke(r, 1.0, Stroke::new(1.0, if i == 0 { p.text } else { p.icon }), StrokeKind::Inside);
|
|
}
|
|
// Edit panel: histogram, then labelled sliders
|
|
let panel = Rect::from_min_max(pos2(strip.left() - (rect.width() * 0.27).round(), top.bottom()), pos2(strip.left(), bottom.top()));
|
|
painter.rect_filled(panel, 0.0, p.chrome);
|
|
painter.line_segment([panel.left_top(), panel.left_bottom()], Stroke::new(1.0, p.divider));
|
|
let hist = Rect::from_min_size(panel.min + vec2(4.0, 4.0), vec2(panel.width() - 8.0, 14.0));
|
|
painter.rect_filled(hist, 1.0, p.inset);
|
|
let curve = [0.2, 0.55, 0.9, 0.7, 0.5, 0.62, 0.4, 0.3, 0.18, 0.1];
|
|
let mut pts = vec![hist.left_bottom()];
|
|
for (i, h) in curve.iter().enumerate() {
|
|
pts.push(pos2(hist.left() + hist.width() * i as f32 / (curve.len() - 1) as f32, hist.bottom() - hist.height() * h));
|
|
}
|
|
pts.push(hist.right_bottom());
|
|
// the area under the curve, a column per stretch between two points
|
|
for pair in pts.windows(2) {
|
|
if let [a, b] = pair {
|
|
let r = Rect::from_min_max(pos2(a.x, a.y.max(b.y)), pos2(b.x, hist.bottom()));
|
|
painter.rect_filled(r, 0.0, p.track.gamma_multiply(0.5));
|
|
}
|
|
}
|
|
painter.add(egui::Shape::line(pts, Stroke::new(1.0, p.text_dim)));
|
|
let sliders = [0.55, 0.62, 0.35, 0.7, 0.6, 0.45];
|
|
let mut y = hist.bottom() + 7.0;
|
|
bar(y, panel.left() + 5.0, 14.0, p.text);
|
|
y += 6.0;
|
|
for v in sliders {
|
|
if y + 7.0 > panel.bottom() {
|
|
break;
|
|
}
|
|
bar(y, panel.left() + 5.0, 12.0, p.text_label);
|
|
bar(y, panel.right() - 10.0, 5.0, p.text_dim);
|
|
let track_y = y + 5.0;
|
|
painter.line_segment([pos2(panel.left() + 5.0, track_y), pos2(panel.right() - 5.0, track_y)], Stroke::new(1.0, p.track));
|
|
painter.circle_filled(pos2(panel.left() + 5.0 + (panel.width() - 10.0) * v, track_y), 1.8, p.thumb);
|
|
y += 10.0;
|
|
}
|
|
// the photo (its own colours: a photo looks the same in every theme) above the filmstrip
|
|
let film = Rect::from_min_max(pos2(rect.left(), bottom.top() - 18.0), pos2(panel.left(), bottom.top()));
|
|
let stage = Rect::from_min_max(pos2(rect.left(), top.bottom()), pos2(panel.left(), film.top()));
|
|
let photo = Rect::from_center_size(stage.center(), vec2(stage.width() * 0.62, stage.height() - 8.0));
|
|
painter.rect_filled(photo, 0.0, Color32::from_rgb(0x6f, 0x8f, 0xb4));
|
|
let horizon = photo.top() + photo.height() * 0.62;
|
|
painter.add(egui::Shape::convex_polygon(
|
|
vec![
|
|
pos2(photo.left(), horizon),
|
|
pos2(photo.left() + photo.width() * 0.3, photo.top() + photo.height() * 0.3),
|
|
pos2(photo.left() + photo.width() * 0.55, horizon),
|
|
],
|
|
Color32::from_rgb(0x4a, 0x55, 0x66),
|
|
Stroke::NONE,
|
|
));
|
|
painter.add(egui::Shape::convex_polygon(
|
|
vec![
|
|
pos2(photo.left() + photo.width() * 0.35, horizon),
|
|
pos2(photo.left() + photo.width() * 0.68, photo.top() + photo.height() * 0.2),
|
|
pos2(photo.right(), horizon),
|
|
],
|
|
Color32::from_rgb(0x5a, 0x63, 0x72),
|
|
Stroke::NONE,
|
|
));
|
|
painter.rect_filled(Rect::from_min_max(pos2(photo.left(), horizon), photo.max), 0.0, Color32::from_rgb(0x3d, 0x5a, 0x3a));
|
|
painter.line_segment([film.left_top(), film.right_top()], Stroke::new(1.0, p.divider));
|
|
for i in 0..5 {
|
|
let cell = Rect::from_min_size(pos2(film.left() + 4.0 + i as f32 * 17.0, film.top() + 3.0), vec2(15.0, 12.0));
|
|
if cell.right() > film.right() - 2.0 {
|
|
break;
|
|
}
|
|
let selected = i == 1;
|
|
painter.rect_filled(cell, 0.0, if selected { p.cell_selected } else { p.cell });
|
|
let thumb = cell.shrink(2.5);
|
|
painter.rect_filled(thumb, 0.0, Color32::from_rgb(0x6f + 8 * i as u8, 0x80, 0x8f));
|
|
if selected {
|
|
painter.rect_stroke(thumb, 0.0, Stroke::new(1.0, p.pick), StrokeKind::Outside);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------- Open Library…
|
|
|
|
/// Switching libraries is refused while an import (or the folder scan before one) is running: its
|
|
/// remaining batches would be committed into the new library (issue #1509).
|
|
pub(crate) fn can_switch_library(app: &LightcraftApp) -> Result<(), String> {
|
|
if crate::lightroom_import::is_running(app) {
|
|
return Err("wait for Lightroom catalog import to finish before switching libraries".into());
|
|
}
|
|
if app.import.is_some() || app.scan.is_some() {
|
|
return Err("wait for the import to finish before switching libraries".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`,
|
|
/// or a folder chosen in a dialog. Remembered as the library to open at launch.
|
|
///
|
|
/// A Lightroom Classic catalog (its folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat`
|
|
/// itself, see [`lightcraft_engine::lightroom_folder`]) is not opened: the current library stays
|
|
/// and a dialog offers Import Lightroom Catalog… instead; returns `{opened: false,
|
|
/// lightroomCatalog: <.lrcat path or null>, path}`.
|
|
pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
|
|
can_switch_library(app)?;
|
|
let path = match p.get("path").and_then(Value::as_str) {
|
|
Some(x) => x.to_string(),
|
|
None => {
|
|
let req = crate::pick::PickRequest::folder(crate::i18n::tr("Open Library"));
|
|
match crate::pick::ask(app, "app.openLibrary", p, "path", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])) {
|
|
crate::pick::Picked::Now(v) => match v.into_iter().next() {
|
|
Some(x) => x,
|
|
None => return Ok(Value::Null),
|
|
},
|
|
crate::pick::Picked::Later => return Ok(Value::Null),
|
|
crate::pick::Picked::Unavailable => return Err("no folder dialog on this platform".into()),
|
|
}
|
|
}
|
|
};
|
|
// A Lightroom catalog folder (or part of one) is not a library: before closing the library
|
|
// open now, offer to import it instead (issue #891). The engine refuses it too.
|
|
if let Some(place) = lightcraft_engine::lightroom_folder::lightroom_catalog_at(std::path::Path::new(&path)) {
|
|
let catalog = place.catalog.map(|c| c.to_string_lossy().into_owned());
|
|
let err = lightcraft_engine::EngineError::LightroomCatalog { path: path.clone().into(), catalog: catalog.clone().map(Into::into) };
|
|
// the library problem window (no library could be opened) shows the error instead
|
|
if app.library_problem.is_some() {
|
|
return Err(err.to_string());
|
|
}
|
|
app.ui.dialog = Some(crate::state::Dialog::LightroomCatalog { folder: path.clone(), catalog: catalog.clone() });
|
|
return Ok(json!({"opened": false, "lightroomCatalog": catalog, "path": path}));
|
|
}
|
|
// a remembered library that is gone: say so (and forget it) instead of creating an empty one there
|
|
if app.ui.settings.recent_libraries.contains(&path) && !std::path::Path::new(&path).exists() {
|
|
app.ui.settings.recent_libraries.retain(|r| r != &path);
|
|
return Err(format!("{path}: the library is no longer there (moved, renamed or on a disconnected drive); removed from Open Recent Library"));
|
|
}
|
|
switch_library(app, &path)
|
|
}
|
|
|
|
/// Close the current library and open the one at `path`. If it can't be opened (locked by
|
|
/// another program, unreadable, a file), the error is returned and the current library stays open:
|
|
/// the target is checked before anything is closed, and if opening it still fails the previous
|
|
/// library is opened again (both errors are reported if that fails too).
|
|
fn switch_library(app: &mut LightcraftApp, path: &str) -> Result<Value, String> {
|
|
let dir = app.session.check_library(std::path::Path::new(path)).map_err(|e| format!("{path}: {e}"))?;
|
|
let path = dir.to_string_lossy().into_owned();
|
|
// the library being left goes in the list too (also when it was opened at launch)
|
|
let previous_dir = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
|
|
let previous = previous_dir.as_ref().map(|d| d.to_string_lossy().into_owned());
|
|
app.session.close_library().map_err(|e| e.to_string())?;
|
|
let opened = app.session.open_library(&dir, false).map(|_| ()).map_err(|e| format!("{path}: {e}"));
|
|
app.lightroom_last = None;
|
|
app.lightroom_result = None;
|
|
if matches!(app.ui.dialog.as_ref(), Some(crate::state::Dialog::LightroomResult { .. })) {
|
|
app.ui.dialog = None;
|
|
}
|
|
app.renderer.forget_all();
|
|
app.ui.compare = None;
|
|
if let Err(err) = opened {
|
|
let Some(prev) = previous_dir else { return Err(err) };
|
|
return match app.session.open_library(&prev, false) {
|
|
Ok(_) => Err(err),
|
|
Err(e) => Err(format!("{err}; reopening the previous library {} failed too: {e}", prev.display())),
|
|
};
|
|
}
|
|
app.ui.settings.library_path = path.clone();
|
|
if let Some(prev) = previous.filter(|p| p != &path && !app.ui.settings.recent_libraries.contains(p)) {
|
|
remember_library(&mut app.ui.settings.recent_libraries, &prev);
|
|
}
|
|
remember_library(&mut app.ui.settings.recent_libraries, &path);
|
|
Ok(json!({"path": path, "name": library_name(&dir), "photos": app.session.catalog.len()}))
|
|
}
|
|
|
|
/// Put `path` first in the recent list, without duplicates, capped at [`RECENT_LIBRARIES`].
|
|
pub fn remember_library(recent: &mut Vec<String>, path: &str) {
|
|
recent.retain(|r| r != path);
|
|
recent.insert(0, path.to_string());
|
|
recent.truncate(crate::state::RECENT_LIBRARIES);
|
|
}
|
|
|
|
/// A library's name as people see it: its folder's name.
|
|
pub fn library_name(dir: &std::path::Path) -> String {
|
|
dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| dir.display().to_string())
|
|
}
|
|
|
|
/// The open library's name (`None`: in memory, nothing is saved).
|
|
pub fn open_library_name(app: &LightcraftApp) -> Option<String> {
|
|
app.session.library.as_ref().filter(|l| l.on_disk).map(|l| library_name(&l.dir))
|
|
}
|
|
|
|
/// `app.newLibrary {path?, name?}`: create a library and switch to it. With `name`, the library
|
|
/// is the folder `name` inside `path` (or inside a location chosen in a dialog); without it,
|
|
/// `path` itself, or a new folder named after the default library. The folder must not hold a
|
|
/// library already, nor anything else.
|
|
pub fn new_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
|
|
can_switch_library(app)?;
|
|
let name = match p.get("name") {
|
|
None | Some(Value::Null) => None,
|
|
Some(Value::String(s)) => Some(valid_library_name(s)?),
|
|
Some(_) => return Err("app.newLibrary: name must be a string".into()),
|
|
};
|
|
// from the menu: ask for the name first (default prefilled), then for the location
|
|
if name.is_none() && p.get("path").is_none() && app.services.pick_folder.is_some() {
|
|
crate::panels::dialogs::prompt(
|
|
app,
|
|
"New Library",
|
|
"Library name",
|
|
lightcraft_engine::library::DEFAULT_NAME,
|
|
"app.newLibrary",
|
|
json!({}),
|
|
"name",
|
|
);
|
|
return Ok(Value::Null);
|
|
}
|
|
let (location, picked) = match p.get("path").and_then(Value::as_str) {
|
|
Some(x) => (std::path::PathBuf::from(x), false),
|
|
None => {
|
|
let req = crate::pick::PickRequest::folder(crate::i18n::tr("New Library: Choose Where to Put It"));
|
|
match crate::pick::ask(app, "app.newLibrary", p, "path", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])) {
|
|
crate::pick::Picked::Now(v) => match v.into_iter().next() {
|
|
Some(x) => (std::path::PathBuf::from(x), true),
|
|
None => return Ok(Value::Null),
|
|
},
|
|
crate::pick::Picked::Later => return Ok(Value::Null),
|
|
crate::pick::Picked::Unavailable => return Err("no folder dialog on this platform".into()),
|
|
}
|
|
}
|
|
};
|
|
let dir = match name {
|
|
Some(n) => location.join(n),
|
|
// a chosen location gets a new folder in it, named after the default library
|
|
None if picked => free_name(&location, lightcraft_engine::library::DEFAULT_NAME),
|
|
None => location,
|
|
};
|
|
if lightcraft_engine::library::is_library(&dir) {
|
|
return Err(format!("{}: there is a library here already; open it with File ▸ Open Library…", dir.display()));
|
|
}
|
|
if dir.is_file() {
|
|
return Err(format!("{}: is a file; a library is a folder", dir.display()));
|
|
}
|
|
if std::fs::read_dir(&dir).is_ok_and(|mut d| d.next().is_some()) {
|
|
return Err(format!("{}: the folder isn't empty; choose an empty folder or a new name", dir.display()));
|
|
}
|
|
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: can't create the library folder: {e}", dir.display()))?;
|
|
switch_library(app, &dir.to_string_lossy())
|
|
}
|
|
|
|
/// Characters a folder name can't hold on Windows (and `/`, which no system allows).
|
|
const RESERVED_NAME_CHARS: [char; 9] = ['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
|
|
|
|
fn valid_library_name(s: &str) -> Result<&str, String> {
|
|
let n = s.trim();
|
|
// Windows drops trailing dots and spaces from a folder name: refuse them rather than
|
|
// creating a folder whose name differs from the one asked for (spaces around the name
|
|
// are trimmed first)
|
|
let bad = n.is_empty() || n == "." || n == ".." || n.ends_with(['.', ' ']) || n.contains(RESERVED_NAME_CHARS) || n.chars().any(char::is_control);
|
|
if bad {
|
|
return Err(format!(
|
|
"app.newLibrary: {s:?} can't be a library name (a folder name without < > : \" / \\ | ? * and not ending in a dot or space)"
|
|
));
|
|
}
|
|
Ok(n)
|
|
}
|
|
|
|
/// `base` inside `location`, or `base 2`, `base 3`… if that is taken.
|
|
fn free_name(location: &std::path::Path, base: &str) -> std::path::PathBuf {
|
|
let first = location.join(base);
|
|
if !first.exists() {
|
|
return first;
|
|
}
|
|
(2..1000u32).map(|i| location.join(format!("{base} {i}"))).find(|p| !p.exists()).unwrap_or(first)
|
|
}
|
|
|
|
/// `app.recentLibraries {clear?: bool}`: the recently opened libraries, newest first, as
|
|
/// `[{path, name, exists, open}]` (`exists: false`: moved or on a disconnected drive). `clear`
|
|
/// forgets them all.
|
|
pub fn recent_libraries(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
|
|
if p.get("clear").and_then(Value::as_bool) == Some(true) {
|
|
app.ui.settings.recent_libraries.clear();
|
|
}
|
|
let open = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
|
|
let list: Vec<Value> = app
|
|
.ui
|
|
.settings
|
|
.recent_libraries
|
|
.iter()
|
|
.map(|r| {
|
|
let d = std::path::Path::new(r);
|
|
json!({"path": r, "name": library_name(d), "exists": d.is_dir(), "open": open.as_deref() == Some(d)})
|
|
})
|
|
.collect();
|
|
Ok(Value::Array(list))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------- Display profile
|
|
|
|
/// `app.displayProfile {path?: string | null}`: show previews through this monitor ICC profile
|
|
/// (`""` or `null`: none, the profile of the screen showing the window where the host knows it
|
|
/// (macOS), else the display is treated as sRGB) and keep it as the setting; no `path`: report
|
|
/// only. A file that can't be used is an error and changes nothing. Returns the profile in use:
|
|
/// `{path, description, kind (matrix|lut), primaries {red, green, blue: [x, y]}}`, with
|
|
/// `path: null, screen: true` for the screen's profile.
|
|
pub fn display_profile(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
|
|
let path = match p.get("path") {
|
|
None => None,
|
|
Some(Value::Null) => Some(String::new()),
|
|
Some(Value::String(s)) => Some(s.trim().to_string()),
|
|
Some(_) => return Err("app.displayProfile: path must be a string or null".into()),
|
|
};
|
|
if let Some(path) = path {
|
|
let d = lightcraft_engine::display::Display::load_opt(&path).map_err(|e| format!("app.displayProfile: {e}"))?;
|
|
app.renderer.set_display(d);
|
|
app.ui.settings.display_profile = path.clone();
|
|
app.display_applied = Some(path);
|
|
app.display_error = None;
|
|
app.screen_profile = None;
|
|
app.screen_profile_read = None;
|
|
if app.ui.settings.display_profile.is_empty() {
|
|
app.apply_screen_profile();
|
|
}
|
|
}
|
|
Ok(match app.renderer.display() {
|
|
Some(d) if d.path == crate::SCREEN_PROFILE => {
|
|
let mut v = d.describe();
|
|
v["path"] = Value::Null;
|
|
v["screen"] = json!(true);
|
|
v
|
|
}
|
|
Some(d) => d.describe(),
|
|
None => json!({"path": null, "description": "sRGB (no display profile)"}),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
fn app_with_import() -> LightcraftApp {
|
|
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default());
|
|
app.import = Some(crate::import::ImportTask::new(vec!["a.jpg".into()], json!({"mode": "add"}), 0, false));
|
|
app
|
|
}
|
|
|
|
#[test]
|
|
fn open_library_is_refused_while_an_import_runs() {
|
|
let mut app = app_with_import();
|
|
let err = open_library(&mut app, &json!({"path": "other"})).unwrap_err();
|
|
assert!(err.contains("import to finish"), "{err}");
|
|
assert!(app.import.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn new_library_is_refused_while_an_import_runs() {
|
|
let mut app = app_with_import();
|
|
let err = new_library(&mut app, &json!({"path": "other"})).unwrap_err();
|
|
assert!(err.contains("import to finish"), "{err}");
|
|
assert!(app.import.is_some());
|
|
}
|
|
|
|
/// The menu items that switch libraries are greyed out meanwhile, like during a Lightroom import.
|
|
#[test]
|
|
fn library_switching_menu_items_are_disabled_while_an_import_runs() {
|
|
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default());
|
|
app.services.pick_folder = Some(Box::new(|| None));
|
|
assert!(crate::menus::ui_enabled(&app, "app.openLibrary"));
|
|
assert!(crate::menus::ui_enabled(&app, "app.newLibrary"));
|
|
app.import = Some(crate::import::ImportTask::new(vec!["a.jpg".into()], json!({"mode": "add"}), 0, false));
|
|
assert!(!crate::menus::ui_enabled(&app, "app.openLibrary"));
|
|
assert!(!crate::menus::ui_enabled(&app, "app.newLibrary"));
|
|
}
|
|
|
|
/// The folder scan before an import's review counts too: it ends in an import into the open library.
|
|
#[test]
|
|
fn open_library_is_refused_while_an_import_scan_runs() {
|
|
let dir = std::env::temp_dir().join(format!("lc-ui-switchscan-{}", std::process::id()));
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default());
|
|
crate::import::open(&mut app, vec![dir.to_string_lossy().into_owned()]).unwrap();
|
|
assert!(app.scan.is_some());
|
|
let err = open_library(&mut app, &json!({"path": "other"})).unwrap_err();
|
|
assert!(err.contains("import to finish"), "{err}");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
}
|