diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 310cac90a..a84e926c3 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -2,775 +2,21 @@ use serde::{Deserialize, Serialize}; -#[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::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, - } - } -} - -/// 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, -} - -/// 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, - /// 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, - } - } -} -/// 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, - /// 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, - /// 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, - /// 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) -> 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 } - } -} - -/// 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, -} - -/// 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, -} +mod dialog; +mod settings; +mod view; + +pub use dialog::{Dialog, NameEdit, SyncCounts, default_preset_groups}; +pub use settings::{ + AppSettings, AppearanceMode, DarkTheme, EditFile, EditFormat, EditSpace, GridBadges, KeywordChips, LOUPE_EDGE_CEILING, LOUPE_EDGE_STEP, + LaunchLibrary, LightTheme, LightroomImport, MIN_TEXTURE_SIDE, PHOTOCRAFT, PREVIEW_LIMITS, RECENT_LIBRARIES, STANDARD_PREVIEW_EDGE, StartupView, + WINDOW_FRAME_CEILING, +}; +pub use view::{ + BRIEF_INFO_SECS, BeforeAfter, BriefInfo, CLICK_ZOOMS, CropOverlay, FOCUS_KEYWORDS_PASSES, InfoOverlay, KeywordingView, LEFT_WIDTH, LayoutImage, + LightsOut, LoupeInfo, LoupeInfoField, MIN_PHOTO_WIDTH, PanelWidth, RIGHT_WIDTH, RightPanel, THUMB_SIZE_MAX, THUMB_SIZE_MIN, THUMB_SIZE_STEP, + ToolOverlay, ViewMode, Zoom, +}; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -1108,380 +354,6 @@ pub struct UiState { pub print: crate::print_view::PrintUi, } -/// 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 { - 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, - details: Vec, - first: Option, - }, - /// 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, - #[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, - }, - /// Edit the colour label names (red, yellow, green, blue, purple; empty = the colour's name). - LabelNames { - names: Vec, - /// 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, - 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, - 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, - name: String, - slots: Vec, - /// 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, - }, - /// 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, - 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, - }, - /// 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, - name: String, - rules: lightcraft_catalog::RuleSet, - /// The folder a new smart album is created in (ignored when editing). - #[serde(default)] - parent: Option, - }, - /// `groups`: the settings groups the preset includes (`SettingsGroup` ids). - CreatePreset { - name: String, - group: String, - #[serde(default = "default_preset_groups")] - groups: Vec, - }, - CopySettings { - groups: Vec, - }, - /// Paste Selected Settings: which of the copied groups to paste. - PasteSettings { - groups: Vec, - }, - /// `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, - }, - /// 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, - }, - /// 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, - 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, - rename: Option<(String, String)>, - confirm_delete: bool, - new_group: String, - }, - /// Confirm deleting mask effect presets. - DeleteEffects { - names: Vec, - }, - /// 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, - 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, - 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 - } -} - impl Default for UiState { fn default() -> Self { UiState { @@ -1715,71 +587,8 @@ impl UiState { } } -/// 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 { - let value = serde_json::Value::deserialize(d)?; - Ok(serde_json::from_value(value).unwrap_or_default()) -} - /// A list of ids read leniently: what isn't a list is none, and what isn't text in it is left out. fn lenient_ids<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { let value = serde_json::Value::deserialize(d)?; Ok(value.as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect()) } - -fn deserialize_favorite_themes<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { - let values = Vec::::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); - } -} diff --git a/crates/ui-egui/src/state/dialog.rs b/crates/ui-egui/src/state/dialog.rs new file mode 100644 index 000000000..edc4946cd --- /dev/null +++ b/crates/ui-egui/src/state/dialog.rs @@ -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 { + 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, + details: Vec, + first: Option, + }, + /// 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, + #[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, + }, + /// Edit the colour label names (red, yellow, green, blue, purple; empty = the colour's name). + LabelNames { + names: Vec, + /// 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, + 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, + 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, + name: String, + slots: Vec, + /// 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, + }, + /// 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, + 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, + }, + /// 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, + name: String, + rules: lightcraft_catalog::RuleSet, + /// The folder a new smart album is created in (ignored when editing). + #[serde(default)] + parent: Option, + }, + /// `groups`: the settings groups the preset includes (`SettingsGroup` ids). + CreatePreset { + name: String, + group: String, + #[serde(default = "default_preset_groups")] + groups: Vec, + }, + CopySettings { + groups: Vec, + }, + /// Paste Selected Settings: which of the copied groups to paste. + PasteSettings { + groups: Vec, + }, + /// `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, + }, + /// 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, + }, + /// 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, + 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, + rename: Option<(String, String)>, + confirm_delete: bool, + new_group: String, + }, + /// Confirm deleting mask effect presets. + DeleteEffects { + names: Vec, + }, + /// 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, + 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, + 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 + } +} diff --git a/crates/ui-egui/src/state/settings.rs b/crates/ui-egui/src/state/settings.rs new file mode 100644 index 000000000..2d748f5a7 --- /dev/null +++ b/crates/ui-egui/src/state/settings.rs @@ -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, + /// 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, + /// 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, + /// 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) -> 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 { + 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, D::Error> { + let values = Vec::::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); + } +} diff --git a/crates/ui-egui/src/state/view.rs b/crates/ui-egui/src/state/view.rs new file mode 100644 index 000000000..1bbfd4349 --- /dev/null +++ b/crates/ui-egui/src/state/view.rs @@ -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::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, + /// 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, +}