refactor(lightcraft-ui-egui): split state.rs into state/

This commit is contained in:
aiongg
2026-10-26 21:39:56 +00:00
parent db31bf3e59
commit 0896aa86ce
4 changed files with 1233 additions and 1206 deletions
File diff suppressed because it is too large Load Diff
+375
View File
@@ -0,0 +1,375 @@
use super::*;
/// Settings group ids (`SettingsGroup` serde names) a new preset includes by default: everything
/// but crop, masks, spots and red eye.
pub fn default_preset_groups() -> Vec<String> {
lightcraft_develop::SettingsGroup::default_copy().iter().filter_map(|g| serde_json::to_value(g).ok()?.as_str().map(str::to_string)).collect()
}
impl Dialog {
/// A fresh Create Preset dialog.
pub fn create_preset() -> Dialog {
Dialog::CreatePreset { name: String::new(), group: "User Presets".into(), groups: default_preset_groups() }
}
}
/// A face's name being typed in the loupe: which face, and what has been typed so far.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NameEdit {
pub photo: u64,
pub index: usize,
pub text: String,
/// Just opened: the text box takes the keyboard focus once.
pub fresh: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Dialog {
/// A Lightroom import result stays readable after its activity row disappears.
LightroomResult {
summary: String,
library: Option<String>,
details: Vec<String>,
first: Option<u64>,
},
/// File ▸ Print…: the Print module. Its state is the session's (`print.*`); the page on show
/// and the selected cell are [`UiState::print`].
Print,
ContactSheet {
options: lightcraft_engine::contact_sheet::Options,
},
/// View ▸ Export Slideshow…: format, size, quality, frame rate, container.
SlideshowExport {
settings: lightcraft_engine::slideshow_export::ExportSettings,
},
/// `parent`: the folder to create it in (none: the top level). `include_selected`: the new album
/// holds the selected photos (Lightroom's "Include selected photos"; folders hold none).
NewAlbum {
name: String,
folder: bool,
#[serde(default)]
parent: Option<u64>,
#[serde(default = "yes")]
include_selected: bool,
},
RenameAlbum {
id: u64,
name: String,
},
/// One text field; OK runs `command` with `params` plus `{key: value}` (rename a preset or a
/// version, move a preset to a new group…).
TextPrompt {
title: String,
hint: String,
value: String,
command: String,
params: serde_json::Value,
key: String,
},
/// Edit Capture Time: `mode` "set" (`time`; the others shift along), "shift" (by `days`,
/// `hours`, `minutes`) or "zone" (time-zone shift by `zone` hours).
CaptureTime {
mode: String,
time: String,
days: i32,
hours: i32,
minutes: i32,
zone: f32,
},
/// The import review (File → Import Photos…).
Import {
opts: Box<crate::import::ImportDialog>,
},
/// Edit the colour label names (red, yellow, green, blue, purple; empty = the colour's name).
LabelNames {
names: Vec<String>,
/// Also save the names as a label set of this name (empty = don't).
save_as: String,
},
/// Batch rename the selected photos with a file-name template.
Rename {
template: String,
start: u32,
},
/// Rename a keyword on every photo (children included).
RenameKeyword {
from: String,
to: String,
},
/// Rename every face of `from` to `to`. `merge` is the user's yes to combining with the person
/// `to` already names.
RenamePerson {
from: String,
to: String,
merge: bool,
},
/// Create a keyword, or edit one (`editing`): Lightroom Classic's Create / Edit Keyword Tag.
KeywordTag {
/// The keyword being edited; `None`: a new one.
editing: Option<String>,
name: String,
/// A new keyword goes inside this one when `inside` is on (the keyword picked in the
/// list, or the default parent).
parent: Option<String>,
inside: bool,
/// Comma-separated.
synonyms: String,
include_on_export: bool,
export_containing: bool,
export_synonyms: bool,
person: bool,
/// A new keyword is given to the selected photos.
add_to_selected: bool,
/// Opened from the sidebar's Keywords header: the new keyword is shown there once made.
#[serde(default)]
reveal: bool,
},
/// Edit Keyword Set: a set's name and its nine slots (an empty one is an empty slot). `replaces`:
/// the set being edited (renamed when the name changes); `None` saves a new set (from Recent
/// Keywords).
KeywordSet {
replaces: Option<String>,
name: String,
slots: Vec<String>,
/// Save as a new set, leaving the edited one (Lightroom Classic's Save as New Preset).
#[serde(default)]
as_new: bool,
},
/// Move a keyword inside `parent` (`None`: the top level) where one of its name is already:
/// asks before merging the two.
MoveKeyword {
keyword: String,
parent: Option<String>,
},
/// Delete a keyword from every photo and the keyword list, after asking.
DeleteKeyword {
keyword: String,
/// Photos that have it (or one below it).
count: usize,
},
/// Merge keywords into another one on every photo.
MergeKeywords {
from: Vec<String>,
into: String,
},
/// Auto-stack by capture time: the largest gap between consecutive shots, in seconds.
AutoStack {
gap: f32,
},
/// Save the current view (source + filter) as a smart album.
NewSmartAlbum {
name: String,
#[serde(default)]
parent: Option<u64>,
},
/// Help ▸ What's New.
WhatsNew,
/// Photo ▸ Assisted Culling: reject photos below this focus score (0 = none), pick the best
/// of each burst; shots at most `burst_gap` seconds apart and at least `similarity` alike are
/// a burst.
Cull {
reject_below: f32,
pick_best: bool,
burst_gap: f32,
similarity: f32,
},
/// Help ▸ System Info: (label, value) rows.
DenoiseModel {
info: serde_json::Value,
accepted: bool,
},
/// A face model file the user chose: what it is, its licence terms, and the "I accept" box.
FaceModel {
path: String,
/// The engine's `faces.models.inspect` answer.
info: serde_json::Value,
accepted: bool,
},
SystemInfo {
rows: Vec<(String, String)>,
},
/// Every metadata field of a photo's file (`photo.allMetadata`), filtered by `search`.
AllMetadata {
title: String,
rows: serde_json::Value,
search: String,
},
/// Create (`id` None) or edit a smart album's rules.
SmartRules {
id: Option<u64>,
name: String,
rules: lightcraft_catalog::RuleSet,
/// The folder a new smart album is created in (ignored when editing).
#[serde(default)]
parent: Option<u64>,
},
/// `groups`: the settings groups the preset includes (`SettingsGroup` ids).
CreatePreset {
name: String,
group: String,
#[serde(default = "default_preset_groups")]
groups: Vec<String>,
},
CopySettings {
groups: Vec<String>,
},
/// Paste Selected Settings: which of the copied groups to paste.
PasteSettings {
groups: Vec<String>,
},
/// `resize` is used unless `full_size`; `limit_kb` 0 = no limit; `dir` empty = default export folder.
Export {
opts: lightcraft_engine::export::ExportOptions,
full_size: bool,
resize: lightcraft_engine::export::Resize,
/// Name typed for "Save as Preset".
#[serde(default)]
preset_name: String,
limit_kb: u32,
dir: String,
},
/// Photo Merge (HDR / Panorama / HDR Panorama) options; the preview lives in the app.
Merge {
opts: crate::merge::MergeDialog,
},
/// Settings (preferences): `tab` = general | import | performance | interface.
Settings {
tab: String,
},
/// Object and Describe masks need the SAM 3 model, which isn't installed: offer to download
/// it (size, licence, progress). `then`: the AI mask to start once it is there (`kind`
/// object|prompt, `op` new|add|subtract|intersect).
SamModel {
then: Option<(String, String)>,
/// Why the download couldn't start (shown in the dialog).
#[serde(default)]
error: Option<String>,
},
/// Open Library was given a Lightroom Classic catalog (issue #891): offer File ▸ Import
/// Lightroom Catalog… with `catalog` (the `.lrcat` found; `None`: ask for it) instead.
/// `folder`: what was chosen.
LightroomCatalog {
folder: String,
catalog: Option<String>,
},
/// File ▸ Import Lightroom Catalog…: where the catalog at `catalog` goes (issue #2672, see
/// [`crate::lightroom_destination`]). Merge into the open library `library` (`photos` in it), or
/// (`new_library`) create the library `name` in the folder `location`, switch to it and import
/// there. `dont_ask`: merge from now on without asking. `generation`: the library the window
/// was opened for (`Session::library_generation`): another one opened meanwhile isn't merged into.
LightroomDestination {
/// The `.lrcat` file.
catalog: String,
/// The open library's name (its folder's name).
library: String,
/// The photos in the open library.
photos: usize,
/// "Create a new library" is chosen (else "Merge into the open library").
new_library: bool,
/// The new library's name (prefilled from the catalog's file name).
name: String,
/// The folder the new library goes in (prefilled with the open library's parent folder).
location: String,
/// "Don't ask again (always merge into the open library)".
dont_ask: bool,
/// `Session::library_generation` when the window opened.
generation: u64,
},
/// Save the selected mask's sliders as a mask effect preset: which sliders (`FIELDS` names,
/// the colour as `color_sat`), whether it carries Amount and the brush settings.
SaveEffect {
name: String,
group: String,
fields: Vec<String>,
amount: bool,
brush: bool,
},
/// Manage Effect Presets: search, the selected effects, an inline rename (old, new),
/// a delete waiting for confirmation, a new group's name being typed.
ManageEffects {
search: String,
selected: Vec<String>,
rename: Option<(String, String)>,
confirm_delete: bool,
new_group: String,
},
/// Confirm deleting mask effect presets.
DeleteEffects {
names: Vec<String>,
},
/// Confirm moving photos to Recently Deleted.
ConfirmDelete {
count: usize,
},
/// Confirm moving photos dropped on a Folders row into that folder on disk
/// (`photo.moveToFolder`). `name`: the folder's name, as the question says it.
MovePhotos {
ids: Vec<u64>,
folder: String,
name: String,
dont_ask: bool,
/// The library the photos were dropped in (`Session::library_generation`): ids are
/// numbered per library, so in another one they name other photos.
library: u64,
},
/// Confirm taking a folder's photos out of the library (`library.removeFolder`).
RemoveFolder {
path: String,
/// What the question calls it (a folder's last two names, a disk's name).
name: String,
count: usize,
/// A whole disk or share (`library.removeFolder` takes it only on request).
disk: bool,
},
/// Synchronize Folder: what changed in a library folder on disk, and what to do about it
/// (`folder.synchronize`; the scan runs in [`crate::sync`]).
SynchronizeFolder {
path: String,
/// What the dialog calls it (a folder's last two names).
name: String,
/// A whole disk, or a folder holding disks (`folder.synchronize` `disk`).
#[serde(default)]
disk: bool,
/// What the scan found (`None` while it runs).
counts: Option<SyncCounts>,
import_new: bool,
#[serde(default = "yes")]
relink_moved: bool,
remove_missing: bool,
read_metadata: bool,
},
About,
Shortcuts,
/// View ▸ View Options (⌘J): the info overlay and the lines of Info 1 and Info 2. Changes apply
/// as they are made.
ViewOptions,
}
fn yes() -> bool {
true
}
/// How many changes a Synchronize Folder scan found, by kind.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SyncCounts {
/// The folder isn't there (moved, renamed, or on a disk that isn't connected).
#[serde(default)]
pub offline: bool,
pub new: usize,
pub duplicates: usize,
pub unreadable: usize,
pub missing: usize,
pub metadata: usize,
#[serde(default)]
pub moved: usize,
}
impl SyncCounts {
/// The folder is there and the scan found nothing to bring in, relink, remove or read: the
/// dialog only says so (files it skipped are not changes).
pub fn up_to_date(&self) -> bool {
!self.offline && self.new + self.moved + self.missing + self.metadata == 0
}
}
+464
View File
@@ -0,0 +1,464 @@
use super::view::LoupeInfo;
#[cfg(test)]
use super::view::{InfoOverlay, LoupeInfoField};
use super::*;
/// Which view the app opens in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum StartupView {
/// Whatever was showing at quit.
#[default]
Last,
Grid,
Detail,
}
/// What the desktop app opens at launch (Settings ▸ General ▸ At launch; issue #1290). `--library`
/// and `LIGHTCRAFT_LIBRARY` come first either way.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LaunchLibrary {
/// The library open when the app last quit (holding Alt/Option as the window appears asks once).
#[default]
Last,
/// A window lists the recent libraries to choose from.
Ask,
}
/// When grid cells show their rating / flag / edited badges.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GridBadges {
/// On hover, on the selection, and on rated or flagged photos.
#[default]
Auto,
Always,
Never,
}
/// How many libraries File ▸ Open Recent Library remembers.
pub const RECENT_LIBRARIES: usize = 8;
/// Whether the interface uses the dark or the light theme (Settings ▸ Interface ▸ Appearance).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AppearanceMode {
/// Follow the operating system's dark/light setting (dark when it gives no answer).
Auto,
/// The dark theme, whatever the system says (the default: LightCraft's look so far).
#[default]
Dark,
Light,
}
impl AppearanceMode {
/// The header button's order: Auto, Light, Dark, then Auto again.
pub fn next(self) -> AppearanceMode {
match self {
AppearanceMode::Auto => AppearanceMode::Light,
AppearanceMode::Light => AppearanceMode::Dark,
AppearanceMode::Dark => AppearanceMode::Auto,
}
}
}
/// The theme the dark appearance uses.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DarkTheme {
#[default]
Charcoal,
Midnight,
}
/// The theme the light appearance uses.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LightTheme {
#[default]
Silver,
Paper,
}
impl DarkTheme {
pub const ALL: [DarkTheme; 2] = [DarkTheme::Charcoal, DarkTheme::Midnight];
pub fn kind(self) -> crate::theme::ThemeKind {
match self {
DarkTheme::Charcoal => crate::theme::ThemeKind::Charcoal,
DarkTheme::Midnight => crate::theme::ThemeKind::Midnight,
}
}
}
impl LightTheme {
pub const ALL: [LightTheme; 2] = [LightTheme::Silver, LightTheme::Paper];
pub fn kind(self) -> crate::theme::ThemeKind {
match self {
LightTheme::Silver => crate::theme::ThemeKind::Silver,
LightTheme::Paper => crate::theme::ThemeKind::Paper,
}
}
}
/// App-level preferences (Settings dialog). They belong to the app, not a library, and are
/// saved with the UI state in the app's config folder (`ui.json`, key `settings`); library
/// preferences (import defaults, XMP, cache size) live in the library's `prefs.json`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct AppSettings {
/// Library opened at launch when no `--library` is given (empty = the default location).
pub library_path: String,
/// Libraries opened recently, newest first (File ▸ Open Recent Library; at most
/// [`RECENT_LIBRARIES`]).
pub recent_libraries: Vec<String>,
/// When each recent library was last opened (path → the session clock's ISO 8601 time), for
/// the launch window. A separate key so files from earlier versions, and earlier versions
/// reading this one, keep their list (issue #1290).
#[serde(rename = "recentLibrariesOpened", deserialize_with = "lenient")]
pub recent_opened: std::collections::BTreeMap<String, String>,
/// Open the last library at launch, or ask (a value this version doesn't know reads as the
/// default).
#[serde(deserialize_with = "lenient")]
pub at_launch: LaunchLibrary,
pub startup_view: StartupView,
/// Ask before moving photos to Recently Deleted. Saved under a new key: earlier versions saved
/// `confirmDelete: false` by default (#1239).
#[serde(rename = "askBeforeDelete")]
pub confirm_delete: bool,
/// Ask before photos dropped on a Folders row move there on disk (the dialog's "Don't ask
/// again" turns it off).
pub ask_before_move: bool,
/// A click on the dimmed backdrop around a dialog closes it, like Esc (off: a stray click
/// never throws away a dialog's choices).
pub close_dialogs_outside: bool,
/// Photos and folders dropped on the window import at once, without the import review.
pub import_dropped_directly: bool,
/// GPU rendering allowed (`app.gpu`).
pub gpu: bool,
/// Largest long edge (pixels) the user lets the loupe render at; 0 = Automatic (the size it is
/// drawn at, up to the photo's own pixels and [`LOUPE_EDGE_CEILING`]). Not the old
/// `previewEdge` key: its 2560 px default was the soft-image bug (issue #323).
pub preview_limit: u32,
/// Edit in Other Editor: the application ("" = the system's default for the file type).
pub external_editor: String,
/// The file Edit in Other Editor hands over.
pub external_file: EditFile,
/// Edit in PhotoCraft: where PhotoCraft is ("" = found where its installer put it; a portable
/// copy needs its path).
pub photocraft_path: String,
/// The file Edit in PhotoCraft hands over.
pub photocraft_file: EditFile,
/// Memory the caches may hold together, in MB (0 = automatic; `app.memoryBudget`).
pub memory_mb: u32,
/// The Info panel's metadata fields with several photos selected: true (the default) shows the
/// values the selected photos share and marks each field where they differ (Selected Photos),
/// false shows the active photo's values (Target Photo) (`view.infoSelectedPhotos`).
pub info_selected_photos: bool,
/// Filmstrip: file names above the thumbnails.
pub film_names: bool,
/// Filmstrip: rating / flag / edited badges on the thumbnails.
pub film_badges: bool,
/// Grid: when to show the rating / flag / edited badges.
pub grid_badges: GridBadges,
/// View ▸ View Options ▸ Show message when loading or rendering photos: the loupe says
/// "Rendering…" while the photo it shows is not the finished render.
pub loupe_messages: bool,
/// Shortcuts the user changed (Help ▸ Keyboard Shortcuts): command id → shortcut, `""` = none.
pub keymap: crate::shortcuts::Keymap,
/// Auto (follow the system), Dark or Light.
pub appearance_mode: AppearanceMode,
/// The theme of the dark appearance.
pub dark_theme: DarkTheme,
/// The theme of the light appearance.
pub light_theme: LightTheme,
/// Theme shortcuts in the appearance context menu.
#[serde(deserialize_with = "deserialize_favorite_themes")]
pub favorite_themes: Vec<String>,
/// The monitor's ICC profile previews are shown through (`app.displayProfile`; "" = none:
/// the display is treated as sRGB).
pub display_profile: String,
/// The Keywording box: whole keyword paths, or grouped by the keyword they are inside.
#[serde(deserialize_with = "lenient")]
pub keyword_chips: KeywordChips,
/// View ▸ Focus Point: draw the box the camera focused on (`view.focusPoint`).
pub focus_point: bool,
/// View ▸ Zoom to Focus Point: Toggle Zoom (Z) centres the zoomed view on the camera's focus point
/// when the photo has one (`view.zoomToFocus`).
pub zoom_to_focus: bool,
/// View ▸ View Options: the lines the loupe's info overlay shows in Info 1 and Info 2.
#[serde(deserialize_with = "lenient")]
pub loupe_info: LoupeInfo,
/// File ▸ Import Lightroom Catalog…: ask whether the catalog merges into the open library or
/// gets a new one, or always merge (issue #2672; the window's "Don't ask again" picks that).
#[serde(deserialize_with = "lenient")]
pub lightroom_import: LightroomImport,
}
/// Where File ▸ Import Lightroom Catalog… puts a catalog (Settings ▸ General ▸ Library). Saved as
/// `"ask"` / `"alwaysMerge"`; anything else in a hand-edited `ui.json` reads as [`Self::Ask`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LightroomImport {
/// A window asks each time: merge into the open library, or create a new library for it.
#[default]
Ask,
/// Merge into the open library without asking (how imports worked before the window).
AlwaysMerge,
}
impl Default for AppSettings {
fn default() -> Self {
AppSettings {
library_path: String::new(),
recent_libraries: Vec::new(),
recent_opened: Default::default(),
at_launch: LaunchLibrary::Last,
startup_view: StartupView::Last,
confirm_delete: true,
ask_before_move: true,
close_dialogs_outside: false,
import_dropped_directly: false,
gpu: true,
preview_limit: 0,
external_editor: String::new(),
external_file: EditFile::default(),
photocraft_path: String::new(),
photocraft_file: EditFile { format: EditFormat::Psd, ..EditFile::default() },
memory_mb: 0,
info_selected_photos: true,
film_names: true,
film_badges: true,
grid_badges: GridBadges::Auto,
loupe_messages: true,
keymap: Default::default(),
appearance_mode: AppearanceMode::Dark,
dark_theme: DarkTheme::Charcoal,
light_theme: LightTheme::Silver,
favorite_themes: Vec::new(),
display_profile: String::new(),
keyword_chips: KeywordChips::default(),
focus_point: false,
zoom_to_focus: true,
loupe_info: LoupeInfo::default(),
lightroom_import: LightroomImport::Ask,
}
}
}
/// The editor name that means PhotoCraft (Photo ▸ Edit In ▸ Edit in PhotoCraft): the host finds
/// where it is installed.
pub const PHOTOCRAFT: &str = "PhotoCraft";
/// The file format Edit In renders the copy as.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum EditFormat {
#[default]
Tiff,
/// A flat PSD: PhotoCraft saves back to it in place (a TIFF it saves only through Save As).
Psd,
}
/// The colour space of the Edit In copy.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum EditSpace {
#[default]
AdobeRgb,
ProPhoto,
DisplayP3,
Srgb,
}
/// The file Photo ▸ Edit In renders for an editor (Settings ▸ External Editing).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct EditFile {
pub format: EditFormat,
pub color_space: EditSpace,
/// 16 or 8.
pub bit_depth: u8,
}
impl Default for EditFile {
fn default() -> Self {
EditFile { format: EditFormat::Tiff, color_space: EditSpace::AdobeRgb, bit_depth: 16 }
}
}
impl EditFile {
/// The `photo.editExternal` params for this file.
pub fn params(&self) -> serde_json::Value {
let format = match self.format {
EditFormat::Tiff => "tiff",
EditFormat::Psd => "psd",
};
let space = match self.color_space {
EditSpace::AdobeRgb => "adobeRgb",
EditSpace::ProPhoto => "proPhoto",
EditSpace::DisplayP3 => "displayP3",
EditSpace::Srgb => "srgb",
};
serde_json::json!({"format": format, "colorSpace": space, "bitDepth": if self.bit_depth == 8 { 8 } else { 16 }})
}
}
/// Preview size limits offered in Settings → Performance (0 = Automatic).
pub const PREVIEW_LIMITS: [u32; 5] = [0, 1600, 2560, 3840, 5120];
/// The most the loupe ever renders in one go (memory and GPU texture size), whatever the setting.
pub const LOUPE_EDGE_CEILING: u32 = 8192;
/// The most a zoomed frame (the one a window is cut from) may be along its long edge: far more
/// than the photos there are (a window holds only what is on screen, so the frame costs nothing).
pub const WINDOW_FRAME_CEILING: usize = 65536;
/// The smallest GPU texture side the loupe plans for, whatever the host reports.
pub const MIN_TEXTURE_SIDE: usize = 512;
/// Loupe render sizes move in steps of this many pixels.
pub const LOUPE_EDGE_STEP: usize = 64;
/// Long edge of Build Standard-Sized Previews when the limit is Automatic.
pub const STANDARD_PREVIEW_EDGE: u32 = 2560;
impl AppSettings {
/// The theme to show: the light theme in Light mode, or in Auto when `system` is light; the
/// dark theme otherwise (Auto with no answer from the system included).
pub fn theme(&self, system: Option<egui::Theme>) -> crate::theme::ThemeKind {
let light = match self.appearance_mode {
AppearanceMode::Light => true,
AppearanceMode::Dark => false,
AppearanceMode::Auto => system == Some(egui::Theme::Light),
};
if light { self.light_theme.kind() } else { self.dark_theme.kind() }
}
/// Pick `kind` and fix the mode to its family (the View ▸ Theme items).
pub fn select_theme(&mut self, kind: crate::theme::ThemeKind) {
use crate::theme::ThemeKind;
match kind {
ThemeKind::Charcoal => self.dark_theme = DarkTheme::Charcoal,
ThemeKind::Midnight => self.dark_theme = DarkTheme::Midnight,
ThemeKind::Silver => self.light_theme = LightTheme::Silver,
ThemeKind::Paper => self.light_theme = LightTheme::Paper,
}
self.appearance_mode = if kind.is_dark() { AppearanceMode::Dark } else { AppearanceMode::Light };
}
/// Long edge (pixels) to render the loupe at when it is drawn `wanted_px` wide on screen:
/// that size (rounded up to [`LOUPE_EDGE_STEP`], so resizing a window doesn't re-render at
/// every pixel), never above the photo's own `native_long_edge`, the user's limit, the ceiling
/// or `texture_side`, the largest texture the GPU allows (egui_glow, i.e. the browser build,
/// panics on a bigger one, and many WebGL devices allow only 2048 or 4096).
pub fn loupe_edge(&self, wanted_px: f32, native_long_edge: usize, texture_side: usize) -> usize {
let limit = if self.preview_limit == 0 { LOUPE_EDGE_CEILING } else { self.preview_limit.clamp(512, LOUPE_EDGE_CEILING) } as usize;
let ceiling = limit.min(texture_side.max(MIN_TEXTURE_SIDE));
let wanted = if wanted_px.is_nan() { 8.0 } else { wanted_px.clamp(8.0, ceiling as f32) } as usize;
let stepped = wanted.div_ceil(LOUPE_EDGE_STEP) * LOUPE_EDGE_STEP;
stepped.min(native_long_edge.max(8)).min(ceiling)
}
/// Long edge for warming a neighbouring photo: its loupe size but never above the preview
/// source level. A full-size decode would replace the open photo's single full-resolution
/// source in the cache, and nothing is gained by it.
pub fn prefetch_edge(&self, wanted_px: f32, native_long_edge: usize, texture_side: usize) -> usize {
self.loupe_edge(wanted_px, native_long_edge, texture_side).min(lightcraft_engine::SourceLevel::Preview.max_edge())
}
/// Long edge for the hover (preset / profile) and Before renders, which are stand-ins drawn
/// over the loupe: capped at the preview source level like the old default.
pub fn stand_in_edge(&self, loupe_edge: usize, texture_side: usize) -> usize {
loupe_edge.min(lightcraft_engine::SourceLevel::Preview.max_edge()).min(texture_side.max(MIN_TEXTURE_SIDE))
}
/// Long edge of the zoomed frame a window render is cut from, for a photo drawn `drawn_long`
/// px along its long edge: as drawn, but never above the photo's own pixels (beyond 100 % the
/// GPU magnifies the window) or the ceiling. The preview size limit is not applied: it is about
/// the whole-frame render (see [`crate::region::plan`]).
pub fn window_frame_edge(&self, drawn_long: f32, native_long_edge: usize) -> usize {
let wanted = if drawn_long.is_nan() { 8.0 } else { drawn_long.clamp(8.0, WINDOW_FRAME_CEILING as f32) } as usize;
wanted.min(native_long_edge.max(8)).min(WINDOW_FRAME_CEILING)
}
/// The edge Build Standard-Sized Previews uses.
pub fn standard_preview_edge(&self) -> u32 {
if self.preview_limit == 0 { STANDARD_PREVIEW_EDGE } else { self.preview_limit }
}
}
/// How the Keywording box lays out the selection's keywords.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum KeywordChips {
/// One chip per keyword, each with its whole path ("Places › Portugal › Lisbon").
#[default]
Paths,
/// Keywords inside the same keyword share a line naming it, and their chips say only what is
/// below it; long lists and the suggestions are shortened.
Grouped,
}
/// A setting read leniently: a value that isn't one of its own (a hand-edited file, a newer
/// build's choice) is the default, and the rest of the settings file is still read.
fn lenient<'de, D: serde::Deserializer<'de>, T: Default + serde::de::DeserializeOwned>(d: D) -> Result<T, D::Error> {
let value = serde_json::Value::deserialize(d)?;
Ok(serde_json::from_value(value).unwrap_or_default())
}
fn deserialize_favorite_themes<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
let values = Vec::<String>::deserialize(d)?;
let mut seen = std::collections::BTreeSet::new();
for id in &values {
if !crate::theme::ThemeKind::ALL.iter().any(|k| k.id() == id) || !seen.insert(id) {
return Err(serde::de::Error::custom("unknown or duplicate favorite theme"));
}
}
Ok(values)
}
#[cfg(test)]
mod loupe_info_tests {
use super::*;
/// The lines chosen in View Options are saved with the app's settings; a settings file from
/// before they existed, or with a line this build doesn't know, gets the default lines and
/// keeps the rest of its settings.
#[test]
fn view_options_lines_are_saved_with_the_settings() {
let mut settings = AppSettings::default();
settings.loupe_info.info2 = [LoupeInfoField::Lens, LoupeInfoField::None, LoupeInfoField::FileName];
settings.loupe_info.briefly = InfoOverlay::Exposure;
let saved = serde_json::to_value(&settings).unwrap();
assert_eq!(saved["loupeInfo"]["info2"], serde_json::json!(["lens", "none", "fileName"]));
assert_eq!(saved["loupeInfo"]["briefly"], "exposure");
let read: AppSettings = serde_json::from_value(saved).unwrap();
assert_eq!(read.loupe_info, settings.loupe_info);
let old: AppSettings = serde_json::from_value(serde_json::json!({"filmNames": false})).unwrap();
assert_eq!(old.loupe_info, LoupeInfo::default());
// lines saved before Show Briefly When Photo Changes existed are kept, with it off
let lines: AppSettings = serde_json::from_value(serde_json::json!({"loupeInfo": {"info2": ["lens", "none", "fileName"]}})).unwrap();
assert_eq!(lines.loupe_info.info2, settings.loupe_info.info2);
assert_eq!(lines.loupe_info.briefly, InfoOverlay::Off);
let newer: AppSettings =
serde_json::from_value(serde_json::json!({"filmNames": false, "loupeInfo": {"info1": ["rating", "fileName", "none"]}})).unwrap();
assert_eq!(newer.loupe_info, LoupeInfo::default());
assert!(!newer.film_names, "the rest of the settings are still read");
}
/// Show message when loading or rendering photos is on until it is turned off, and is saved
/// with the app's settings.
#[test]
fn the_loading_message_choice_is_saved_with_the_settings() {
assert!(AppSettings::default().loupe_messages);
let old: AppSettings = serde_json::from_value(serde_json::json!({"filmNames": false})).unwrap();
assert!(old.loupe_messages, "a settings file from before the choice existed keeps the message");
let settings = AppSettings { loupe_messages: false, ..AppSettings::default() };
let saved = serde_json::to_value(&settings).unwrap();
assert_eq!(saved["loupeMessages"], false);
let read: AppSettings = serde_json::from_value(saved).unwrap();
assert!(!read.loupe_messages);
}
}
+379
View File
@@ -0,0 +1,379 @@
use super::*;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ViewMode {
/// Justified rows ("Photo Grid").
#[default]
PhotoGrid,
SquareGrid,
Detail,
/// Two photos side by side (select | candidate), synced zoom.
Compare,
/// The selected photos tiled.
Survey,
/// A reference photo (left, fixed) beside the active photo (right, being edited).
Reference,
/// A card per person named on faces (close-up, name, photo count).
People,
}
/// The right-hand tool/panel shown next to the tool strip.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RightPanel {
#[default]
None,
Edit,
Crop,
Remove,
Masking,
RedEye,
Versions,
Activity,
Keywords,
Info,
/// The profile browser (part of Edit).
Profiles,
}
impl RightPanel {
pub fn is_edit_tool(self) -> bool {
matches!(self, RightPanel::Edit | RightPanel::Profiles | RightPanel::Crop | RightPanel::Remove | RightPanel::Masking | RightPanel::RedEye)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Zoom {
#[default]
Fit,
Fill,
/// 100 % = one image pixel per physical screen pixel.
Percent(f32),
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BeforeAfter {
#[default]
Off,
/// Hold `\`: show the original.
Original,
SideBySide,
Split,
/// Before above after.
TopBottom,
/// One image split horizontally: before above the line.
SplitTopBottom,
}
impl BeforeAfter {
/// One of the Before / After layouts (two views or a split line), which the bottom bar's
/// Before/After (Y) button stands for. Show Original (`\`) is a view of its own, not a layout.
pub fn is_layout(self) -> bool {
!matches!(self, BeforeAfter::Off | BeforeAfter::Original)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CropOverlay {
#[default]
Thirds,
Grid,
Golden,
Diagonal,
Triangle,
Spiral,
None,
}
/// Tool Overlay: when the retouching tools' pins (Remove / Heal / Clone spots, mask pins) show on
/// the photo. Shift+H steps through the modes in this order (`view.toolOverlay`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ToolOverlay {
/// While the pointer is over the photo (hidden while a new spot or brush stroke is painted).
#[default]
Auto,
Always,
/// Only the selected spot's or mask's.
Selected,
/// None; the tool still works (a click still adds a spot).
Never,
}
impl ToolOverlay {
pub const ALL: [ToolOverlay; 4] = [ToolOverlay::Auto, ToolOverlay::Always, ToolOverlay::Selected, ToolOverlay::Never];
/// The id used by `view.toolOverlay {mode}` and the saved settings.
pub fn name(self) -> &'static str {
match self {
ToolOverlay::Auto => "auto",
ToolOverlay::Always => "always",
ToolOverlay::Selected => "selected",
ToolOverlay::Never => "never",
}
}
pub fn parse(s: &str) -> Option<ToolOverlay> {
ToolOverlay::ALL.into_iter().find(|m| m.name().eq_ignore_ascii_case(s.trim()))
}
/// Menu label.
pub fn label(self) -> &'static str {
match self {
ToolOverlay::Auto => "Auto",
ToolOverlay::Always => "Always",
ToolOverlay::Selected => "Selected",
ToolOverlay::Never => "Never",
}
}
/// The mode Shift+H steps to.
pub fn next(self) -> ToolOverlay {
match self {
ToolOverlay::Auto => ToolOverlay::Always,
ToolOverlay::Always => ToolOverlay::Selected,
ToolOverlay::Selected => ToolOverlay::Never,
ToolOverlay::Never => ToolOverlay::Auto,
}
}
/// Whether a pin shows: `selected` = its spot or mask is the selected one, `live` = the pointer
/// is over the photo (or a pin is being dragged), which is what Auto waits for.
pub fn shows(self, selected: bool, live: bool) -> bool {
match self {
ToolOverlay::Auto => live,
ToolOverlay::Always => true,
ToolOverlay::Selected => selected,
ToolOverlay::Never => false,
}
}
}
/// The loupe's info overlay (Cmd+I cycles; I in the full-screen preview).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum InfoOverlay {
#[default]
Off,
/// File name, capture date and dimensions.
Basic,
/// File name, camera, lens and exposure (shutter, aperture, ISO, focal length).
Exposure,
}
impl InfoOverlay {
pub fn next(self) -> InfoOverlay {
match self {
InfoOverlay::Off => InfoOverlay::Basic,
InfoOverlay::Basic => InfoOverlay::Exposure,
InfoOverlay::Exposure => InfoOverlay::Off,
}
}
}
/// One line of the loupe's info overlay, chosen in View ▸ View Options (Lightroom Classic's Loupe
/// Info 1 / Loupe Info 2). A line with nothing to show for a photo is left out.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LoupeInfoField {
#[default]
None,
FileName,
/// The capture date and time (the import date when the photo has none).
CaptureDate,
/// Width × height in pixels.
Dimensions,
/// Shutter speed, aperture, ISO and focal length.
Exposure,
Camera,
Lens,
/// Camera and lens on one line.
CameraAndLens,
}
impl LoupeInfoField {
/// Every field, in the order View Options lists them.
pub const ALL: &'static [LoupeInfoField] = &[
LoupeInfoField::None,
LoupeInfoField::FileName,
LoupeInfoField::CaptureDate,
LoupeInfoField::Dimensions,
LoupeInfoField::Exposure,
LoupeInfoField::Camera,
LoupeInfoField::Lens,
LoupeInfoField::CameraAndLens,
];
pub fn label(self) -> &'static str {
match self {
LoupeInfoField::None => "None",
LoupeInfoField::FileName => "File Name",
LoupeInfoField::CaptureDate => "Capture Date",
LoupeInfoField::Dimensions => "Dimensions",
LoupeInfoField::Exposure => "Exposure",
LoupeInfoField::Camera => "Camera",
LoupeInfoField::Lens => "Lens",
LoupeInfoField::CameraAndLens => "Camera & Lens",
}
}
}
/// The three lines each mode of the info overlay shows: Info 1 ([`InfoOverlay::Basic`]) and
/// Info 2 ([`InfoOverlay::Exposure`]), and which of them Show Briefly When Photo Changes shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct LoupeInfo {
pub info1: [LoupeInfoField; 3],
pub info2: [LoupeInfoField; 3],
/// Show Briefly When Photo Changes: while the info overlay is off, this Info shows for
/// [`BRIEF_INFO_SECS`] each time the loupe changes to another photo ([`InfoOverlay::Off`]:
/// neither).
pub briefly: InfoOverlay,
}
/// Passes after Add Keywords within which the Add keyword box takes the cursor (the panel may
/// need a pass to open first).
pub const FOCUS_KEYWORDS_PASSES: u64 = 2;
/// How long Show Briefly When Photo Changes shows the info overlay (seconds).
pub const BRIEF_INFO_SECS: f64 = 3.0;
/// When the loupe last changed photo, for Show Briefly When Photo Changes
/// ([`LoupeInfo::briefly`]).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct BriefInfo {
/// The photo the loupe showed last.
pub photo: Option<lightcraft_catalog::PhotoId>,
/// When the loupe changed to it (seconds on egui's clock).
pub since: f64,
/// The brief info overlay shows this frame.
pub shown: bool,
}
impl LoupeInfo {
/// Info 1's lines before they are changed (and after Use Defaults).
pub const INFO1: [LoupeInfoField; 3] = [LoupeInfoField::FileName, LoupeInfoField::CaptureDate, LoupeInfoField::Dimensions];
/// Info 2's lines before they are changed (and after Use Defaults).
pub const INFO2: [LoupeInfoField; 3] = [LoupeInfoField::FileName, LoupeInfoField::Exposure, LoupeInfoField::CameraAndLens];
}
impl Default for LoupeInfo {
fn default() -> Self {
LoupeInfo { info1: LoupeInfo::INFO1, info2: LoupeInfo::INFO2, briefly: InfoOverlay::Off }
}
}
/// View ▸ Loupe Overlay ▸ Layout Image (Lightroom Classic's loupe overlay): a graphic with
/// transparency, such as a magazine cover's masthead and cover lines, drawn over the photo in the
/// loupe (`panels::layout_overlay`). Saved with the UI state, so the layout stays chosen across
/// launches.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct LayoutImage {
/// The graphic's file; empty until one is chosen.
pub path: String,
/// Drawn over the loupe (⌥⌘O).
pub show: bool,
/// The graphic's opacity, 0..=1.
pub opacity: f32,
/// How dark the photo is outside the graphic's box, 0..=1 (0: not darkened).
pub matte: f32,
/// Bumped by each Choose Layout Image…, so choosing the same file again reads it again (after
/// it was edited elsewhere).
#[serde(skip)]
pub revision: u32,
}
impl Default for LayoutImage {
fn default() -> Self {
LayoutImage { path: String::new(), show: false, opacity: 1.0, matte: 0.0, revision: 0 }
}
}
impl LayoutImage {
/// The opacity and matte steps View ▸ Loupe Overlay offers.
pub const OPACITY_STEPS: [f32; 4] = [0.25, 0.5, 0.75, 1.0];
pub const MATTE_STEPS: [f32; 4] = [0.0, 0.25, 0.5, 0.75];
}
/// Lights Out (View ▸ Lights Out, Shift+L cycles On → Dim → Off → On; Esc turns the lights back
/// on): everything but the photo is veiled in black, the panels stay where they are.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LightsOut {
#[default]
On,
/// The interface at 20 % brightness.
Dim,
/// The interface black.
Off,
}
impl LightsOut {
pub fn next(self) -> LightsOut {
match self {
LightsOut::On => LightsOut::Dim,
LightsOut::Dim => LightsOut::Off,
LightsOut::Off => LightsOut::On,
}
}
/// Opacity of the black veil over everything but the photo.
pub fn veil(self) -> f32 {
match self {
LightsOut::On => 0.0,
LightsOut::Dim => 0.8,
LightsOut::Off => 1.0,
}
}
}
/// Click-zoom ratios offered (percent): 1:1, 2:1, 3:1, 4:1, 8:1.
pub const CLICK_ZOOMS: [u32; 5] = [100, 200, 300, 400, 800];
/// Grid thumbnail size (points): the bottom-bar slider's range, and the `=` / `-` keys' step
/// (`view.thumbSizeUp` / `view.thumbSizeDown`), about a thirteenth of the range.
pub const THUMB_SIZE_MIN: f32 = 90.0;
pub const THUMB_SIZE_MAX: f32 = 480.0;
pub const THUMB_SIZE_STEP: f32 = 30.0;
/// Width limits of a side panel the user resizes (points).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PanelWidth {
pub min: f32,
pub default: f32,
pub max: f32,
}
impl PanelWidth {
/// `w` within the limits (the default when it isn't a number).
pub fn clamp(&self, w: f32) -> f32 {
if w.is_finite() { w.clamp(self.min, self.max) } else { self.default }
}
}
/// The left sidebar (sources, albums, folders).
pub const LEFT_WIDTH: PanelWidth = PanelWidth { min: 200.0, default: 268.0, max: 480.0 };
/// The right panel (Edit, Masking, Info, …).
pub const RIGHT_WIDTH: PanelWidth = PanelWidth { min: 250.0, default: 270.0, max: 520.0 };
/// The photo area the side panels always leave free (as far as their minimum widths allow).
pub const MIN_PHOTO_WIDTH: f32 = 360.0;
/// What the Keywording box shows: the keywords (chips to edit), them with the keywords containing
/// them, or what exported files will carry (read only).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum KeywordingView {
#[default]
Keywords,
Containing,
WillExport,
}