mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-30 13:07:43 -06:00
830 lines
31 KiB
Rust
830 lines
31 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,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum CropOverlay {
|
||
#[default]
|
||
Thirds,
|
||
Grid,
|
||
Golden,
|
||
Diagonal,
|
||
Triangle,
|
||
Spiral,
|
||
None,
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
pub startup_view: StartupView,
|
||
/// Ask before moving photos to Recently Deleted (keyboard and menu).
|
||
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 External Editor: the application ("" = the system's default for TIFF files).
|
||
pub external_editor: String,
|
||
/// 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,
|
||
}
|
||
|
||
impl Default for AppSettings {
|
||
fn default() -> Self {
|
||
AppSettings {
|
||
library_path: String::new(),
|
||
startup_view: StartupView::Last,
|
||
confirm_delete: false,
|
||
gpu: true,
|
||
preview_limit: 0,
|
||
external_editor: String::new(),
|
||
memory_mb: 0,
|
||
film_names: true,
|
||
film_badges: true,
|
||
grid_badges: GridBadges::Auto,
|
||
keymap: Default::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 {
|
||
/// 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];
|
||
|
||
/// 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;
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
pub struct UiState {
|
||
#[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>,
|
||
/// Single-panel mode: opening one 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,
|
||
/// 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,
|
||
/// 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,
|
||
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,
|
||
/// Left-sidebar sections folded shut by their header (`albums`, `local`, `byDate`,
|
||
/// `keywords`); the rest are open.
|
||
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,
|
||
/// 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>,
|
||
/// 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>,
|
||
/// 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 wheel: "3way" | "shadows" | "midtones" | "highlights" | "global".
|
||
pub grading_mode: String,
|
||
/// 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,
|
||
/// 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,
|
||
/// 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,
|
||
/// 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>,
|
||
/// A running slideshow (full screen): seconds per photo, when the next one is due (egui
|
||
/// time), paused.
|
||
#[serde(skip)]
|
||
pub slideshow: Option<(f64, f64, bool)>,
|
||
/// Info overlay on the loupe.
|
||
pub info_overlay: InfoOverlay,
|
||
/// 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>,
|
||
#[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 {
|
||
/// `parent`: the folder to create it in (none: the top level).
|
||
NewAlbum {
|
||
name: String,
|
||
folder: bool,
|
||
#[serde(default)]
|
||
parent: Option<u64>,
|
||
},
|
||
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,
|
||
},
|
||
/// 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>,
|
||
},
|
||
/// 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,
|
||
},
|
||
About,
|
||
Shortcuts,
|
||
}
|
||
|
||
impl Default for UiState {
|
||
fn default() -> Self {
|
||
UiState {
|
||
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() },
|
||
mask_overlay: true,
|
||
mask_overlay_mode: "color".into(),
|
||
mask_overlay_color: [230, 30, 40],
|
||
mask_overlay_opacity: 50.0,
|
||
mask_pins: true,
|
||
crop_overlay: CropOverlay::Thirds,
|
||
crop_overlay_orient: 0,
|
||
show_filenames: true,
|
||
grid_info: "filename".into(),
|
||
show_counts: true,
|
||
face_boxes: true,
|
||
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,
|
||
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,
|
||
dragging_album: None,
|
||
curve_channel: "parametric".into(),
|
||
mixer_mode: "hue".into(),
|
||
grading_mode: "3way".into(),
|
||
tool: String::new(),
|
||
brush_size: 0.04,
|
||
brush_feather: 50.0,
|
||
brush_flow: 60.0,
|
||
brush_erase: false,
|
||
brush_auto_mask: false,
|
||
remove_size: 0.02,
|
||
remove_feather: 50.0,
|
||
remove_opacity: 100.0,
|
||
point_color: 0,
|
||
point_color_visualize: false,
|
||
eye: 0,
|
||
eye_pet: false,
|
||
visualize_spots: false,
|
||
spots_threshold: 50.0,
|
||
auto_advance: false,
|
||
fullscreen: false,
|
||
slideshow: None,
|
||
second_window: false,
|
||
keyword_painter: None,
|
||
info_overlay: InfoOverlay::Off,
|
||
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,
|
||
status: String::new(),
|
||
dialog: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl UiState {
|
||
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.open_sections.clear();
|
||
}
|
||
self.open_sections.push(id.to_string());
|
||
}
|
||
}
|
||
pub fn sidebar_section_collapsed(&self, id: &str) -> bool {
|
||
self.collapsed_sidebar.iter().any(|s| s == id)
|
||
}
|
||
pub fn toggle_sidebar_section(&mut self, id: &str) {
|
||
if self.sidebar_section_collapsed(id) {
|
||
self.collapsed_sidebar.retain(|s| s != id);
|
||
} else {
|
||
self.collapsed_sidebar.push(id.to_string());
|
||
}
|
||
}
|
||
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(90.0, 480.0);
|
||
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.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
|
||
}
|
||
}
|