diff --git a/crates/engine/src/settings/prefs.rs b/crates/engine/src/settings/prefs.rs index 30b80668..1ed7fea2 100644 --- a/crates/engine/src/settings/prefs.rs +++ b/crates/engine/src/settings/prefs.rs @@ -17,384 +17,28 @@ use effectcraft_time::Tick; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; +mod choices; +mod dialog; +mod document; +mod queries; +mod recent; +mod sections; + +pub use choices::{APPEARANCE_MODES, DARK_THEMES, LANGUAGES, LIGHT_THEMES, THEMES, WINDOW_GRAPHICS, theme_family}; +pub use dialog::{Item, Kind, Page, pages, pages_json, todo_keys}; +pub use document::{migrate, page_id, section_key}; +pub use queries::{CacheBudgets, WEB_LAYER_CACHE, WEB_MEDIA_CACHE, mask_color}; +pub use sections::{ + Appearance, Audio, AutoSave, CompPreset, CompositionPrefs, Disk, Export, FacePrefs, General, Grids, Import, LabelPref, Memory, Previews, ProjectPrefs, + RotoPrefs, Scripting, Startup, ThreeD, TypePrefs, Video, +}; +use sections::{Extra, default_labels, lenient_comp_presets}; + /// Current settings layout version. pub const PREFS_VERSION: u32 = 2; /// Settings file name in the config store. pub const PREFS_FILE: &str = "prefs.json"; -/// Settings ▸ General ▸ Language: (label, `general.language` value). -pub const LANGUAGES: &[(&str, &str)] = &[ - ("Match System", "system"), - ("English", "en"), - // Alphabetical by the language's own name after English; Cyrillic, Georgian, then CJK names - // follow the Latin ones. - ("Čeština", "cs"), - ("Deutsch", "de"), - ("Español", "es"), - ("Français", "fr"), - ("Português (Brasil)", "pt-br"), - ("Русский", "ru"), - ("Українська", "uk"), - ("ქართული", "ka"), - ("日本語", "ja"), - ("简体中文", "zh-hans"), - ("繁體中文", "zh-hant"), -]; - -/// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value). -pub const WINDOW_GRAPHICS: &[(&str, &str)] = &[("Automatic", "auto"), ("OpenGL (compatibility)", "gl")]; - -/// Settings ▸ Appearance ▸ Appearance Mode: (label, `appearance.appearanceMode` value). Auto -/// follows the operating system's light or dark appearance. -pub const APPEARANCE_MODES: &[(&str, &str)] = &[("Sync with System", "auto"), ("Dark", "dark"), ("Light", "light")]; - -/// The dark family of themes: (label, `appearance.darkTheme` value). -pub const DARK_THEMES: &[(&str, &str)] = &[("Dark", "dark"), ("Darker", "darker")]; - -/// The light family of themes: (label, `appearance.lightTheme` value). -pub const LIGHT_THEMES: &[(&str, &str)] = &[("Light", "light")]; - -/// Every theme (the legacy single-theme `appearance.theme`). -pub const THEMES: &[(&str, &str)] = &[("Dark", "dark"), ("Darker", "darker"), ("Light", "light")]; - -/// The settings whose value must be one of their choices. -fn choices(key: &str) -> Option<&'static [(&'static str, &'static str)]> { - match key { - "general.language" => Some(LANGUAGES), - "startup.windowGraphics" => Some(WINDOW_GRAPHICS), - "appearance.appearanceMode" => Some(APPEARANCE_MODES), - "appearance.darkTheme" => Some(DARK_THEMES), - "appearance.lightTheme" => Some(LIGHT_THEMES), - "appearance.theme" => Some(THEMES), - _ => None, - } -} - -fn is_choice(choices: &[(&str, &str)], v: &str) -> bool { - choices.iter().any(|(_, c)| *c == v) -} - -/// Unknown keys inside a page, kept so a newer version's settings survive a round trip. -type Extra = BTreeMap; - -macro_rules! page { - ($(#[$m:meta])* $name:ident { $($(#[$fm:meta])* $f:ident : $t:ty = $d:expr),* $(,)? }) => { - $(#[$m])* - #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] - #[serde(default, rename_all = "camelCase")] - pub struct $name { - $($(#[$fm])* pub $f: $t,)* - #[serde(flatten)] - pub extra: Extra, - } - impl Default for $name { - fn default() -> Self { - $name { $($f: $d,)* extra: Extra::new() } - } - } - }; -} - -page!(General { - /// Interface language: `system` (the operating system's, where EffectCraft has it, else - /// English), or a language code from `LANGUAGES`. - language: String = "system".into(), - /// Levels of Undo (1–99). - undo_levels: u32 = 32, - /// Path Point and Handle Size (px). - path_point_size: u32 = 5, - show_tool_tips: bool = true, - /// Create New Layers at Composition Start Time (off: at the current time). - create_layers_at_comp_start: bool = true, - switches_affect_nested_comps: bool = true, - /// Default Spatial Interpolation to Linear. - default_spatial_linear: bool = false, - preserve_constant_vertex_count: bool = true, - /// Synchronize Time of All Related Items. - sync_time_related_items: bool = true, - expression_pick_whip_compact: bool = true, - /// Create Split Layers Above Original Layer (After Effects' default). - create_split_layers_above: bool = true, - use_system_color_picker: bool = false, - /// Number of projects in File ▸ Open Recent. - recent_items: u32 = 10, -}); - -page!(Startup { - show_home_on_launch: bool = true, - show_home_on_open_project: bool = false, - /// After a crash, offer to open the most recent auto-save. - offer_crash_recovery: bool = true, - /// What the desktop window draws with, from the next launch: `auto` (the platform's best - /// graphics API) or `gl` (OpenGL, for drivers that crash with the others). A launch that - /// stops as its window opens switches it to `gl` (#243). - window_graphics: String = "auto".into(), -}); - -page!(ProjectPrefs { - /// Open a template project for File ▸ New ▸ New Project. - use_template: bool = false, - template_path: String = String::new(), -}); - -page!(AutoSave { - enabled: bool = true, - interval_minutes: u32 = 20, - max_versions: u32 = 5, - /// `nextToProject` (an "EffectCraft Auto-Save" folder beside the project) or `custom`. - location: String = "nextToProject".into(), - folder: String = String::new(), - save_on_render_start: bool = false, -}); - -page!(CompositionPrefs { - /// Motion path: `all`, `none`, `seconds`, `keyframes`. - motion_path: String = "keyframes".into(), - motion_path_seconds: f64 = 15.0, - motion_path_keyframes: u32 = 15, - show_rendering_progress: bool = true, - hardware_accelerate_panels: bool = true, -}); - -page!(Previews { - /// Largest downsampling while playing back: `1/2`, `1/4`, `1/8`. - adaptive_resolution_limit: String = "1/8".into(), - show_internal_wireframes: bool = false, - cache_frames_when_idle: bool = false, - fast_previews: bool = false, - /// `faster` or `moreAccurate`. - zoom_quality: String = "moreAccurate".into(), - show_gpu_info: bool = false, - /// The monitor's colour space for View ▸ Use Display Color Management: `srgb` or `p3`. - display_profile: String = "srgb".into(), -}); - -page!(Appearance { - /// The single theme before appearance modes (`dark`, `darker` or `light`), kept for older - /// settings files and automation: setting it picks its family's mode and theme. While the mode - /// is Dark or Light it mirrors the theme in use. - theme: String = "dark".into(), - /// `auto` (follow the operating system), `dark` or `light`. New installs stay Dark. - appearance_mode: String = "dark".into(), - /// The theme used in dark appearance: `dark` or `darker`. - dark_theme: String = "dark".into(), - /// The theme used in light appearance: `light`. - light_theme: String = "light".into(), - /// Theme shortcuts in the appearance context menu. - favorite_themes: Vec = Vec::new(), - /// User interface brightness, -1 (darker) … 1 (lighter). - brightness: f64 = 0.0, - /// The size of the whole interface in percent (75–200), on top of the display's own scale. - ui_scale: u32 = 100, - use_label_color_for_handles: bool = true, - use_label_color_for_tabs: bool = true, - cycle_mask_colors: bool = true, - use_gradients: bool = true, - /// macOS: draw the menu bar inside the window instead of the system menu bar. - in_window_menu_bar_mac: bool = false, -}); - -page!(Grids { - grid_color: String = "#7a9cff".into(), - grid_spacing: f64 = 100.0, - grid_subdivisions: u32 = 4, - /// `lines`, `dashed` or `dots`. - grid_style: String = "lines".into(), - proportional_horizontal: u32 = 4, - proportional_vertical: u32 = 4, - guide_color: String = "#3cc8f0".into(), - guide_style: String = "lines".into(), - action_safe: f64 = 10.0, - title_safe: f64 = 20.0, -}); - -page!(TypePrefs { - /// `latin` or `southAsian` (South Asian and Middle Eastern). - text_engine: String = "latin".into(), - font_preview: bool = true, - recent_fonts: u32 = 10, - font_names_in_english: bool = false, -}); - -page!(Import { - /// Still footage duration: `compLength` or `seconds`. - still_footage: String = "compLength".into(), - still_seconds: f64 = 5.0, - sequence_fps: f64 = 30.0, - report_missing_frames: bool = true, - /// Automatically Reload Footage whose file another app changed (size or modification time), - /// checked every two seconds and when the window comes back to the front: `off`, - /// `nonSequence` (the default, as in After Effects: image sequences are left alone) or `all`. - auto_reload_footage: String = "nonSequence".into(), - /// Interpret unlabeled alpha as: `ask`, `guess`, `ignore`, `straight`, `premultiplied`. - unlabeled_alpha: String = "ask".into(), - /// Default drag import as: `footage`, `comp`, `compLayerSizes`. - drag_import_as: String = "footage".into(), -}); - -page!(Export { - default_output_folder: String = String::new(), - segment_sequences: bool = false, - segment_sequence_files: u32 = 700, - segment_movies: bool = false, - segment_movie_mb: u32 = 1024, - append_bits_to_name: bool = false, -}); - -page!(Audio { - /// Output device name (empty = the system default). - output_device: String = String::new(), - /// Output mapping: device channel for the left / right output (1-based). - output_left: u32 = 1, - output_right: u32 = 2, - preview_sample_rate: u32 = 48_000, -}); - -page!(Disk { - disk_cache_enabled: bool = true, - disk_cache_max_gb: u32 = 100, - disk_cache_folder: String = String::new(), - media_cache_folder: String = String::new(), - conformed_media_folder: String = String::new(), -}); - -page!(Memory { - ram_reserved_gb: u32 = 6, - /// Processed-layer cache budget (MB). - layer_cache_mb: u32 = 1024, - /// Decoded footage frame cache budget (MB). - media_cache_mb: u32 = 1024, - /// RAM preview (viewer frame) cache budget (MB). - preview_cache_mb: u32 = 3072, - reduce_cache_when_low: bool = true, -}); - -page!(Video { - enable_output: bool = false, - device: String = String::new(), - output_during_playback: bool = true, - mirror_on_monitor: bool = true, - disable_when_background: bool = false, -}); - -page!(ThreeD { - /// Default 3D renderer for new compositions: `classic` or `advanced`. - default_renderer: String = "classic".into(), - show_reference_axes: bool = true, - extended_viewer: bool = false, - realtime_shadows: bool = true, -}); - -page!( - /// Settings ▸ Roto Brush. - RotoPrefs { - /// The segmentation model Roto Brush 2.0 / 3.0 use: `classical` (built in) or a model id - /// from `effectcraft_segment::MODELS` (once installed). - model: String = "classical".into(), - } -); - -page!( - /// Settings ▸ Face Tracking. - FacePrefs { - /// The model Track Mask ▸ Face Tracking uses: `classical` (built in) or a model id from - /// `effectcraft_segment::MODELS` (once installed). - model: String = "classical".into(), - } -); - -page!(Scripting { - allow_scripts_write_files: bool = false, - warn_executing_files: bool = true, - enable_js_debugger: bool = false, - editor_font_size: u32 = 13, - syntax_highlighting: bool = true, - line_numbers: bool = true, - auto_complete: bool = true, - bracket_matching: bool = true, - word_wrap: bool = false, - error_banner: bool = true, -}); - -impl Scripting { - /// Whether `other` grants scripts the same as this: Application Scripting (file and network - /// access, the warning before executing files, the debugger). Scripts can't change these - /// ([`crate::Session::execute_for_script`]): a script could otherwise give itself the - /// access the user withheld. - pub fn same_permissions(&self, other: &Scripting) -> bool { - self.allow_scripts_write_files == other.allow_scripts_write_files - && self.warn_executing_files == other.warn_executing_files - && self.enable_js_debugger == other.enable_js_debugger - } - - /// Take `from`'s Application Scripting settings (see [`Scripting::same_permissions`]). - pub fn keep_permissions(&mut self, from: &Scripting) { - self.allow_scripts_write_files = from.allow_scripts_write_files; - self.warn_executing_files = from.warn_executing_files; - self.enable_js_debugger = from.enable_js_debugger; - } -} - -/// One editable label: name and `#rrggbb` colour. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct LabelPref { - pub name: String, - pub color: String, -} - -/// A Composition Settings preset the user saved (Composition Settings ▸ Preset ▸ Save): the -/// frame size, pixel aspect and frame rate, as in After Effects' own presets. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CompPreset { - pub name: String, - pub width: u32, - pub height: u32, - pub pixel_aspect: f64, - pub frame_rate: f64, -} - -impl CompPreset { - /// Longest preset name kept. - pub const MAX_NAME: usize = 128; - - /// Values a composition accepts (a hand-edited settings file can hold anything). - pub fn is_valid(&self) -> bool { - let size = 4..=30000; - !self.name.trim().is_empty() - && self.name.chars().count() <= Self::MAX_NAME - && size.contains(&self.width) - && size.contains(&self.height) - && self.pixel_aspect.is_finite() - && (0.01..=100.0).contains(&self.pixel_aspect) - && self.frame_rate.is_finite() - && (1.0..=999.0).contains(&self.frame_rate) - } -} - -/// Saved composition presets, skipping entries that don't parse instead of losing the list. -fn lenient_comp_presets<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { - let v = Vec::::deserialize(d)?; - Ok(v.into_iter().filter_map(|x| serde_json::from_value(x).ok()).collect()) -} - -fn default_labels() -> Vec { - Label::ALL.iter().skip(1).map(|l| LabelPref { name: l.name().to_string(), color: hex(l.rgb()) }).collect() -} - -fn hex([r, g, b]: [u8; 3]) -> String { - format!("#{r:02x}{g:02x}{b:02x}") -} - -fn parse_hex(s: &str) -> Option<[u8; 3]> { - let h = s.trim().trim_start_matches('#'); - if h.len() != 6 { - return None; - } - let p = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok(); - Some([p(0)?, p(2)?, p(4)?]) -} - /// All settings. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -482,1037 +126,3 @@ impl Default for Prefs { } } } - -/// Bring an older settings document up to [`PREFS_VERSION`]. -/// -/// - version 0/1 (early builds) kept a flat object: `undoLevels`, `autoSaveEnabled`, -/// `autoSaveMinutes`, `autoSaveVersions`, `labelNames` (array) and `theme` at the top level. -pub fn migrate(mut v: Value) -> Value { - let Some(obj) = v.as_object_mut() else { return json!({}) }; - let version = obj.get("version").and_then(Value::as_u64).unwrap_or(0); - if version < 2 { - let moved = |from: &str, page: &str, key: &str, obj: &mut Map| { - if let Some(x) = obj.remove(from) { - let p = obj.entry(page.to_string()).or_insert_with(|| json!({})); - if let Some(p) = p.as_object_mut() { - p.entry(key.to_string()).or_insert(x); - } - } - }; - moved("undoLevels", "general", "undoLevels", obj); - moved("autoSaveEnabled", "autoSave", "enabled", obj); - moved("autoSaveMinutes", "autoSave", "intervalMinutes", obj); - moved("autoSaveVersions", "autoSave", "maxVersions", obj); - moved("theme", "appearance", "theme", obj); - if let Some(Value::Array(names)) = obj.remove("labelNames") { - let mut labels = default_labels(); - for (l, n) in labels.iter_mut().zip(names) { - if let Some(n) = n.as_str() { - l.name = n.to_string(); - } - } - obj.insert("labels".into(), serde_json::to_value(labels).unwrap_or_default()); - } - } - // Settings saved before appearance modes had one theme: keep showing it, as a fixed Dark or - // Light mode, instead of switching established users to Auto. - if let Some(a) = obj.get_mut("appearance").and_then(Value::as_object_mut) - && !a.contains_key("appearanceMode") - && let Some(theme) = a.get("theme").and_then(Value::as_str).map(str::to_string) - && let Some((mode, slot)) = theme_family(&theme) - { - a.insert("appearanceMode".into(), json!(mode)); - a.insert(slot.into(), json!(theme)); - } - obj.insert("version".into(), json!(PREFS_VERSION)); - v -} - -/// The appearance mode and the per-mode setting a theme belongs to (`darker` → (`dark`, -/// `darkTheme`)); `None` for an unknown theme. -pub fn theme_family(theme: &str) -> Option<(&'static str, &'static str)> { - if is_choice(DARK_THEMES, theme) { - Some(("dark", "darkTheme")) - } else if is_choice(LIGHT_THEMES, theme) { - Some(("light", "lightTheme")) - } else { - None - } -} - -impl Appearance { - /// Show the legacy single `theme`: fix the mode to its family and remember it as that - /// family's theme. An unknown theme changes nothing. - pub fn select_theme(&mut self) { - match theme_family(&self.theme) { - Some((mode, "darkTheme")) => { - self.appearance_mode = mode.into(); - self.dark_theme = self.theme.clone(); - } - Some((mode, _)) => { - self.appearance_mode = mode.into(); - self.light_theme = self.theme.clone(); - } - None => {} - } - } - - /// The theme to show: the light or dark family's choice by the mode; Auto follows `system_light` - /// (`None` = the system said nothing: dark). - pub fn resolved_theme(&self, system_light: Option) -> &str { - let light = match self.appearance_mode.as_str() { - "light" => true, - "auto" => system_light == Some(true), - _ => false, - }; - if light { &self.light_theme } else { &self.dark_theme } - } -} - -impl Prefs { - /// Parse a settings document (migrating older layouts; unknown keys are kept, bad values - /// fall back to defaults). - pub fn from_json(text: &str) -> Prefs { - let v: Value = serde_json::from_str(text).unwrap_or_else(|_| json!({})); - let v = migrate(v); - let mut p: Prefs = match serde_json::from_value(v.clone()) { - Ok(p) => p, - // One bad value shouldn't lose everything: take each page that parses. - Err(_) => { - let mut base = serde_json::to_value(Prefs::default()).unwrap_or_default(); - if let (Some(b), Some(o)) = (base.as_object_mut(), v.as_object()) { - for (k, x) in o { - let mut trial = b.clone(); - trial.insert(k.clone(), x.clone()); - if serde_json::from_value::(Value::Object(trial)).is_ok() { - b.insert(k.clone(), x.clone()); - } - } - } - serde_json::from_value(base).unwrap_or_default() - } - }; - p.normalize(); - p - } - - pub fn to_json(&self) -> String { - serde_json::to_string_pretty(self).unwrap_or_default() - } - - /// Clamp values into their valid ranges and fill missing labels. - pub fn normalize(&mut self) { - self.version = PREFS_VERSION; - self.comp_presets.retain(CompPreset::is_valid); - let mut seen = std::collections::HashSet::new(); - self.effects_favorites.retain(|id| !id.is_empty() && seen.insert(id.clone())); - self.effects_favorites.truncate(MAX_EFFECTS_FAVORITES); - if !is_choice(WINDOW_GRAPHICS, &self.startup.window_graphics) { - self.startup.window_graphics = Startup::default().window_graphics; - } - let g = &mut self.general; - if !is_choice(LANGUAGES, &g.language) { - g.language = General::default().language; - } - g.undo_levels = g.undo_levels.clamp(1, 99); - g.path_point_size = g.path_point_size.clamp(3, 20); - g.recent_items = g.recent_items.clamp(1, 30); - let a = &mut self.auto_save; - a.interval_minutes = a.interval_minutes.clamp(1, 240); - a.max_versions = a.max_versions.clamp(1, 99); - let ap = &mut self.appearance; - let mut seen = std::collections::BTreeSet::new(); - ap.favorite_themes.retain(|id| is_choice(THEMES, id) && seen.insert(id.clone())); - if !is_choice(APPEARANCE_MODES, &ap.appearance_mode) { - ap.appearance_mode = Appearance::default().appearance_mode; - } - if !is_choice(DARK_THEMES, &ap.dark_theme) { - ap.dark_theme = Appearance::default().dark_theme; - } - if !is_choice(LIGHT_THEMES, &ap.light_theme) { - ap.light_theme = Appearance::default().light_theme; - } - // The legacy single theme mirrors the theme a fixed mode shows (Auto depends on the - // system, so it keeps the last one). - match ap.appearance_mode.as_str() { - "dark" => ap.theme = ap.dark_theme.clone(), - "light" => ap.theme = ap.light_theme.clone(), - _ if !is_choice(THEMES, &ap.theme) => ap.theme = ap.dark_theme.clone(), - _ => {} - } - self.appearance.brightness = self.appearance.brightness.clamp(-1.0, 1.0); - self.appearance.ui_scale = self.appearance.ui_scale.clamp(75, 200); - let defaults = default_labels(); - self.labels.truncate(defaults.len()); - for d in defaults.iter().skip(self.labels.len()) { - self.labels.push(d.clone()); - } - for (l, d) in self.labels.iter_mut().zip(&defaults) { - if parse_hex(&l.color).is_none() { - l.color = d.color.clone(); - } - } - let m = &mut self.memory; - m.layer_cache_mb = m.layer_cache_mb.clamp(64, 1 << 20); - m.media_cache_mb = m.media_cache_mb.clamp(64, 1 << 20); - m.preview_cache_mb = m.preview_cache_mb.clamp(64, 1 << 20); - self.grids.grid_spacing = self.grids.grid_spacing.clamp(1.0, 10_000.0); - let n = self.general.recent_items as usize; - self.recent_projects.truncate(n); - } - - /// The value at a dotted key (`general.undoLevels`, `labels.3.color`); `""` = everything. - pub fn get(&self, key: &str) -> Option { - let v = serde_json::to_value(self).ok()?; - if key.is_empty() { - return Some(v); - } - let ptr = format!("/{}", key.replace('.', "/")); - v.pointer(&ptr).cloned() - } - - /// Set the value at a dotted key. The key must exist and the value must have its type. - pub fn set(&mut self, key: &str, value: Value) -> Result<(), String> { - if let Some(c) = choices(key) - && !value.as_str().is_some_and(|v| is_choice(c, v)) - { - let all: Vec<&str> = c.iter().map(|(_, v)| *v).collect(); - return Err(format!("`{key}` expects one of {}", all.join(", "))); - } - let favorites = if key == "appearance.favoriteThemes" { - Some(&value) - } else if key == "appearance" { - value.get("favoriteThemes") - } else { - None - }; - if let Some(favorites) = favorites { - let values = favorites.as_array().ok_or("favorite themes must be a list")?; - let mut seen = std::collections::BTreeSet::new(); - for value in values { - let id = value.as_str().ok_or("favorite theme must be a string")?; - if !is_choice(THEMES, id) || !seen.insert(id) { - return Err("unknown or duplicate favorite theme".into()); - } - } - } - // Older clients set the single theme (by key, or inside the page object): it still picks - // a visible theme, fixing the mode to its family. - let legacy_theme = key == "appearance.theme" || (key == "appearance" && value.get("theme").is_some() && value.get("appearanceMode").is_none()); - let mut v = serde_json::to_value(&*self).map_err(|e| e.to_string())?; - let ptr = format!("/{}", key.replace('.', "/")); - let slot = v.pointer_mut(&ptr).ok_or_else(|| format!("unknown setting `{key}`"))?; - let value = coerce(slot, value).ok_or_else(|| format!("`{key}` expects a {}", kind_name(slot)))?; - *slot = value; - let mut next: Prefs = serde_json::from_value(v).map_err(|e| format!("`{key}`: {e}"))?; - let (a, b) = (&self.appearance, &next.appearance); - let only_theme_changed = a.theme != b.theme && a.appearance_mode == b.appearance_mode && a.dark_theme == b.dark_theme && a.light_theme == b.light_theme; - if legacy_theme || only_theme_changed { - next.appearance.select_theme(); - } - next.normalize(); - *self = next; - Ok(()) - } - - /// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent and - /// the saved composition presets are kept. - pub fn reset(&mut self, page: Option<&str>) -> Result<(), String> { - let d = Prefs::default(); - let Some(page) = page else { - *self = Prefs { - recent_projects: std::mem::take(&mut self.recent_projects), - recent_footage: std::mem::take(&mut self.recent_footage), - recent_presets: std::mem::take(&mut self.recent_presets), - recent_fonts: std::mem::take(&mut self.recent_fonts), - effects_favorites: std::mem::take(&mut self.effects_favorites), - preview: std::mem::take(&mut self.preview), - comp_presets: std::mem::take(&mut self.comp_presets), - ..d - }; - return Ok(()); - }; - let keys: Vec<&str> = match page { - "project" => vec!["project", "autoSave"], - p => vec![section_key(p).ok_or_else(|| format!("unknown settings page `{p}`"))?], - }; - let dv = serde_json::to_value(&d).map_err(|e| e.to_string())?; - for k in keys { - if let Some(x) = dv.get(k) { - self.set(k, x.clone())?; - } - } - Ok(()) - } - - /// Display name of a label (Labels settings). - pub fn label_name(&self, l: Label) -> String { - match label_index(l) { - Some(i) => self.labels.get(i).map(|p| p.name.clone()).unwrap_or_else(|| l.name().to_string()), - None => l.name().to_string(), - } - } - - /// sRGB colour of a label (Labels settings). - pub fn label_rgb(&self, l: Label) -> [u8; 3] { - label_index(l).and_then(|i| self.labels.get(i)).and_then(|p| parse_hex(&p.color)).unwrap_or(l.rgb()) - } - - /// A label by its built-in name or its (renamed) display name. - pub fn label_from_name(&self, s: &str) -> Option