mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
# Conflicts: # crates/ui-egui/locales/de-formats.json # crates/ui-egui/locales/es-formats.json # crates/ui-egui/locales/fr-formats.json # crates/ui-egui/locales/ja-formats.json # crates/ui-egui/locales/pt-br-formats.json # crates/ui-egui/locales/ru-formats.json # crates/ui-egui/locales/uk-formats.json # crates/ui-egui/locales/zh-hans-formats.json # crates/ui-egui/locales/zh-hant-formats.json # crates/ui-egui/src/lib.rs # docs/parity-checklist.md
1475 lines
56 KiB
Rust
1475 lines
56 KiB
Rust
//! UI state (serde: saved as preferences, readable/settable through the control channel).
|
||
|
||
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> {
|
||
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,
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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<String>,
|
||
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,
|
||
/// 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,
|
||
/// 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,
|
||
/// 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,
|
||
}
|
||
|
||
impl Default for AppSettings {
|
||
fn default() -> Self {
|
||
AppSettings {
|
||
library_path: String::new(),
|
||
recent_libraries: Vec::new(),
|
||
startup_view: StartupView::Last,
|
||
confirm_delete: true,
|
||
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,
|
||
film_names: true,
|
||
film_badges: true,
|
||
grid_badges: GridBadges::Auto,
|
||
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(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 }
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
pub struct UiState {
|
||
/// Shared panel arrangement; absent in legacy settings until their first layout.
|
||
pub docking: Option<crate::docking::Workspace>,
|
||
#[serde(default = "crate::i18n::default_language")]
|
||
pub language: crate::i18n::Locale,
|
||
/// The Build Previews run last announced (its identity, finished?).
|
||
#[serde(skip)]
|
||
pub preview_build_seen: Option<(usize, bool)>,
|
||
/// Saving the library is failing (announced with a toast; the top bar shows a warning until
|
||
/// a later save succeeds).
|
||
#[serde(skip)]
|
||
pub unsaved_seen: bool,
|
||
pub view: ViewMode,
|
||
pub left_panel: bool,
|
||
pub right: RightPanel,
|
||
/// Widths of the left sidebar and of the right panel in points (dragging their inner edge
|
||
/// resizes them; within [`LEFT_WIDTH`] / [`RIGHT_WIDTH`]).
|
||
pub left_width: f32,
|
||
pub right_width: f32,
|
||
/// Presets column open (opens to the left of the Edit panel).
|
||
pub presets: bool,
|
||
/// Presets column: show a live thumbnail of the photo with each preset.
|
||
pub preset_thumbs: bool,
|
||
pub filmstrip: bool,
|
||
pub zoom: Zoom,
|
||
/// Pan offset of the loupe when zoomed (image-normalized centre).
|
||
pub pan: (f32, f32),
|
||
/// Zoom applied by a click on the image (and Z / Space), in percent (100 = 1:1, 200 = 2:1);
|
||
/// one of [`CLICK_ZOOMS`].
|
||
pub click_zoom: u32,
|
||
/// Animate the loupe rect toward its target (set by a click-zoom).
|
||
#[serde(skip)]
|
||
pub zoom_anim: bool,
|
||
pub before_after: BeforeAfter,
|
||
pub thumb_size: f32,
|
||
/// Open Edit sections by id.
|
||
pub open_sections: Vec<String>,
|
||
/// Open flyouts (curve, mixer, grading…).
|
||
pub open_flyouts: Vec<String>,
|
||
/// Solo Mode (`view.soloSections`): opening one Edit section closes the others.
|
||
pub single_panel: bool,
|
||
pub show_clipping: bool,
|
||
pub histogram: bool,
|
||
/// Soft proofing (S in the loupe): render as `proof` would hold the photo.
|
||
pub soft_proof: bool,
|
||
pub proof: lightcraft_engine::pipeline::Proof,
|
||
/// Soft proofing against a printer / paper ICC profile: (file, name), the intent and Simulate
|
||
/// Paper & Ink. With a profile, `proof.profile` names its table.
|
||
#[serde(skip)]
|
||
pub proof_profile: Option<(String, String)>,
|
||
#[serde(skip)]
|
||
pub proof_intent: lightcraft_engine::proof::ProofIntent,
|
||
pub proof_paper_ink: bool,
|
||
/// Masking: show the selected mask as a rendered overlay (O), how (`MaskView` name, ⇧O cycles),
|
||
/// in which colour and opacity (0..100, colour views), and whether pins are drawn.
|
||
pub mask_overlay: bool,
|
||
pub mask_overlay_mode: String,
|
||
pub mask_overlay_color: [u8; 3],
|
||
pub mask_overlay_opacity: f32,
|
||
pub mask_pins: bool,
|
||
/// When the Remove tool's spot pins and the mask pins show (Shift+H cycles). Mask pins also
|
||
/// need [`mask_pins`](Self::mask_pins).
|
||
pub tool_overlay: ToolOverlay,
|
||
/// Masking: show every visible mask's region as one overlay at once (Ctrl+Shift+O / Cmd+Shift+O),
|
||
/// instead of just the selected mask's ([`mask_overlay`]).
|
||
pub mask_overlay_all: bool,
|
||
/// The overlay (on, mode) to go back to when Show Luminance Map is turned off.
|
||
#[serde(skip)]
|
||
pub luminance_map_restore: Option<(bool, String)>,
|
||
/// Mirroring of the triangle / spiral crop guides (0..4).
|
||
pub crop_overlay_orient: u8,
|
||
pub crop_overlay: CropOverlay,
|
||
/// Last explicitly chosen aspect, retained across photos and app launches.
|
||
pub crop_default_aspect: Option<serde_json::Value>,
|
||
pub show_filenames: bool,
|
||
/// What the square grid's caption shows: `filename`, `exposure` (shutter · aperture · ISO ·
|
||
/// focal length) or `date`.
|
||
pub grid_info: String,
|
||
/// Photo counts next to sources and albums in the left panel.
|
||
pub show_counts: bool,
|
||
/// Face / pet boxes (read from XMP) over the photo in the loupe.
|
||
pub face_boxes: bool,
|
||
/// The left sidebar's sections: their order, which are hidden and which are folded shut.
|
||
pub sidebar: crate::sidebar::SidebarLayout,
|
||
/// What settings files said before [`UiState::sidebar`]: the ids of the folded sections. Read
|
||
/// so they stay folded ([`UiState::sanitized`] moves them over), never written.
|
||
#[serde(skip_serializing)]
|
||
pub collapsed_sidebar: Vec<String>,
|
||
/// A face's name being typed in the loupe.
|
||
#[serde(skip)]
|
||
pub name_edit: Option<NameEdit>,
|
||
/// The person whose page the People view shows (their faces and the faces that look like them); `None`: everyone.
|
||
#[serde(skip)]
|
||
pub person_page: Option<String>,
|
||
/// The person page a photo in the loupe was opened from, and that photo: Escape goes back to the page (not the grid).
|
||
#[serde(skip)]
|
||
pub person_from: Option<(String, u64)>,
|
||
/// The last person page left: the People view offers a way back to it next to its title.
|
||
#[serde(skip)]
|
||
pub last_person: Option<String>,
|
||
/// "More" faces the user hid with ×, for this session: (photo, region index).
|
||
#[serde(skip)]
|
||
pub dismissed_faces: std::collections::HashSet<(u64, usize)>,
|
||
/// The unnamed faces selected in the People view (photo, region), the name being typed for them, the face last
|
||
/// clicked (Shift-click selects a range from it) and whether the name box should take the keyboard.
|
||
#[serde(skip)]
|
||
pub unnamed_selected: std::collections::HashSet<(u64, usize)>,
|
||
#[serde(skip)]
|
||
pub unnamed_name: String,
|
||
#[serde(skip)]
|
||
pub unnamed_anchor: Option<usize>,
|
||
#[serde(skip)]
|
||
pub unnamed_focus: bool,
|
||
/// Local sidebar locations hidden with “Remove from Local” (folders on disk are untouched).
|
||
pub hidden_locations: Vec<String>,
|
||
/// Copies opened in an external editor this session (reloaded when the window is focused
|
||
/// again), and whether the window had focus last frame.
|
||
#[serde(skip)]
|
||
pub external_edits: Vec<u64>,
|
||
#[serde(skip)]
|
||
pub was_focused: bool,
|
||
/// When the watched folder was last scanned (egui time).
|
||
#[serde(skip)]
|
||
pub auto_import_at: f64,
|
||
/// When the automatic XMP writes that failed were last tried again (egui time), and how many were waiting then.
|
||
#[serde(skip)]
|
||
pub sidecar_retry_at: f64,
|
||
#[serde(skip)]
|
||
pub sidecars_waiting: usize,
|
||
/// The cards and cameras already seen (volume roots) while Settings → Import → "Show the
|
||
/// import review when a camera or card is connected" is on; `None` = not looked yet.
|
||
#[serde(skip)]
|
||
pub known_cards: Option<Vec<String>>,
|
||
/// The develop control whose slider is being dragged (geometry sliders show a grid).
|
||
#[serde(skip)]
|
||
pub dragging_control: Option<String>,
|
||
pub search: String,
|
||
/// Focus the search field on the next frame (Edit → Find…).
|
||
#[serde(skip)]
|
||
pub focus_search: bool,
|
||
/// A mask being renamed in the Masks list: its id and the edited name.
|
||
#[serde(skip)]
|
||
pub renaming_mask: Option<(u32, String)>,
|
||
/// The Describe field (AI mask from a text prompt) while open: how the selection combines
|
||
/// (`new` mask, or `add`/`subtract`/`intersect` on the selected one) and the text typed.
|
||
#[serde(skip)]
|
||
pub describe: Option<(String, String)>,
|
||
/// A SAM 3 download was started from the app (to report its end once).
|
||
#[serde(skip)]
|
||
pub sam_downloading: bool,
|
||
/// When to start the zoomed-in detail pass of an AI mask (app time) and which mask: set by
|
||
/// each click or description, so the pass runs once the clicking stops.
|
||
#[serde(skip)]
|
||
pub detail_due: Option<(f64, u32)>,
|
||
/// A mask component being renamed inline: (mask id, component index, name).
|
||
pub renaming_component: Option<(u32, usize, String)>,
|
||
/// Close the window on the next frame (File → Quit).
|
||
#[serde(skip)]
|
||
pub quit: bool,
|
||
/// Photos being dragged from the grid (dropped on an album to add them).
|
||
#[serde(skip)]
|
||
pub dragging_photos: Option<Vec<u64>>,
|
||
/// An album just made: the Albums tree opens the folders down to it, once.
|
||
#[serde(skip)]
|
||
pub reveal_album: Option<u64>,
|
||
/// A keyword just made from the sidebar: Keywords opens down to it, once.
|
||
#[serde(skip)]
|
||
pub reveal_keyword: Option<String>,
|
||
/// An album or folder row being dragged in the sidebar (dropped on a folder to move it there).
|
||
#[serde(skip)]
|
||
pub dragging_album: Option<u64>,
|
||
/// A sidebar section being dragged by its header (dropped between sections to move it there).
|
||
#[serde(skip)]
|
||
pub dragging_section: Option<crate::sidebar::SidebarSection>,
|
||
/// Selected curve channel in the Curve flyout.
|
||
pub curve_channel: String,
|
||
/// Selected mixer mode: "hue" | "saturation" | "luminance" | "all".
|
||
pub mixer_mode: String,
|
||
/// Selected colour grading tab: "3way" | "shadows" | "midtones" | "highlights".
|
||
pub grading_mode: String,
|
||
/// Colour grading wheels (shadows, midtones, highlights) hidden for viewing by their eye, and
|
||
/// the photo that applies to: not an edit, and forgotten on moving to another photo or quitting.
|
||
#[serde(skip)]
|
||
pub grading_hidden: Option<(lightcraft_catalog::PhotoId, [bool; 3])>,
|
||
/// Active on-canvas tool: "", "brush", "linear", "radial", "wbPicker", "straighten", "remove".
|
||
pub tool: String,
|
||
pub brush_size: f32,
|
||
pub brush_feather: f32,
|
||
pub brush_flow: f32,
|
||
pub brush_erase: bool,
|
||
/// Brush Auto Mask: dabs stick to areas like the one under the brush centre.
|
||
pub brush_auto_mask: bool,
|
||
/// Brush Pen Pressure: a pen's pressure scales each dab's size and flow (a mouse paints at full pressure).
|
||
pub brush_pressure: bool,
|
||
/// Remove tool brush: size (fraction of the long edge), feather and opacity (0..100).
|
||
pub remove_size: f32,
|
||
pub remove_feather: f32,
|
||
pub remove_opacity: f32,
|
||
/// Selected Point Color sample.
|
||
pub point_color: usize,
|
||
/// Point Color "Visualize range": the selected sample's range in colour, the rest grey.
|
||
pub point_color_visualize: bool,
|
||
/// Light panel "Visualize HDR": grey below SDR white, colour bands above (HDR edits).
|
||
pub hdr_visualize: bool,
|
||
/// Red Eye panel: selected correction, and whether new ones are pet eyes.
|
||
pub eye: usize,
|
||
pub eye_pet: bool,
|
||
/// Remove tool: Visualize Spots (high-pass black/white view) and its threshold 0..100.
|
||
pub visualize_spots: bool,
|
||
pub spots_threshold: f32,
|
||
/// The library filter bar above the grid.
|
||
pub filter_bar: bool,
|
||
/// Folders kept in Local (Browse Folder…, Keep in Local / Add to Local; `local.addRoot`):
|
||
/// listed after the built-in locations, across restarts, whatever is browsed.
|
||
pub local_roots: Vec<String>,
|
||
/// The top-level Local row listed for this session only, for a browsed folder outside every
|
||
/// kept location; it stays while browsing below it (see `panels::left::local_places`).
|
||
#[serde(skip)]
|
||
pub local_browse_root: Option<String>,
|
||
/// Culling: after a rating, flag or colour-label key, move to the next photo.
|
||
pub auto_advance: bool,
|
||
/// Full-screen preview (F): the photo alone on black, no chrome.
|
||
pub fullscreen: bool,
|
||
/// Lights Out (Shift+L): not saved, every launch starts with the lights on.
|
||
#[serde(skip)]
|
||
pub lights_out: LightsOut,
|
||
/// Window ▸ Second Window.
|
||
pub second_window: bool,
|
||
/// The keyword painter: clicking a photo in the grid toggles this keyword on it.
|
||
#[serde(skip)]
|
||
pub keyword_painter: Option<String>,
|
||
/// What the Keywording box shows (Lightroom Classic's Keyword Tags views).
|
||
pub keywording_view: KeywordingView,
|
||
/// The Keywording box, grouped: every keyword of this selection (its hash) is shown, however
|
||
/// many lines that takes ("+N more" was clicked). Another selection's list is short again.
|
||
#[serde(skip)]
|
||
pub keyword_chips_all: Option<u64>,
|
||
/// Sections of the right panel folded shut by a click on their title (`keywording`,
|
||
/// `keywordList`); the others are open.
|
||
#[serde(deserialize_with = "lenient_ids")]
|
||
pub folded_sections: Vec<String>,
|
||
/// The Keyword List's open levels (lower-case paths).
|
||
pub keyword_list_open: Vec<String>,
|
||
/// The keyword picked in the Keyword List (− deletes it, Edit edits it).
|
||
#[serde(skip)]
|
||
pub keyword_list_selected: Option<String>,
|
||
/// A keyword being dragged in the Keyword List (onto another to nest it).
|
||
#[serde(skip)]
|
||
pub dragging_keyword: Option<String>,
|
||
/// A running slideshow (full screen; see [`crate::panels::slideshow`]).
|
||
#[serde(skip)]
|
||
pub slideshow: Option<Box<crate::panels::slideshow::Run>>,
|
||
/// Info overlay on the loupe.
|
||
pub info_overlay: InfoOverlay,
|
||
/// The loupe's layout image overlay (View ▸ Loupe Overlay).
|
||
pub layout_image: LayoutImage,
|
||
/// Navigator mini map in the loupe while zoomed in.
|
||
pub navigator: bool,
|
||
/// App preferences (Settings dialog).
|
||
pub settings: AppSettings,
|
||
/// Window full screen requested (⇧⌘F); the host applies it (`None` = no change pending).
|
||
#[serde(skip)]
|
||
pub window_fullscreen: Option<bool>,
|
||
/// Compare view: (select, candidate) photo ids.
|
||
#[serde(skip)]
|
||
pub compare: Option<(u64, u64)>,
|
||
/// Reference view: the reference photo.
|
||
#[serde(skip)]
|
||
pub reference: Option<u64>,
|
||
/// Transient toast text, expiry (seconds of app time), and optional colour-label styling.
|
||
#[serde(skip)]
|
||
pub toast: Option<(String, f64, Option<lightcraft_catalog::ColorLabel>)>,
|
||
/// The result of the last Find Missing Photos (it searches in the background).
|
||
#[serde(skip)]
|
||
pub last_find_missing: Option<serde_json::Value>,
|
||
/// The outcome (success or error) of the last Find Missing Photos task.
|
||
#[serde(skip)]
|
||
pub last_find_missing_result: Option<Result<serde_json::Value, String>>,
|
||
/// Injected delay for Find Missing Photos worker (test-only, for timeout testing).
|
||
#[cfg(test)]
|
||
#[serde(skip)]
|
||
pub find_missing_test_delay: Option<std::time::Duration>,
|
||
/// The library (its `Session::library_generation`) whose preview-only raws were rechecked
|
||
/// (`crate::recheck`).
|
||
#[serde(skip)]
|
||
pub rechecked_library: Option<u64>,
|
||
#[serde(skip)]
|
||
pub status: String,
|
||
#[serde(skip)]
|
||
pub dialog: Option<Dialog>,
|
||
}
|
||
|
||
/// 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>,
|
||
},
|
||
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.
|
||
Cull {
|
||
reject_below: f32,
|
||
pick_best: bool,
|
||
},
|
||
/// 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>,
|
||
},
|
||
/// 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 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,
|
||
}
|
||
|
||
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 Default for UiState {
|
||
fn default() -> Self {
|
||
UiState {
|
||
docking: None,
|
||
language: crate::i18n::default_language(),
|
||
preview_build_seen: None,
|
||
unsaved_seen: false,
|
||
luminance_map_restore: None,
|
||
view: ViewMode::Detail,
|
||
left_panel: false,
|
||
left_width: LEFT_WIDTH.default,
|
||
right_width: RIGHT_WIDTH.default,
|
||
right: RightPanel::Edit,
|
||
presets: false,
|
||
preset_thumbs: false,
|
||
filmstrip: true,
|
||
zoom: Zoom::Fit,
|
||
pan: (0.5, 0.5),
|
||
click_zoom: 100,
|
||
zoom_anim: false,
|
||
before_after: BeforeAfter::Off,
|
||
thumb_size: 220.0,
|
||
open_sections: vec!["light".into()],
|
||
open_flyouts: vec![],
|
||
single_panel: false,
|
||
show_clipping: false,
|
||
histogram: true,
|
||
soft_proof: false,
|
||
proof: lightcraft_engine::pipeline::Proof { dest_warning: false, ..Default::default() },
|
||
proof_profile: None,
|
||
proof_intent: lightcraft_engine::proof::ProofIntent::Perceptual,
|
||
proof_paper_ink: false,
|
||
mask_overlay: true,
|
||
mask_overlay_mode: "color".into(),
|
||
mask_overlay_color: [230, 30, 40],
|
||
mask_overlay_opacity: 50.0,
|
||
mask_pins: true,
|
||
tool_overlay: ToolOverlay::Auto,
|
||
mask_overlay_all: false,
|
||
crop_overlay: CropOverlay::Thirds,
|
||
crop_default_aspect: None,
|
||
crop_overlay_orient: 0,
|
||
show_filenames: true,
|
||
grid_info: "filename".into(),
|
||
show_counts: true,
|
||
face_boxes: true,
|
||
sidebar: Default::default(),
|
||
collapsed_sidebar: Vec::new(),
|
||
name_edit: None,
|
||
person_page: None,
|
||
person_from: None,
|
||
last_person: None,
|
||
dismissed_faces: Default::default(),
|
||
unnamed_selected: Default::default(),
|
||
unnamed_name: String::new(),
|
||
unnamed_anchor: None,
|
||
unnamed_focus: false,
|
||
hidden_locations: Vec::new(),
|
||
dragging_control: None,
|
||
external_edits: Vec::new(),
|
||
was_focused: true,
|
||
auto_import_at: 0.0,
|
||
sidecar_retry_at: 0.0,
|
||
sidecars_waiting: 0,
|
||
known_cards: None,
|
||
search: String::new(),
|
||
focus_search: false,
|
||
renaming_mask: None,
|
||
describe: None,
|
||
detail_due: None,
|
||
sam_downloading: false,
|
||
renaming_component: None,
|
||
quit: false,
|
||
dragging_photos: None,
|
||
reveal_album: None,
|
||
reveal_keyword: None,
|
||
dragging_album: None,
|
||
dragging_section: None,
|
||
curve_channel: "parametric".into(),
|
||
mixer_mode: "hue".into(),
|
||
grading_mode: "3way".into(),
|
||
grading_hidden: None,
|
||
tool: String::new(),
|
||
brush_size: 0.04,
|
||
brush_feather: 50.0,
|
||
brush_flow: 60.0,
|
||
brush_erase: false,
|
||
brush_auto_mask: false,
|
||
brush_pressure: true,
|
||
remove_size: 0.02,
|
||
remove_feather: 50.0,
|
||
remove_opacity: 100.0,
|
||
point_color: 0,
|
||
point_color_visualize: false,
|
||
hdr_visualize: false,
|
||
eye: 0,
|
||
eye_pet: false,
|
||
visualize_spots: false,
|
||
spots_threshold: 50.0,
|
||
auto_advance: false,
|
||
fullscreen: false,
|
||
lights_out: LightsOut::On,
|
||
slideshow: None,
|
||
second_window: false,
|
||
keyword_painter: None,
|
||
keywording_view: KeywordingView::default(),
|
||
keyword_chips_all: None,
|
||
folded_sections: Vec::new(),
|
||
keyword_list_open: Vec::new(),
|
||
keyword_list_selected: None,
|
||
dragging_keyword: None,
|
||
info_overlay: InfoOverlay::Off,
|
||
layout_image: LayoutImage::default(),
|
||
navigator: true,
|
||
settings: AppSettings::default(),
|
||
window_fullscreen: None,
|
||
filter_bar: false,
|
||
local_roots: Vec::new(),
|
||
local_browse_root: None,
|
||
compare: None,
|
||
reference: None,
|
||
toast: None,
|
||
last_find_missing: None,
|
||
last_find_missing_result: None,
|
||
#[cfg(test)]
|
||
find_missing_test_delay: None,
|
||
rechecked_library: None,
|
||
status: String::new(),
|
||
dialog: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl UiState {
|
||
/// Which colour grading wheels (shadows, midtones, highlights) are hidden for viewing `photo`.
|
||
pub fn grading_hidden_for(&self, photo: lightcraft_catalog::PhotoId) -> [bool; 3] {
|
||
match self.grading_hidden {
|
||
Some((p, h)) if p == photo => h,
|
||
_ => [false; 3],
|
||
}
|
||
}
|
||
|
||
/// Show or hide colour grading wheel `i` (0 shadows, 1 midtones, 2 highlights) on `photo`.
|
||
pub fn set_grading_hidden(&mut self, photo: lightcraft_catalog::PhotoId, i: usize, hidden: bool) {
|
||
let mut h = self.grading_hidden_for(photo);
|
||
if let Some(slot) = h.get_mut(i) {
|
||
*slot = hidden;
|
||
}
|
||
self.grading_hidden = h.iter().any(|x| *x).then_some((photo, h));
|
||
}
|
||
|
||
/// A right-panel section that folds by its title is folded shut.
|
||
pub fn section_folded(&self, id: &str) -> bool {
|
||
self.folded_sections.iter().any(|s| s == id)
|
||
}
|
||
pub fn toggle_folded(&mut self, id: &str) {
|
||
if self.section_folded(id) {
|
||
self.folded_sections.retain(|s| s != id);
|
||
} else {
|
||
self.folded_sections.push(id.to_string());
|
||
}
|
||
}
|
||
|
||
pub fn section_open(&self, id: &str) -> bool {
|
||
self.open_sections.iter().any(|s| s == id)
|
||
}
|
||
pub fn toggle_section(&mut self, id: &str) {
|
||
if self.section_open(id) {
|
||
self.open_sections.retain(|s| s != id);
|
||
} else if self.single_panel {
|
||
self.solo_section(id);
|
||
} else {
|
||
self.open_sections.push(id.to_string());
|
||
}
|
||
}
|
||
/// Opens `id` and closes every other Edit section (⌥-click on a section header).
|
||
pub fn solo_section(&mut self, id: &str) {
|
||
self.open_sections.clear();
|
||
self.open_sections.push(id.to_string());
|
||
}
|
||
/// Turns Solo Mode on or off. Turning it on keeps only the section opened last.
|
||
pub fn set_single_panel(&mut self, on: bool) {
|
||
self.single_panel = on;
|
||
if on {
|
||
let keep = self.open_sections.len().saturating_sub(1);
|
||
self.open_sections.drain(..keep);
|
||
}
|
||
}
|
||
/// Whether the sidebar section with this id is folded shut (an unknown id: no).
|
||
pub fn sidebar_section_collapsed(&self, id: &str) -> bool {
|
||
crate::sidebar::SidebarSection::from_id(id).is_some_and(|s| self.sidebar.is_collapsed(s))
|
||
}
|
||
pub fn flyout_open(&self, id: &str) -> bool {
|
||
self.open_flyouts.iter().any(|s| s == id)
|
||
}
|
||
pub fn toggle_flyout(&mut self, id: &str) {
|
||
if self.flyout_open(id) {
|
||
self.open_flyouts.retain(|s| s != id);
|
||
} else {
|
||
self.open_flyouts.push(id.to_string());
|
||
}
|
||
}
|
||
/// Clamp values restored from disk.
|
||
pub fn sanitized(mut self) -> Self {
|
||
self.thumb_size = self.thumb_size.clamp(THUMB_SIZE_MIN, THUMB_SIZE_MAX);
|
||
self.left_width = LEFT_WIDTH.clamp(self.left_width);
|
||
self.right_width = RIGHT_WIDTH.clamp(self.right_width);
|
||
self.brush_size = self.brush_size.clamp(0.002, 0.5);
|
||
self.sidebar.adopt_collapsed(&std::mem::take(&mut self.collapsed_sidebar));
|
||
self.dialog = None;
|
||
self.fullscreen = false;
|
||
if !crate::state::PREVIEW_LIMITS.contains(&self.settings.preview_limit) {
|
||
self.settings.preview_limit = AppSettings::default().preview_limit;
|
||
}
|
||
match self.settings.startup_view {
|
||
StartupView::Last => {}
|
||
StartupView::Grid => self.view = ViewMode::PhotoGrid,
|
||
StartupView::Detail => self.view = ViewMode::Detail,
|
||
}
|
||
self
|
||
}
|
||
}
|
||
|
||
/// 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())
|
||
}
|
||
|
||
/// 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<Vec<String>, 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<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)
|
||
}
|