Browse / github.com/storytold/soundcraft / patch
feat(ui): add complete Preferences window and live settings
[SoundCraft] Setup > Preferences menu fails to open when clicked ### Description App: SoundCraft (Other) Version: 0.6.1 Steps to Reproduce: 1. Launch SoundCraft. 2. Click on Setup in the top menu bar. 3. Select Preferences (or press Ctrl + , / Cmd + ,). Expected Behavior: The Preferences dialog window should open on screen, allowing configuration of settings such as plugin directory paths. Actual Behavior: Nothing opens or responds when Preferences is clicked; the main application UI remains active without displaying the settings dialog. ### Ticket details | | | |---|---| | **App** | SoundCraft | | **Reported by** | Rhythmic Chaos (Discord usernam
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- SlyLaM (imported from pull request #819, open)
- Base
1e6310f85ba4e289d9078b52202948c9d398ff44- Size
- +1528 −53 in 10 files
- Review
- pending
- Shared
- 2026-10-28
- Id
6be68cf665bb042dd47995be
How it went
No other operator has reported using it yet.
Diff
raw · apps/soundcraft/src/native_menu.rs crates/engine/src/lib.rs crates/ui-egui/src/app.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/panels/extra_windows.rs crates/ui-egui/src/preferences.rs crates/ui-egui/src/shortcuts.rs crates/ui-egui/src/tests.rs docs/ui-parity.md
@@ -33,6 +33,7 @@ impl NativeMenu { let mut native = Self { menu, entries: Vec::new(), events, last_sync: None }; let app_menu = Submenu::new("SoundCraft", true); native.add_item(app, &app_menu, "About SoundCraft", "", Some("window.about".into()))?;+ native.add_item(app, &app_menu, "Preferences...", "", Some("setup.preferences".into()))?; native.add_item(app, &app_menu, "Session Info", "", Some("window.session_info".into()))?; native.add_item(app, &app_menu, "Session Audio Health", "", Some("window.audio_health".into()))?; app_menu.append(&PredefinedMenuItem::separator())?;@@ -94,8 +95,8 @@ impl NativeMenu { && let Some(command) = &entry.command && menus::item_state(app, &entry.path, Some(command)).0 {- if entry.path.is_empty() && command.starts_with("window.") {- // The application menu's About / Session Info open their window (never close it).+ if entry.path.is_empty() && (command.starts_with("window.") || command == "setup.preferences") {+ // Application-menu window entries open their window rather than toggling it. let _ = app.run(command, serde_json::json!({"value": true})); } else { menus::invoke_menu(app, command, &entry.path);@@ -322,6 +322,18 @@ impl Engine { self.undo.iter().map(|h| h.label.clone()).collect() } + /// Change the retained document-history depth, dropping the oldest excess entries now.+ pub fn set_history_limit(&mut self, limit: usize) {+ let limit = limit.max(1);+ self.history_limit = limit;+ if self.undo.len() > limit {+ self.undo.drain(..self.undo.len() - limit);+ }+ if self.redo.len() > limit {+ self.redo.drain(..self.redo.len() - limit);+ }+ }+ pub fn undo(&mut self) -> bool { let Some(h) = self.undo.pop() else { return false }; let cur = std::mem::replace(&mut self.doc, h.doc);@@ -589,6 +601,20 @@ mod merge_tests { mod dirty_tests { use super::*; + #[test]+ fn reducing_the_history_limit_drops_the_oldest_steps_immediately() {+ let mut e = Engine::default();+ for _ in 0..4 {+ e.execute("track.new", &json!({})).unwrap();+ }+ assert_eq!(e.undo_history().len(), 4);+ e.set_history_limit(2);+ assert_eq!(e.undo_history().len(), 2);+ assert!(e.undo());+ assert!(e.undo());+ assert!(!e.undo());+ }+ #[test] fn selecting_locating_and_zooming_leave_the_session_clean() { let mut e = crate::demo::demo_engine();@@ -53,6 +53,7 @@ impl SoundApp { ops: ops_windows::OpsState::default(), extra: extra_windows::ExtraState::default(), palette: palette::PaletteState::default(),+ preferences: preferences::PreferencesState::default(), focus_mix: false, edit_scroll_into_view: None, mix_scroll_into_view: None,@@ -125,6 +126,8 @@ impl SoundApp { /// Each host iteration starts with `raw_input_hook`; its editor-poll admission prevents an /// empty queue on a discarded pass from ending a still-active plugin gesture. pub fn logic(&mut self, ctx: &egui::Context) {+ self.ui.undo_levels = self.ui.undo_levels.clamp(1, 64);+ self.engine.set_history_limit(self.ui.undo_levels); // Fonts set now take effect next frame, so draw only from the frame after. if !self.fonts_ready { if self.fonts_installed {@@ -322,6 +325,7 @@ impl SoundApp { } panels::floating(self, &ctx); windows::show(self, &ctx);+ preferences::show(self, &ctx); video_window::show(self, &ctx); ops_windows::show(self, &ctx); extra_windows::show(self, &ctx);@@ -18,6 +18,7 @@ pub mod menus; pub mod mix_window; pub mod palette; pub mod panels;+pub mod preferences; pub mod shortcuts; pub mod theme; pub mod toolbar;@@ -105,6 +106,13 @@ pub struct UiState { pub show_workspace: bool, pub show_configurations: bool, pub show_playback_engine: bool,+ /// Setup › Preferences. The selected page is kept per machine; the search and staged values+ /// live in [`preferences::PreferencesState`] and are discarded when the window closes.+ pub show_preferences: bool,+ pub preferences_page: preferences::PreferencesPage,+ pub preferences_window_pos: Option<[f32; 2]>,+ pub reduce_visual_effects: preferences::ReduceVisualEffects,+ pub undo_levels: usize, /// The audio device choice (Setup › Playback Engine), kept with the preferences: per machine. pub audio: soundcraft_playback::AudioConfig, /// The Mix window lives in its own native window (Window › Mix in Separate Window); the main@@ -173,6 +181,11 @@ impl Default for UiState { show_workspace: false, show_configurations: false, show_playback_engine: false,+ show_preferences: false,+ preferences_page: preferences::PreferencesPage::default(),+ preferences_window_pos: None,+ reduce_visual_effects: preferences::ReduceVisualEffects::default(),+ undo_levels: 64, audio: soundcraft_playback::AudioConfig::default(), mix_separate: false, show_io_setup: false,@@ -292,6 +305,7 @@ pub struct SoundApp { pub ops: ops_windows::OpsState, pub extra: extra_windows::ExtraState, pub palette: palette::PaletteState,+ pub preferences: preferences::PreferencesState, /// Bring the separate Mix window to the front on the next frame. pub(crate) focus_mix: bool, /// Scroll Into View: the track each window brings to its top (Edit) or left (Mix) when it@@ -75,6 +75,8 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[ ("ui.bounce_dialog", "Bounce Mix…", "", None), ("ui.set", "Set UI State", "", None), ("ui.theme", "Appearance", "", None),+ ("ui.reduce_visual_effects", "Reduce Visual Effects", "", None),+ ("ui.undo_levels", "Levels of Undo", "", None), ]; /// Extra catalog mappings handled by the UI layer.@@ -225,56 +227,65 @@ pub fn tree() -> Vec<MenuNode> { pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::current();+ let menu_entries_enabled = !app.ui.show_preferences;+ let preferences_enabled = preferences_menu_enabled(app); egui::Panel::top("menu_bar").exact_size(24.0).frame(egui::Frame::NONE.fill(t.menu_bar).inner_margin(egui::Margin::symmetric(8, 2))).show( ui, |ui| { egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("SoundCraft", |ui| {- if ui.button("About SoundCraft").clicked() {- app.ui.show_about = true;- }- if ui.button("Check for Updates…").clicked() {- let _ = app.run("app.checkForUpdates", json!({}));- }- if ui.button("Session Info").clicked() {- app.ui.show_session_info = true;- }- if ui.button("Session Audio Health").clicked() {- let _ = app.run("window.audio_health", json!({"value": true}));- }- ui.separator();- if ui.button("Quit").clicked() {- let _ = crate::dialogs::run_or_ask(app, "app.quit", json!({}));- }+ ui.add_enabled_ui(menu_entries_enabled, |ui| {+ if ui.button("About SoundCraft").clicked() {+ app.ui.show_about = true;+ }+ if ui.add_enabled(preferences_enabled, egui::Button::new("Preferences...")).clicked() {+ let _ = app.run("setup.preferences", json!({"value": true}));+ }+ if ui.button("Check for Updates…").clicked() {+ let _ = app.run("app.checkForUpdates", json!({}));+ }+ if ui.button("Session Info").clicked() {+ app.ui.show_session_info = true;+ }+ if ui.button("Session Audio Health").clicked() {+ let _ = app.run("window.audio_health", json!({"value": true}));+ }+ ui.separator();+ if ui.button("Quit").clicked() {+ let _ = crate::dialogs::run_or_ask(app, "app.quit", json!({}));+ }+ }); }); let extra = ui_aliases(); for root in tree() { ui.menu_button(&root.label, |ui| {- for c in &root.children {- menu_node(app, ui, c, &extra);- if c.path == "File > Open" {- crate::recent::menu(app, ui);+ ui.add_enabled_ui(menu_entries_enabled, |ui| {+ for c in &root.children {+ menu_node(app, ui, c, &extra);+ if c.path == "File > Open" {+ crate::recent::menu(app, ui);+ }+ if c.path == "Track > New..." {+ instrument_menu(app, ui);+ } }- if c.path == "Track > New..." {- instrument_menu(app, ui);+ if root.label == "AudioSuite" {+ ui.separator();+ ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim)); }- }- if root.label == "AudioSuite" {- ui.separator();- ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim));- }- if root.label == "Window" {- ui.separator();- let mut separate = app.ui.mix_separate;- if ui- .checkbox(&mut separate, "Mix in Separate Window")- .on_hover_text("Show Edit and Mix at once, e.g. one per monitor")- .changed()- {- let _ = run_ui_command(app, "window.mix_separate", &json!({"value": separate}));- ui.close();+ if root.label == "Window" {+ ui.separator();+ let mut separate = app.ui.mix_separate;+ if ui+ .checkbox(&mut separate, "Mix in Separate Window")+ .on_hover_text("Show Edit and Mix at once, e.g. one per monitor")+ .changed()+ {+ let _ = run_ui_command(app, "window.mix_separate", &json!({"value": separate}));+ ui.close();+ } }- }+ }); }); } let title = format!(@@ -338,18 +349,31 @@ pub fn command_for_path(path: &str, extra: &[(&str, &str)]) -> Option<String> { /// Enabled and checked state shared by all menu presentations. pub fn item_state(app: &SoundApp, path: &str, id: Option<&str>) -> (bool, bool) {- let enabled = match id {- Some(i)- if i.starts_with("window.") || i.starts_with("view.mix_section") || matches!(i, "audiosuite.process" | "ui.dock" | "ui.dock.reset") =>- {- true+ let enabled = if app.ui.show_preferences {+ false+ } else {+ match id {+ Some("setup.preferences") => preferences_menu_enabled(app),+ Some(i)+ if i.starts_with("window.")+ || i.starts_with("view.mix_section")+ || matches!(i, "audiosuite.process" | "ui.dock" | "ui.dock.reset") =>+ {+ true+ }+ Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok() && !app.engine.blocked_while_recording(i)),+ None => false, }- Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok() && !app.engine.blocked_while_recording(i)),- None => false, }; (enabled, checked_state(app, path, id)) } +/// Preferences cannot replace audio resources while transport is active or another Preferences+/// instance is already modal.+fn preferences_menu_enabled(app: &SoundApp) -> bool {+ !app.ui.show_preferences && !app.is_playing() && !app.engine.transport.recording+}+ fn checked_state(app: &SoundApp, path: &str, id: Option<&str>) -> bool { let e = &app.engine.session().edit; match id {@@ -469,6 +493,10 @@ pub fn live_label(app: &SoundApp, id: Option<&str>) -> Option<String> { /// Menu-style invocation: may open a dialog. pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) {+ if id == "setup.preferences" {+ let _ = app.run(id, json!({"value": true}));+ return;+ } if let Some((_, w)) = MENU_WINDOWS.iter().find(|(p, _)| *p == path) && w.starts_with("window.") {@@ -582,6 +610,15 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result< "window.midi_ops" => toggle(&mut app.ui.show_midi_ops), "window.rtp" => toggle(&mut app.ui.show_rtp), "window.playback_engine" => toggle(&mut app.ui.show_playback_engine),+ // The engine catalog already exposes this command. Handling it here keeps the menu,+ // control channel and scripted callers on the same public command id.+ "setup.preferences" => {+ let result = toggle(&mut app.ui.show_preferences);+ if !app.ui.show_preferences {+ app.preferences.discard_draft();+ }+ result+ } "window.io_setup" => toggle(&mut app.ui.show_io_setup), "window.shortcuts" => toggle(&mut app.ui.show_shortcuts), "window.color_palette" => toggle(&mut app.ui.show_color_palette),@@ -635,6 +672,8 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result< app.ui.show_undo_history = false; app.ui.show_session_info = false; app.ui.show_audio_health = false;+ app.ui.show_preferences = false;+ app.preferences.discard_draft(); app.ui.show_automation = false; app.ui.plugin_windows.clear(); app.ui.audiosuite = None;@@ -689,6 +728,23 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result< app.ui.theme = mode; json!({"mode": mode.id()}) }+ "ui.reduce_visual_effects" => {+ let Some(mode) = p.get("mode").and_then(Value::as_str) else {+ return Some(Ok(json!({"mode": app.ui.reduce_visual_effects.id()})));+ };+ let Some(mode) = crate::preferences::ReduceVisualEffects::parse(mode) else {+ return Some(Err(format!("unknown visual-effects mode `{mode}` (never, playback or always)")));+ };+ app.ui.reduce_visual_effects = mode;+ json!({"mode": mode.id()})+ }+ "ui.undo_levels" => {+ if let Some(levels) = p.get("levels").and_then(Value::as_u64) {+ app.ui.undo_levels = usize::try_from(levels).unwrap_or(64).clamp(1, 64);+ app.engine.set_history_limit(app.ui.undo_levels);+ }+ json!({"levels": app.ui.undo_levels})+ } "ui.set" => match serde_json::to_value(&app.ui) { Ok(mut cur) => { if let (Some(obj), Some(patch)) = (cur.as_object_mut(), p.as_object()) {@@ -701,8 +757,14 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result< if let Err(error) = crate::docking::validate(&ns.docking).and_then(|()| crate::docking::validate_mix(&ns.mix_docking)) { return Some(Err(error)); }+ let closed_preferences = app.ui.show_preferences && !ns.show_preferences; crate::docking::cancel_replaced_state(app, &ns); app.ui = ns;+ app.ui.undo_levels = app.ui.undo_levels.clamp(1, 64);+ app.engine.set_history_limit(app.ui.undo_levels);+ if closed_preferences {+ app.preferences.discard_draft();+ } json!({"ui": app.ui}) } Err(e) => return Some(Err(format!("bad ui state: {e}"))),@@ -726,6 +788,49 @@ mod tests { use super::*; use crate::{Services, SoundApp}; + /// Preferences menu entries follow transport safety and the modal window locks every menu.+ #[test]+ fn preferences_menu_state_tracks_transport_and_modality() {+ let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());+ assert!(item_state(&app, "Setup > Preferences...", Some("setup.preferences")).0);++ app.engine.transport.playing = true;+ assert!(!item_state(&app, "Setup > Preferences...", Some("setup.preferences")).0);+ app.engine.transport.playing = false;+ app.engine.transport.recording = true;+ assert!(!item_state(&app, "Setup > Preferences...", Some("setup.preferences")).0);++ app.engine.transport.recording = false;+ app.ui.show_preferences = true;+ assert!(!item_state(&app, "Setup > Preferences...", Some("setup.preferences")).0);+ assert!(!item_state(&app, "Window > About", Some("window.about")).0);+ assert!(!item_state(&app, "File > Save Session", Some("session.save")).0);+ }++ /// Re-selecting Preferences through either menu keeps its modal window open.+ #[test]+ fn preferences_menu_always_opens_instead_of_toggling() {+ let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());+ invoke_menu(&mut app, "setup.preferences", "Setup > Preferences...");+ assert!(app.ui.show_preferences);+ invoke_menu(&mut app, "setup.preferences", "Setup > Preferences...");+ assert!(app.ui.show_preferences);+ }++ /// Generic UI-state updates clamp and immediately synchronize the engine history limit.+ #[test]+ fn ui_set_synchronizes_undo_levels_immediately() {+ let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());++ let result = app.run("ui.set", json!({"undo_levels": 0})).unwrap();+ assert_eq!(result["ui"]["undo_levels"], 1);+ assert_eq!((app.ui.undo_levels, app.engine.history_limit), (1, 1));++ let result = app.run("ui.set", json!({"undo_levels": 100})).unwrap();+ assert_eq!(result["ui"]["undo_levels"], 64);+ assert_eq!((app.ui.undo_levels, app.engine.history_limit), (64, 64));+ }+ #[test] fn mix_view_aliases_are_allocated_once() { let first = ui_aliases();@@ -16,7 +16,6 @@ pub struct ExtraState { pub show_ui_customization: bool, pub auto_update_config: bool, pub ui_scale: f32,- pub reduce_motion: bool, pub tooltips: bool, last_auto_update: f64, applied: Option<(f32, bool)>,@@ -29,7 +28,6 @@ impl Default for ExtraState { show_ui_customization: false, auto_update_config: false, ui_scale: 1.0,- reduce_motion: false, tooltips: true, last_auto_update: 0.0, applied: None,@@ -59,7 +57,12 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } fn apply_customization(app: &mut SoundApp, ctx: &egui::Context) {- let want = (app.extra.ui_scale.clamp(0.75, 1.5), app.extra.reduce_motion);+ let reduce = match app.ui.reduce_visual_effects {+ crate::preferences::ReduceVisualEffects::Never => false,+ crate::preferences::ReduceVisualEffects::Playback => app.is_playing(),+ crate::preferences::ReduceVisualEffects::Always => true,+ };+ let want = (app.extra.ui_scale.clamp(0.75, 1.5), reduce); // Rescaling mid-drag would move the Interface scale slider out from under the pointer. if app.extra.applied == Some(want) || ctx.input(|i| i.pointer.any_down()) { return;@@ -163,7 +166,17 @@ fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) { .custom_formatter(|v, _| format!("{:.0} %", v * 100.0)) .text("Interface scale"), );- crate::widgets::checkbox(ui, &mut app.extra.reduce_motion, "Reduce motion");+ let mut chosen_effects = None;+ craft_ui::ComboBox::from_label("Reduce visual effects").selected_text(app.ui.reduce_visual_effects.label()).show_ui(ui, |ui| {+ for mode in crate::preferences::ReduceVisualEffects::ALL {+ if ui.selectable_label(app.ui.reduce_visual_effects == mode, mode.label()).clicked() {+ chosen_effects = Some(mode);+ }+ }+ });+ if let Some(mode) = chosen_effects {+ let _ = app.run("ui.reduce_visual_effects", json!({"mode": mode.id()}));+ } let mut chosen = None; craft_ui::ComboBox::from_label("Theme").selected_text(app.ui.theme.label()).show_ui(ui, |ui| { for mode in crate::theme::ThemeMode::ALL {@@ -179,7 +192,7 @@ fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) { ui.add_space(4.0); if ui.button("Reset").clicked() { app.extra.ui_scale = 1.0;- app.extra.reduce_motion = false;+ let _ = app.run("ui.reduce_visual_effects", json!({"mode": "never"})); let _ = app.run("ui.theme", json!({"mode": crate::theme::ThemeMode::default().id()})); } },new file mode 100644@@ -0,0 +1,1308 @@+//! SoundCraft / Setup › Preferences: the complete settings surface, including placeholders for features whose+//! underlying behaviour does not exist yet. A placeholder is never interactive: enabling a row+//! requires a real command and persistence policy first.++use crate::SoundApp;+use crate::theme::{ThemeMode, Tokens};+use egui::{Key, Modifiers, RichText, Stroke, vec2};+use serde_json::json;++const PREFERENCES_WINDOW_WIDTH: f32 = 900.0;+const PREFERENCES_WINDOW_HEIGHT: f32 = 945.0;+const PREFERENCES_BODY_HEIGHT: f32 = 800.0;+const PREFERENCES_SCREEN_MARGIN: f32 = 24.0;+const PREFERENCES_MIN_HEIGHT: f32 = 320.0;++/// One of the pages exposed by the Preferences window, in shortcut order.+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]+#[serde(rename_all = "snake_case")]+pub enum PreferencesPage {+ #[default]+ Display,+ Operation,+ Editing,+ Mixing,+ Metering,+ Processing,+ Midi,+ Collaboration,+ Synchronization,+}++/// Controls when optional interface animation and decoration are reduced.+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]+#[serde(rename_all = "snake_case")]+pub enum ReduceVisualEffects {+ #[default]+ Never,+ Playback,+ Always,+}++impl ReduceVisualEffects {+ pub const ALL: [Self; 3] = [Self::Never, Self::Playback, Self::Always];++ /// Returns the text shown for this mode in preference selectors.+ pub const fn label(self) -> &'static str {+ match self {+ Self::Never => "Never",+ Self::Playback => "Only During Playback",+ Self::Always => "Always",+ }+ }++ /// Returns the stable command and persistence identifier for this mode.+ pub const fn id(self) -> &'static str {+ match self {+ Self::Never => "never",+ Self::Playback => "playback",+ Self::Always => "always",+ }+ }++ /// Parses a command or persistence identifier into a visual-effects mode.+ pub fn parse(id: &str) -> Option<Self> {+ match id {+ "never" => Some(Self::Never),+ "playback" => Some(Self::Playback),+ "always" => Some(Self::Always),+ _ => None,+ }+ }+}++impl PreferencesPage {+ pub const ALL: [Self; 9] = [+ Self::Display,+ Self::Operation,+ Self::Editing,+ Self::Mixing,+ Self::Metering,+ Self::Processing,+ Self::Midi,+ Self::Collaboration,+ Self::Synchronization,+ ];++ /// Returns the page title shown in the tab strip.+ pub const fn label(self) -> &'static str {+ match self {+ Self::Display => "Display",+ Self::Operation => "Operation",+ Self::Editing => "Editing",+ Self::Mixing => "Mixing",+ Self::Metering => "Metering",+ Self::Processing => "Processing",+ Self::Midi => "MIDI",+ Self::Collaboration => "Collaboration",+ Self::Synchronization => "Synchronization",+ }+ }++ /// Returns the static section catalog belonging to this page.+ const fn sections(self) -> &'static [Section] {+ match self {+ Self::Display => DISPLAY,+ Self::Operation => OPERATION,+ Self::Editing => EDITING,+ Self::Mixing => MIXING,+ Self::Metering => METERING,+ Self::Processing => PROCESSING,+ Self::Midi => MIDI,+ Self::Collaboration => COLLABORATION,+ Self::Synchronization => SYNCHRONIZATION,+ }+ }+}++/// Values edited in the window before the user applies or cancels them.+#[derive(Debug, Clone, Copy)]+struct PreferencesDraft {+ theme: ThemeMode,+ reduce_visual_effects: ReduceVisualEffects,+ undo_levels: usize,+ theme_edited: bool,+ reduce_visual_effects_edited: bool,+ undo_levels_edited: bool,+}++impl PreferencesDraft {+ /// Starts a clean draft from the current live, per-machine settings.+ fn from_live(app: &SoundApp) -> Self {+ Self {+ theme: app.ui.theme,+ reduce_visual_effects: app.ui.reduce_visual_effects,+ undo_levels: app.ui.undo_levels,+ theme_edited: false,+ reduce_visual_effects_edited: false,+ undo_levels_edited: false,+ }+ }++ /// Pulls in scripted setting changes while retaining fields edited in this window.+ fn sync_unedited_from_live(&mut self, app: &SoundApp) {+ if !self.theme_edited {+ self.theme = app.ui.theme;+ }+ if !self.reduce_visual_effects_edited {+ self.reduce_visual_effects = app.ui.reduce_visual_effects;+ }+ if !self.undo_levels_edited {+ self.undo_levels = app.ui.undo_levels;+ }+ }++ /// Remembers which values the user changed during the current UI frame.+ fn remember_user_edits(&mut self, before: Self) {+ self.theme_edited |= self.theme != before.theme;+ self.reduce_visual_effects_edited |= self.reduce_visual_effects != before.reduce_visual_effects;+ self.undo_levels_edited |= self.undo_levels != before.undo_levels;+ }+}++/// Transient Preferences-window state that is never stored in a session.+#[derive(Debug, Default)]+pub struct PreferencesState {+ search: String,+ draft: Option<PreferencesDraft>,+ popup_open: bool,+}++impl PreferencesState {+ /// Drops unapplied values and search text when the window is closed by any route.+ pub(crate) fn discard_draft(&mut self) {+ self.draft = None;+ self.search.clear();+ self.popup_open = false;+ }+}++/// A declarative preference control, including disabled placeholders without a backend yet.+#[derive(Clone, Copy)]+enum Control {+ Check(&'static str),+ Choice(&'static str, &'static str),+ ChoiceOptions(&'static str, &'static str, &'static [&'static str]),+ Value(&'static str, &'static str),+ Button(&'static str),+ Radios(&'static str, &'static [&'static str]),+ Theme,+ ReduceVisualEffects,+ UndoLevels,+}++impl Control {+ /// Returns the user-facing name used by the control and search index.+ const fn label(self) -> &'static str {+ match self {+ Self::Check(label)+ | Self::Button(label)+ | Self::Choice(label, _)+ | Self::ChoiceOptions(label, _, _)+ | Self::Value(label, _)+ | Self::Radios(label, _) => label,+ Self::Theme => "UI Theme",+ Self::ReduceVisualEffects => "Reduce Visual Effects",+ Self::UndoLevels => "Levels of Undo",+ }+ }++ /// Builds the searchable text from a control's label, value and available choices.+ fn search_text(self) -> String {+ match self {+ Self::Choice(label, value) | Self::Value(label, value) => format!("{label} {value}"),+ Self::ChoiceOptions(label, value, values) => format!("{label} {value} {}", values.join(" ")),+ Self::Radios(label, values) => format!("{label} {}", values.join(" ")),+ Self::ReduceVisualEffects => format!("{} Never Only During Playback Always", self.label()),+ _ => self.label().to_string(),+ }+ }+}++/// A titled group of controls within a preference page.+#[derive(Clone, Copy)]+struct Section {+ title: &'static str,+ controls: &'static [Control],+}++const DISPLAY: &[Section] = &[+ Section {+ title: "Basics",+ controls: &[+ Control::Check("Track Position Numbers Stay with Hidden Tracks"),+ Control::Check("Function Tool Tips"),+ Control::Check("Detailed Tool Tips"),+ Control::Value("Edit Window Default Length", "00:00:00:00"),+ Control::Choice("Organize Plugin Menus By", "Category & Manufacturer"),+ Control::Choice("Organize Track I/O Menus By", "Type"),+ Control::Check("Auto-Switch Input Language"),+ Control::Choice("Language", "English"),+ Control::Check("Default Automatic Naming to English"),+ Control::Theme,+ Control::ReduceVisualEffects,+ ],+ },+ Section {+ title: "General Color",+ controls: &[+ Control::Check("MIDI Note Color Shows Velocity"),+ Control::Choice("Default Track Color Coding", "Track Type"),+ Control::Choice("Default Clip Color Coding", "Track Color"),+ ],+ },+ Section {+ title: "Warnings & Dialogs",+ controls: &[+ Control::Button("Reset “Don’t Show This Again” Settings"),+ Control::Check("Show Dashboard Window at Startup"),+ Control::Check("Show Dashboard After Closing a Session"),+ ],+ },+ Section {+ title: "Marker Colors",+ controls: &[+ Control::Check("Randomize Marker Color Upon Creation"),+ Control::Check("Show Marker Lane Colors in Timeline Rulers"),+ Control::Check("Show Marker Lane Colors in Tracks"),+ ],+ },+ Section {+ title: "Pinned Tracks",+ controls: &[+ Control::Check("Auto-Adjust Pinned Area for Tracks"),+ Control::Check("Adjusting Pinned Area Resizes Tracks"),+ Control::Choice("Original Track Height While Pinned", "Small"),+ ],+ },+];++const OPERATION: &[Section] = &[+ Section {+ title: "Transport",+ controls: &[+ Control::Check("Timeline Insertion and Play Start Follow Playback"),+ Control::Check("Edit Insertion Follows Scrub and Shuttle"),+ Control::Check("Audio During Fast Forward and Rewind"),+ Control::Check("Latch Fast Forward and Rewind"),+ Control::Check("Play Start Follows Timeline Selection"),+ Control::Check("Dynamic Transport Follows Selection During Playback"),+ Control::Check("Reserve Voices for Preview in Context"),+ Control::Value("Custom Shuttle Lock Speed", "800 %"),+ Control::Value("Custom Fast Forward/Rewind Speed", "300 %"),+ Control::Value("Back/Forward Amount", "2|0|000"),+ Control::Choice("Back/Forward Time Scale", "Bars|Beats"),+ Control::Radios("Numeric Keypad", &["Classic", "Transport", "Shuttle"]),+ Control::Check("Use Separate Play and Stop Keys"),+ ],+ },+ Section {+ title: "Record",+ controls: &[+ Control::Choice("Record Enable Buttons", "Latch Audio Tracks"),+ Control::Check("Enable Automation in Record"),+ Control::Check("Link Record and Play Faders"),+ Control::Check("Audio Track Record Lock"),+ Control::Check("Transport Record Lock"),+ Control::Check("Disable Input Monitoring when Disarming a Stopped Track"),+ Control::Check("Mute Record-Armed Tracks While Stopped"),+ Control::Check("Enable DSP Mode for Record-Armed or Input-Monitored Tracks"),+ Control::Check("Disable DSP Mode when Disabling Record and Input"),+ Control::Check("Console-Style Input Monitoring Indicators"),+ Control::Check("Create New Playlists when Loop Recording"),+ Control::Radios("Online Recording Starts", &["At Timecode Lock", "At Insertion or Selection"]),+ Control::Value("Destructive Punch File Length", "25 minutes"),+ ],+ },+ Section {+ title: "Auto Backup",+ controls: &[+ Control::Check("Enable Session File Auto Backup"),+ Control::Value("Keep Most Recent Session Backups", "10"),+ Control::Value("Backup Every", "5 minutes"),+ ],+ },+ Section {+ title: "Miscellaneous",+ controls: &[+ Control::Check("Mouse Wheel Scrolling Snaps to Track"),+ Control::Value("Clip Auto Fade In/Out Length", "0 ms"),+ Control::Value("Local Waveform Cache Versions", "0"),+ ],+ },+ Section {+ title: "User Media and Settings Location",+ controls: &[+ Control::Button("Change Documentation and Settings Location"),+ Control::Button("Change Project Media Cache Location"),+ Control::Button("Change Sound Libraries Location"),+ Control::Check("Show Sound Libraries in Workspace Locations"),+ ],+ },+ Section {+ title: "Video",+ controls: &[+ Control::Check("Enable GPU Processing"),+ Control::Check("Prevent Tearing"),+ Control::Choice("Play Start Latency", "1 — Shortest"),+ Control::Choice("Slewing", "Disabled While Looping"),+ ],+ },+ Section {+ title: "Data Collection and Personalization",+ controls: &[Control::Check("Anonymous Usage Data"), Control::Check("Personalize the Experience")],+ },+ Section {+ title: "Logging",+ controls: &[Control::Button("Save Logs"), Control::Button("Reset Logging"), Control::Check("Advanced Video Logging")],+ },+];++const EDITING: &[Section] = &[+ Section {+ title: "Clips",+ controls: &[+ Control::Check("Clip List Selection Follows Edit Selection"),+ Control::Check("Edit Selection Follows Clip List Selection"),+ Control::Check("Auto-Name Separated Clips"),+ Control::Check("Separate Clip Operates on All Related Takes"),+ Control::Check("Send Fully Overlapped Clips to a Playlist While Editing"),+ Control::Check("Send Fully Overlapped Clips to a Playlist While Recording"),+ Control::Check("Tab Respects Breakpoints in Automation Lanes"),+ Control::Check("Double-Click Selects Automation Breakpoints"),+ Control::Value("Clip Gain Nudge Value", "0.5 dB"),+ Control::Choice("Number Keys 1–5 Control", "Zoom Presets"),+ Control::Choice("Double-Clicking an Audio Clip Opens", "Name Dialog"),+ ],+ },+ Section {+ title: "Fades",+ controls: &[+ Control::Value("Fades Dialog Preview Pre-Roll", "3000 ms"),+ Control::Value("Fades Dialog Preview Post-Roll", "3000 ms"),+ Control::Value("Punch Recording Crossfade Length", "0 ms"),+ Control::Check("Preserve Fades when Editing"),+ Control::Check("Auto Accept Adjust Bounds"),+ Control::Choice("Smart Tool Fade Adjustment", "Always On"),+ Control::Button("Default Fade In"),+ Control::Button("Default Fade Out"),+ Control::Button("Default Crossfade"),+ Control::Button("Default Sliced-Loop Fade"),+ ],+ },+ Section {+ title: "Tracks",+ controls: &[+ Control::Check("New Tracks Default to Tick Timebase"),+ Control::Check("Suppress Name Dialog when Creating New Playlists"),+ Control::Check("Show Target Playlists after Sending a Clip Selection"),+ Control::Choice("Add New Playlists", "At Bottom"),+ ],+ },+ Section {+ title: "Zoom Toggle",+ controls: &[+ Control::Choice("Vertical MIDI Zoom", "Selection"),+ Control::Choice("Horizontal Zoom", "Selection"),+ Control::Check("Remove Range Selection after Zooming In"),+ Control::Choice("Track Height", "Fit to Window"),+ Control::Choice("Track View", "No Change"),+ Control::Check("Separate Grid Settings when Zoomed In"),+ Control::Check("Zoom Toggle Follows Edit Selection"),+ ],+ },+ Section {+ title: "Memory Locations",+ controls: &[+ Control::Check("Auto-Name Memory Locations when Playing"),+ Control::Check("Recall Memory Location at Original Track"),+ Control::Check("Marker Target Follows First Selected Track"),+ ],+ },+ Section { title: "Undo", controls: &[Control::UndoLevels] },+];++const MIXING: &[Section] = &[+ Section {+ title: "Setup",+ controls: &[+ Control::Value("Sends Default to", "−∞ dB"),+ Control::Check("Send Pans Default to Follow Main Pan"),+ Control::Check("Use Absolute Pan Linking"),+ Control::Check("Allow Sends to Persist During Low-Latency Monitoring"),+ Control::Choice("Default EQ", "None"),+ Control::Choice("Default EQ Insert Slot", "None"),+ Control::Choice("Default Dynamics", "None"),+ Control::Choice("Default Dynamics Insert Slot", "None"),+ Control::Check("Auto Insert Default Plugins from Control Surfaces"),+ ],+ },+ Section {+ title: "Controllers",+ controls: &[+ Control::Check("Edit Window Follows Banking"),+ Control::Check("Edit Window Follows Selection"),+ Control::Check("Mix Window Follows Banking"),+ Control::Check("Mix Window Follows Selection"),+ Control::Check("Scroll to Track Banks Controllers"),+ Control::Check("Always Fill Channel Strips when Banking"),+ Control::Check("Always Show Assignable Knob Highlight"),+ Control::Value("Touch Timeout", "1000 ms"),+ Control::Choice("EQ and Dynamics Cycling Starts at Insert", "None"),+ ],+ },+ Section {+ title: "Automation",+ controls: &[+ Control::Check("Smooth and Thin Data after Pass"),+ Control::Choice("Degree of Thinning", "Some"),+ Control::Check("Plugin Controls Default to Auto-Enabled"),+ Control::Check("Suppress Automation Write-To Warnings"),+ Control::Check("Latch Switch Controls in Touch Mode"),+ Control::Check("Allow Latch Prime in Stop"),+ Control::Check("Coalesce when Removing Members from a VCA Group"),+ Control::Check("Standard VCA Logic for Group Attributes"),+ Control::Check("Include Sends in Trim Mode"),+ Control::Check("Include Control Changes in Undo Queue"),+ Control::Check("Link Object and Audio Recording"),+ Control::Radios("After Write Pass, Switch To", &["Touch", "Latch", "No Change"]),+ Control::Radios("Coalesce Trim Automation", &["After Every Pass", "On Exiting Trim Mode", "Manually"]),+ Control::Value("Steep Breakpoint Smoothing Time", "0 ms"),+ Control::Value("Automation Match Time", "250 ms"),+ Control::Value("Automation Glide Time", "1000 ms"),+ ],+ },+ Section {+ title: "Delay Compensation",+ controls: &[Control::Choice("Time Mode", "Samples"), Control::Check("Compensate Side Chains"), Control::Check("Compensate Hardware Inputs")],+ },+ Section {+ title: "Routing Coefficients",+ controls: &[+ Control::Choice("New Session Fold Down", "Modern"),+ Control::Choice("New Session 5.x to 7.x/9.x", "Direct to Rear Surrounds"),+ Control::Choice("New Session 7.x/9.x to 5.x", "Downmix to Surrounds"),+ Control::Value("New Session Surround to Stereo", "−3.0 dB"),+ Control::Check("New Session Includes LFE in Fold Down"),+ Control::Choice("Current Session Fold Down", "Modern"),+ Control::Choice("Current Session 5.x to 7.x/9.x", "Direct to Rear Surrounds"),+ Control::Choice("Current Session 7.x/9.x to 5.x", "Downmix to Surrounds"),+ Control::Value("Current Session Surround to Stereo", "−3.0 dB"),+ Control::Check("Current Session Includes LFE in Fold Down"),+ ],+ },+];++const METER_TYPES: &[&str] = &[+ "Sample Peak",+ "Classic",+ "Linear",+ "Linear Extended",+ "RMS",+ "VU",+ "Digital VU",+ "PPM Digital",+ "PPM BBC",+ "PPM Nordic",+ "PPM EBU",+ "PPM DIN",+ "K-12",+ "K-14",+ "K-20",+ "Live Peak",+ "Live RMS",+];++const METERING: &[Section] = &[+ Section {+ title: "Track and Master Meter Types",+ controls: &[+ Control::Check("Track and Master Meter Types Linked"),+ Control::ChoiceOptions("Track Meters", "Digital VU", METER_TYPES),+ Control::ChoiceOptions("Master Meters", "Linear", METER_TYPES),+ ],+ },+ Section {+ title: "Advanced Meter Type Settings",+ controls: &[+ Control::Choice("Meter Type", "Digital VU"),+ Control::Value("Decay", "100 dB in 1.00 s"),+ Control::Value("0 dB Reference", "0 dBFS"),+ Control::Value("Integration Time", "— ms"),+ Control::Value("Color Break High", "−6 dB"),+ Control::Value("Color Break Low", "−20 dB"),+ Control::Button("Reset Advanced Meter Settings"),+ ],+ },+ Section {+ title: "Peak / Clip",+ controls: &[+ Control::Radios("Peak Hold", &["3 Seconds", "Infinite", "None"]),+ Control::Radios("Clip Indication", &["3 Seconds", "Infinite", "None"]),+ Control::Value("Calibration Reference Level", "−18 dB"),+ ],+ },+ Section {+ title: "Display",+ controls: &[+ Control::Check("Show Send Assignment Level Meter"),+ Control::Check("Show Insert Assignment Gain Reduction Meter"),+ Control::Check("Show Track Gain Reduction Meter"),+ Control::Radios(+ "Gain Reduction Meter Type",+ &["Compressor/Limiter Only", "Expander/Gate Only", "Priority Compressor/Limiter", "Priority Expander/Gate", "All — Summed"],+ ),+ ],+ },+];++const PROCESSING: &[Section] = &[+ Section {+ title: "Offline Processing",+ controls: &[Control::Radios("Default Handle Length", &["Whole File", "Length"]), Control::Value("Handle Length", "2.00 seconds")],+ },+ Section {+ title: "Time Compression / Expansion",+ controls: &[Control::Choice("Processor", "Time Stretch"), Control::Choice("Default Settings", "Factory Default")],+ },+ Section {+ title: "Import",+ controls: &[+ Control::Check("Convert Imported WAV Files to Broadcast Wave"),+ Control::Check("Automatically Copy Files on Import"),+ Control::Check("Convert Copied Files to Session Format"),+ Control::Check("Do Not Convert Sample Rate on Import"),+ Control::Check("Import Sliced Loops as Clip Groups"),+ Control::Check("Automatically Create Fades"),+ Control::Radios("Desktop Drag-and-Drop Conforms to Session Tempo", &["No Files", "Tagged Loop Files Only", "All Files"]),+ Control::Choice("Sample Rate Conversion Quality", "Highest"),+ ],+ },+ Section {+ title: "Elastic Audio",+ controls: &[+ Control::Choice("Default Plugin", "Polyphonic"),+ Control::Value("Default Input Gain", "0.0 dB"),+ Control::Check("Enable Elastic Audio on New Tracks"),+ ],+ },+ Section { title: "Commit", controls: &[Control::Radios("Rendered File Bit Depth", &["Always Use 32-bit", "Follow Session Settings"])] },+ Section {+ title: "DSP Management",+ controls: &[Control::Check("Open Unresourced DSP Plugins as Native"), Control::Check("Enable Analog Console Processing in New Sessions")],+ },+ Section { title: "ARA", controls: &[Control::Check("Automatic Analysis on Tick-Based Tracks")] },+ Section {+ title: "Miscellaneous",+ controls: &[+ Control::Choice("Parallel Task Optimization", "High"),+ Control::Check("Display Waveforms in Low Resolution While Loading"),+ Control::Check("Noise Burst Peak Protection"),+ ],+ },+];++const MIDI: &[Section] = &[+ Section {+ title: "Basics",+ controls: &[+ Control::Check("Remove Range Selection after Opening a MIDI or Score Editor"),+ Control::Check("Use MIDI to Tap Tempo"),+ Control::Check("Display Events as Modified by Real-Time Properties"),+ Control::Check("Use F11 for Wait for Note"),+ Control::Check("Automatically Create Click Track in New Sessions"),+ Control::Check("MIDI Input Monitoring Follows Selected Track"),+ Control::Value("Pencil Resolution for Controller Data", "30 ms"),+ Control::Value("Global MIDI Playback Offset", "0 samples"),+ Control::Choice("Double-Clicking a MIDI Clip Opens", "Docked MIDI Editor"),+ Control::Choice("MIDI Merge Release Mode", "Latch Until Next Event"),+ ],+ },+ Section { title: "Note Display", controls: &[Control::Radios("Middle C", &["C3", "C4", "MIDI Note 60"])] },+ Section {+ title: "Delay Compensation for External Devices",+ controls: &[Control::Check("MIDI Beat Clock"), Control::Check("MIDI Timecode"), Control::Check("MIDI Notes and Controllers")],+ },+ Section { title: "MIDI / Score Editor Display", controls: &[Control::Value("Additional Empty Bars in the Score Editor", "8")] },+];++const COLLABORATION: &[Section] = &[+ Section {+ title: "Track Collaboration",+ controls: &[+ Control::Check("New Tracks Are Shared"),+ Control::Check("First Invite Shares All Tracks"),+ Control::Check("Joining a Project Downloads All Shared Tracks"),+ Control::Check("Enable Track Notes Dialog"),+ Control::Check("Automatically Download New Shared Tracks"),+ ],+ },+ Section {+ title: "Cloud Storage Services",+ controls: &[+ Control::Check("Prevent Others from Overwriting an Open Session"),+ Control::Choice("Lock File Extension", ".lock"),+ Control::Check("Keep Unused Folders on Session Close"),+ ],+ },+];++const SYNCHRONIZATION: &[Section] = &[+ Section {+ title: "Machine Control",+ controls: &[+ Control::Check("Machine Chases Memory Location"),+ Control::Check("Machine Follows Edit Insertion and Scrub"),+ Control::Choice("Controlled Device Type", "Non-Linear Device"),+ Control::Check("Machine Cues Intelligently"),+ Control::Check("Stop at Shuttle Speed Zero"),+ Control::Check("Non-Linear Transport Error Suppression"),+ ],+ },+ Section {+ title: "Remote Mode",+ controls: &[+ Control::Check("Ignore Track Arming"),+ Control::Check("Set Servo Lock at Play"),+ Control::Check("Allow External Track-Arm Commands in Local Mode"),+ Control::Check("Allow External Transport Commands in Local Mode"),+ Control::Value("Punch In Frame Offset", "0 frames"),+ Control::Value("Punch Out Frame Offset", "0 frames"),+ Control::Value("Delay after Play Command", "0 frames"),+ ],+ },+ Section {+ title: "Synchronization",+ controls: &[+ Control::Value("Minimum Sync Delay", "30 frames"),+ Control::Value("Delay before Locking to Longitudinal Timecode", "0 frames"),+ Control::Value("Delay before Locking to Serial Timecode", "0 frames"),+ ],+ },+ Section {+ title: "Linked Systems",+ controls: &[+ Control::Check("Transmit Solos"),+ Control::Check("Receive Solos"),+ Control::Check("Solo Independent of Linked State"),+ Control::Check("Audio Engine Errors Stop All Linked Systems"),+ Control::Check("Transmit Play Selections"),+ Control::Check("Receive Play Selections"),+ ],+ },+];++enum Action {+ Apply,+ Cancel,+}++/// Draws the modal Preferences window and applies or discards its staged values.+pub fn show(app: &mut SoundApp, ctx: &egui::Context) {+ let mut open = app.ui.show_preferences;+ if !open {+ app.preferences.discard_draft();+ return;+ }++ let mut draft = app.preferences.draft.unwrap_or_else(|| PreferencesDraft::from_live(app));+ draft.sync_unedited_from_live(app);+ let draft_before_ui = draft;+ let mut search = std::mem::take(&mut app.preferences.search);+ let mut page = app.ui.preferences_page;+ let mut action = None;+ select_page_from_keyboard(ctx, &mut page);+ // A single-line text edit releases focus on Enter in the frame that receives the key. Capture+ // ownership before drawing so Enter in Search cannot also apply and close the window.+ let typing = ctx.egui_wants_keyboard_input();+ // egui closes a ComboBox before this window observes Escape, so preserve the previous frame's+ // popup state to keep the same key from also canceling the complete Preferences window.+ let popup_was_open = app.preferences.popup_open;++ // A transparent middle-layer area makes this a modal window without borrowing the app while+ // the window body is drawn. The Preferences window itself is on the foreground layer.+ let screen = ctx.content_rect();+ let window_height = preferences_window_height(screen.height());+ let body_height = (PREFERENCES_BODY_HEIGHT - (PREFERENCES_WINDOW_HEIGHT - window_height)).max(160.0);+ egui::Area::new(egui::Id::unique("preferences_modal_blocker")).order(egui::Order::Middle).fixed_pos(screen.min).show(ctx, |ui| {+ ui.allocate_exact_size(screen.size(), egui::Sense::click_and_drag());+ });++ let mut window = egui::Window::new("SoundCraft Preferences")+ .id(egui::Id::unique("preferences_window"))+ .open(&mut open)+ .order(egui::Order::Foreground)+ .collapsible(false)+ .resizable(false)+ .fixed_size(vec2(PREFERENCES_WINDOW_WIDTH, window_height));+ if let Some([x, y]) = app.ui.preferences_window_pos {+ window = window.default_pos(egui::pos2(x, y));+ }++ let shown = window.show(ctx, |ui| {+ ui.horizontal(|ui| {+ let (icon_rect, _) = ui.allocate_exact_size(vec2(18.0, 18.0), egui::Sense::hover());+ crate::icons::draw(ui.painter(), icon_rect.shrink(1.0), "zoom", Tokens::current().text_dim);+ let search_label = ui.label("Search");+ let edit = egui::TextEdit::singleline(&mut search)+ .id(egui::Id::unique("preferences_search"))+ .hint_text("Type to search preferences")+ .desired_width(f32::INFINITY);+ ui.add(edit).labelled_by(search_label.id);+ if !search.is_empty() && ui.small_button("Clear").clicked() {+ search.clear();+ }+ });+ ui.add_space(4.0);+ tabs(ui, &mut page, &search);+ ui.separator();++ // Reserve the same body height on every page. Only screens shorter than the normal window+ // get an emergency scroll area, keeping the footer reachable without changing tall-screen+ // layout or making the window resizable.+ let body_top = ui.cursor().top();+ if window_height < PREFERENCES_WINDOW_HEIGHT {+ egui::ScrollArea::vertical()+ .id_salt("preferences_body")+ .max_height(body_height)+ .min_scrolled_height(body_height)+ .auto_shrink([false, false])+ .show(ui, |ui| page_body(ui, page, &search, &mut draft));+ } else {+ page_body(ui, page, &search, &mut draft);+ }+ let used_height = ui.cursor().top() - body_top;+ ui.add_space((body_height - used_height).max(0.0));++ ui.separator();+ ui.horizontal(|ui| {+ ui.add_space(ui.available_width() - 176.0);+ if ui.add_sized([80.0, 24.0], egui::Button::new("Cancel")).clicked() {+ action = Some(Action::Cancel);+ }+ if ui.add_sized([80.0, 24.0], egui::Button::new("OK").fill(Tokens::current().accent_dark)).clicked() {+ action = Some(Action::Apply);+ }+ });+ });+ if let Some(shown) = shown {+ let rect = shown.response.rect;+ if rect.min.x.is_finite() && rect.min.y.is_finite() {+ app.ui.preferences_window_pos = Some([rect.min.x, rect.min.y]);+ }+ }+ draft.remember_user_edits(draft_before_ui);+ app.preferences.popup_open = ctx.any_popup_open();++ if ctx.input(|i| i.key_pressed(Key::Escape)) && !popup_was_open {+ action = Some(Action::Cancel);+ } else if action.is_none() && ctx.input(|i| i.key_pressed(Key::Enter)) && !typing {+ action = Some(Action::Apply);+ }++ if !open && action.is_none() {+ action = Some(Action::Cancel);+ }+ match action {+ Some(Action::Apply) => {+ let _ = app.run("ui.theme", json!({"mode": draft.theme.id()}));+ let _ = app.run("ui.reduce_visual_effects", json!({"mode": draft.reduce_visual_effects.id()}));+ let _ = app.run("ui.undo_levels", json!({"levels": draft.undo_levels}));+ open = false;+ app.preferences.discard_draft();+ }+ Some(Action::Cancel) => {+ open = false;+ app.preferences.discard_draft();+ }+ None => {+ app.preferences.draft = Some(draft);+ app.preferences.search = search;+ }+ }+ app.ui.preferences_page = page;+ app.ui.show_preferences = open;+}++/// Caps the normal fixed window height so its frame and footer remain inside short screens.+fn preferences_window_height(screen_height: f32) -> f32 {+ (screen_height - PREFERENCES_SCREEN_MARGIN).clamp(PREFERENCES_MIN_HEIGHT, PREFERENCES_WINDOW_HEIGHT)+}++/// Draws the fixed-order page tabs and highlights pages containing a search match.+fn tabs(ui: &mut egui::Ui, page: &mut PreferencesPage, search: &str) {+ let t = Tokens::current();+ egui::ScrollArea::horizontal().id_salt("preferences_tabs").show(ui, |ui| {+ ui.horizontal(|ui| {+ for candidate in PreferencesPage::ALL {+ let hit = page_matches(candidate, search);+ let text =+ if !search.is_empty() && hit { RichText::new(candidate.label()).color(t.meter_yellow) } else { RichText::new(candidate.label()) };+ if ui.selectable_label(*page == candidate, text).clicked() {+ *page = candidate;+ }+ }+ });+ });+}++/// Draws one page using its standard two-column section layout.+fn page_body(ui: &mut egui::Ui, page: PreferencesPage, search: &str, draft: &mut PreferencesDraft) {+ if page == PreferencesPage::Mixing {+ mixing_page_body(ui, search, draft);+ return;+ }++ let sections = page.sections();+ ui.columns(2, |columns| {+ for (index, section) in sections.iter().enumerate() {+ let column = index % 2;+ section_ui(&mut columns[column], page, *section, search, draft);+ columns[column].add_space(6.0);+ }+ });+}++/// Draws the Mixing page whose larger sections use explicit column splits.+fn mixing_page_body(ui: &mut egui::Ui, search: &str, draft: &mut PreferencesDraft) {+ ui.columns(2, |columns| {+ section_ui(&mut columns[0], PreferencesPage::Mixing, MIXING[0], search, draft);+ section_ui(&mut columns[1], PreferencesPage::Mixing, MIXING[1], search, draft);+ });+ ui.add_space(6.0);+ section_ui_columns(ui, PreferencesPage::Mixing, MIXING[2], 11, search, draft);+ ui.add_space(6.0);+ section_ui_columns(ui, PreferencesPage::Mixing, MIXING[3], 1, search, draft);+ ui.add_space(6.0);+ section_ui_columns(ui, PreferencesPage::Mixing, MIXING[4], 5, search, draft);+}++/// Draws every control in one section inside its shared frame.+fn section_ui(ui: &mut egui::Ui, page: PreferencesPage, section: Section, search: &str, draft: &mut PreferencesDraft) {+ section_frame(ui, section, search, |ui| {+ for (index, control) in section.controls.iter().copied().enumerate() {+ control_ui(ui, page, section.title, index, control, search, draft);+ }+ });+}++/// Draws a section's title and border, including search-match highlighting.+fn section_frame(ui: &mut egui::Ui, section: Section, search: &str, add_contents: impl FnOnce(&mut egui::Ui)) {+ let t = Tokens::current();+ let section_hit = text_matches(section.title, search);+ let stroke = if !search.is_empty() && section_hit { Stroke::new(1.5, t.meter_yellow) } else { Stroke::new(1.0, t.border) };+ egui::Frame::new().fill(t.panel_bg2).stroke(stroke).corner_radius(3.0).inner_margin(egui::Margin::same(6)).show(ui, |ui| {+ ui.set_min_width((ui.available_width() - 12.0).max(280.0));+ egui::Frame::new().fill(t.toolbar_group).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {+ let title = if !search.is_empty() && section_hit {+ RichText::new(section.title).strong().color(t.meter_yellow)+ } else {+ RichText::new(section.title).strong()+ };+ ui.label(title);+ });+ ui.add_space(3.0);+ add_contents(ui);+ });+}++/// Draws one long section in two columns at a stable catalog index.+fn section_ui_columns(ui: &mut egui::Ui, page: PreferencesPage, section: Section, split_at: usize, search: &str, draft: &mut PreferencesDraft) {+ section_frame(ui, section, search, |ui| {+ let split_at = split_at.min(section.controls.len());+ ui.columns(2, |columns| {+ for (index, control) in section.controls[..split_at].iter().copied().enumerate() {+ control_ui(&mut columns[0], page, section.title, index, control, search, draft);+ }+ for (offset, control) in section.controls[split_at..].iter().copied().enumerate() {+ control_ui(&mut columns[1], page, section.title, split_at + offset, control, search, draft);+ }+ });+ });+}++/// Draws an implemented control or a disabled placeholder for unavailable behavior.+fn control_ui(ui: &mut egui::Ui, page: PreferencesPage, section: &str, index: usize, control: Control, search: &str, draft: &mut PreferencesDraft) {+ let t = Tokens::current();+ let hit = text_matches(&control.search_text(), search);+ let label = |text: &'static str| {+ if !search.is_empty() && hit { RichText::new(text).color(t.meter_yellow) } else { RichText::new(text) }+ };+ let id = ("preference", page, section, index);+ match control {+ Control::Theme => {+ craft_ui::ComboBox::new(id, label("UI Theme")).selected_text(draft.theme.label()).show_ui(ui, |ui| {+ for mode in ThemeMode::ALL {+ ui.selectable_value(&mut draft.theme, mode, mode.label());+ }+ });+ }+ Control::ReduceVisualEffects => {+ craft_ui::ComboBox::new(id, label("Reduce Visual Effects")).selected_text(draft.reduce_visual_effects.label()).show_ui(ui, |ui| {+ for mode in ReduceVisualEffects::ALL {+ ui.selectable_value(&mut draft.reduce_visual_effects, mode, mode.label());+ }+ });+ }+ Control::UndoLevels => {+ ui.horizontal(|ui| {+ ui.label(label("Levels of Undo"));+ ui.add(egui::DragValue::new(&mut draft.undo_levels).range(1..=64));+ });+ }+ Control::Check(text) => {+ let mut value = false;+ ui.add_enabled(false, egui::Checkbox::new(&mut value, label(text))).on_disabled_hover_text("This preference is not available yet.");+ }+ Control::Choice(text, value) | Control::ChoiceOptions(text, value, _) => {+ ui.add_enabled_ui(false, |ui| {+ craft_ui::ComboBox::new(id, label(text)).selected_text(value).show_ui(ui, |_| {});+ })+ .response+ .on_disabled_hover_text("This preference is not available yet.");+ }+ Control::Value(text, value) => {+ ui.add_enabled_ui(false, |ui| {+ ui.horizontal(|ui| {+ let caption = ui.label(label(text));+ let mut shown = value.to_string();+ ui.add(egui::TextEdit::singleline(&mut shown).desired_width(96.0)).labelled_by(caption.id);+ });+ })+ .response+ .on_disabled_hover_text("This preference is not available yet.");+ }+ Control::Button(text) => {+ ui.add_enabled(false, egui::Button::new(label(text))).on_disabled_hover_text("This preference is not available yet.");+ }+ Control::Radios(text, values) => {+ ui.add_enabled_ui(false, |ui| {+ ui.label(label(text));+ let mut selected = 0usize;+ ui.indent(id, |ui| {+ for (value_index, value) in values.iter().enumerate() {+ ui.radio_value(&mut selected, value_index, *value);+ }+ });+ })+ .response+ .on_disabled_hover_text("This preference is not available yet.");+ }+ }+}++/// Selects a page from the platform's primary-modifier plus number shortcuts.+fn select_page_from_keyboard(ctx: &egui::Context, page: &mut PreferencesPage) {+ let mac = ctx.os() == egui::os::OperatingSystem::Mac;+ let keys = [Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9];+ let selected = ctx.input(|input| {+ input.events.iter().find_map(|event| match event {+ egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. } if page_modifiers(*modifiers, mac) => {+ keys.iter().position(|candidate| candidate == key)+ }+ _ => None,+ })+ });+ if let Some(index) = selected.and_then(|index| PreferencesPage::ALL.get(index).copied()) {+ *page = index;+ }+}++/// Reports whether modifiers represent the platform-specific page shortcut chord.+fn page_modifiers(modifiers: Modifiers, mac: bool) -> bool {+ !modifiers.alt && !modifiers.shift && if mac { modifiers.command && !modifiers.ctrl } else { modifiers.command && modifiers.ctrl }+}++/// Reports whether a page title, section or control matches the search terms.+fn page_matches(page: PreferencesPage, search: &str) -> bool {+ text_matches(page.label(), search)+ || page.sections().iter().any(|section| {+ text_matches(section.title, search) || section.controls.iter().copied().any(|control| text_matches(&control.search_text(), search))+ })+}++/// Performs a case-insensitive AND match over whitespace-separated search terms.+fn text_matches(text: &str, search: &str) -> bool {+ let search = search.trim();+ if search.is_empty() {+ return false;+ }+ let text = text.to_lowercase();+ search.split_whitespace().all(|term| text.contains(&term.to_lowercase()))+}++#[cfg(test)]+mod tests {+ use super::*;+ use egui_kittest::Harness;+ use egui_kittest::kittest::{NodeT, Queryable};+ use soundcraft_engine::Engine;++ /// Creates a complete application harness with Preferences already open.+ fn preferences_harness(size: egui::Vec2) -> Harness<'static, SoundApp> {+ let mut app = SoundApp::new(Engine::default(), None, crate::Services::default());+ app.ui.show_preferences = true;+ Harness::builder().with_size(size).build_ui_state(+ |ui, app: &mut SoundApp| {+ app.logic(&ui.ctx().clone());+ app.ui(ui);+ },+ app,+ )+ }++ /// Search includes page, section, control, choice and value text.+ #[test]+ fn search_matches_pages_sections_labels_and_values() {+ assert!(page_matches(PreferencesPage::Display, "theme"));+ assert!(page_matches(PreferencesPage::Editing, "zoom selection"));+ assert!(page_matches(PreferencesPage::Metering, "K-20"));+ assert!(page_matches(PreferencesPage::Synchronization, "linked systems"));+ assert!(!page_matches(PreferencesPage::Midi, "waveform cache"));+ }++ /// Implemented preferences use commands without becoming document edits.+ #[test]+ fn applying_live_preferences_uses_ui_commands_without_dirtying_the_session() {+ let mut app = SoundApp::new(Engine::default(), None, crate::Services::default());+ assert_eq!(app.ui.theme, ThemeMode::Dark);+ assert!(app.run("ui.theme", json!({"mode": ThemeMode::Light.id()})).is_ok());+ assert!(app.run("ui.reduce_visual_effects", json!({"mode": "playback"})).is_ok());+ assert!(app.run("ui.undo_levels", json!({"levels": 32})).is_ok());+ assert_eq!(app.ui.theme, ThemeMode::Light);+ assert_eq!(app.ui.reduce_visual_effects, ReduceVisualEffects::Playback);+ assert_eq!((app.ui.undo_levels, app.engine.history_limit), (32, 32));+ assert!(!app.engine.is_dirty());+ }++ /// An unchanged open draft follows newer scripted values instead of restoring stale ones.+ #[test]+ fn unchanged_draft_tracks_live_commands_before_apply() {+ let mut harness = preferences_harness(egui::vec2(1280.0, 1000.0));+ harness.run_steps(4);+ {+ let app = harness.state_mut();+ assert!(app.run("ui.theme", json!({"mode": ThemeMode::Light.id()})).is_ok());+ assert!(app.run("ui.reduce_visual_effects", json!({"mode": "always"})).is_ok());+ assert!(app.run("ui.undo_levels", json!({"levels": 17})).is_ok());+ }+ harness.run_steps(2);+ harness.get_by_label("OK").click();+ harness.run_steps(2);++ let app = harness.state();+ assert_eq!(app.ui.theme, ThemeMode::Light);+ assert_eq!(app.ui.reduce_visual_effects, ReduceVisualEffects::Always);+ assert_eq!((app.ui.undo_levels, app.engine.history_limit), (17, 17));+ assert!(!app.ui.show_preferences);+ }++ /// Fields edited in the window win over later live changes while untouched fields follow them.+ #[test]+ fn draft_sync_preserves_only_user_edited_fields() {+ let mut app = SoundApp::new(Engine::default(), None, crate::Services::default());+ let mut draft = PreferencesDraft::from_live(&app);+ let before = draft;+ draft.theme = ThemeMode::Light;+ draft.remember_user_edits(before);++ app.ui.theme = ThemeMode::System;+ app.ui.reduce_visual_effects = ReduceVisualEffects::Always;+ app.ui.undo_levels = 17;+ draft.sync_unedited_from_live(&app);++ assert_eq!(draft.theme, ThemeMode::Light);+ assert_eq!(draft.reduce_visual_effects, ReduceVisualEffects::Always);+ assert_eq!(draft.undo_levels, 17);+ }++ /// Command inputs reject unknown modes and bound undo depth safely.+ #[test]+ fn live_preferences_reject_or_clamp_hostile_values() {+ let mut app = SoundApp::new(Engine::default(), None, crate::Services::default());+ assert!(app.run("ui.reduce_visual_effects", json!({"mode": "sometimes"})).is_err());+ assert_eq!(app.run("ui.undo_levels", json!({"levels": 10_000})).unwrap()["levels"], 64);+ }++ /// The Setup menu maps to the same scriptable command as every other entry point.+ #[test]+ fn setup_menu_opens_preferences_through_the_catalog_command() {+ let aliases = crate::menus::ui_aliases();+ assert_eq!(crate::menus::command_for_path("Setup > Preferences...", &aliases).as_deref(), Some("setup.preferences"));+ let mut app = SoundApp::new(Engine::default(), None, crate::Services::default());+ assert_eq!(app.run("setup.preferences", json!({"value": true})), Ok(json!({"value": true})));+ assert!(app.ui.show_preferences);+ }++ /// Closing by command cannot leak staged values into a later window instance.+ #[test]+ fn command_closes_discard_staged_preferences_before_a_reopen() {+ let mut app = SoundApp::new(Engine::default(), None, crate::Services::default());+ app.ui.show_preferences = true;+ app.preferences.search = "theme".into();+ let mut draft = PreferencesDraft::from_live(&app);+ draft.theme = ThemeMode::Light;+ draft.reduce_visual_effects = ReduceVisualEffects::Always;+ draft.undo_levels = 1;+ app.preferences.draft = Some(draft);++ assert_eq!(app.run("setup.preferences", json!({})), Ok(json!({"value": false})));+ assert!(app.preferences.draft.is_none());+ assert!(app.preferences.search.is_empty());+ assert_eq!(app.run("setup.preferences", json!({})), Ok(json!({"value": true})));++ app.preferences.search = "undo".into();+ app.preferences.draft = Some(draft);+ assert!(app.run("window.close", json!({})).is_ok());+ assert!(app.preferences.draft.is_none());+ assert!(app.preferences.search.is_empty());+ }++ /// A broad scripted UI-state replacement also discards the closed window before another+ /// command can reopen it, even when no UI frame runs between the two commands.+ #[test]+ fn ui_set_close_then_immediate_reopen_starts_without_the_canceled_draft() {+ let mut app = SoundApp::new(Engine::default(), None, crate::Services::default());+ app.ui.show_preferences = true;+ app.preferences.search = "undo".into();+ let mut draft = PreferencesDraft::from_live(&app);+ draft.theme = ThemeMode::Light;+ draft.undo_levels = 1;+ app.preferences.draft = Some(draft);++ assert!(app.run("ui.set", json!({"show_preferences": false})).is_ok());+ assert_eq!(app.run("setup.preferences", json!({"value": true})), Ok(json!({"value": true})));++ assert!(app.ui.show_preferences);+ assert!(app.preferences.draft.is_none());+ assert!(app.preferences.search.is_empty());+ assert_eq!(app.ui.theme, ThemeMode::Dark);+ assert_eq!(app.ui.undo_levels, 64);+ }++ /// Enter belongs to the focused search field and must not apply its window.+ #[test]+ fn enter_in_search_does_not_apply_or_close_preferences() {+ let mut harness = preferences_harness(egui::vec2(1280.0, 1000.0));+ harness.run_steps(4);+ harness.ctx.memory_mut(|memory| memory.request_focus(egui::Id::unique("preferences_search")));+ harness.run_steps(1);+ assert!(harness.ctx.memory(|memory| memory.has_focus(egui::Id::unique("preferences_search"))));+ let state = harness.state_mut();+ state.preferences.draft.as_mut().unwrap().theme = ThemeMode::Light;++ harness.key_press(Key::Enter);+ harness.run_steps(2);++ assert!(harness.state().ui.show_preferences);+ assert_eq!(harness.state().ui.theme, ThemeMode::Dark);+ }++ /// Enter cannot replace a Cancel button action received in the same frame.+ #[test]+ fn enter_and_cancel_in_the_same_frame_discard_the_draft() {+ let mut harness = preferences_harness(egui::vec2(1280.0, 1000.0));+ harness.run_steps(4);+ harness.state_mut().preferences.draft.as_mut().unwrap().theme = ThemeMode::Light;++ harness.get_by_label("Cancel").click();+ harness.key_press(Key::Enter);+ harness.run_steps(2);++ assert!(!harness.state().ui.show_preferences);+ assert_eq!(harness.state().ui.theme, ThemeMode::Dark);+ }++ /// Escape closes an open choice popup before it can cancel the complete window.+ #[test]+ fn escape_closes_a_choice_popup_before_preferences() {+ let mut harness = preferences_harness(egui::vec2(1280.0, 1000.0));+ harness.run_steps(4);+ harness.get_by_value("Dark").click();+ harness.run_steps(2);+ assert!(harness.ctx.any_popup_open());++ harness.key_press(Key::Escape);+ harness.run_steps(2);++ assert!(!harness.ctx.any_popup_open());+ assert!(harness.state().ui.show_preferences);+ }++ /// The adaptive fixed height keeps both footer buttons inside a short viewport.+ #[test]+ fn short_screens_keep_the_preferences_footer_reachable() {+ assert_eq!(preferences_window_height(1000.0), PREFERENCES_WINDOW_HEIGHT);+ assert_eq!(preferences_window_height(800.0), 776.0);++ let mut harness = preferences_harness(egui::vec2(1280.0, 800.0));+ harness.run_steps(4);+ let ok = harness.get_by_label("OK").rect();+ let cancel = harness.get_by_label("Cancel").rect();+ assert!(ok.max.y <= 800.0, "OK button is outside the screen: {ok:?}");+ assert!(cancel.max.y <= 800.0, "Cancel button is outside the screen: {cancel:?}");+ }++ /// The application menu invokes the same Preferences command as Setup.+ #[test]+ fn soundcraft_menu_opens_preferences_through_the_same_command() {+ let app = SoundApp::new(Engine::default(), None, crate::Services::default());+ let mut harness = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_ui_state(+ |ui, app: &mut SoundApp| {+ app.logic(&ui.ctx().clone());+ app.ui(ui);+ },+ app,+ );+ harness.run_steps(4);+ harness.get_by_label("SoundCraft").click();+ harness.run_steps(2);+ harness.get_by_label("Preferences...").click();+ harness.run_steps(2);+ assert!(harness.state().ui.show_preferences);+ }++ /// The in-window application menu visibly disables Preferences during playback.+ #[test]+ fn soundcraft_menu_disables_preferences_during_playback() {+ let mut app = SoundApp::new(Engine::default(), None, crate::Services::default());+ app.engine.transport.playing = true;+ let mut harness = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_ui_state(+ |ui, app: &mut SoundApp| {+ app.logic(&ui.ctx().clone());+ app.ui(ui);+ },+ app,+ );+ harness.run_steps(4);+ harness.get_by_label("SoundCraft").click();+ harness.run_steps(2);+ assert!(harness.get_by_label("Preferences...").accesskit_node().is_disabled());+ }++ /// Page declaration order remains identical to number-shortcut order.+ #[test]+ fn page_order_matches_the_platform_shortcuts() {+ assert_eq!(PreferencesPage::ALL[0], PreferencesPage::Display);+ assert_eq!(PreferencesPage::ALL[4], PreferencesPage::Metering);+ assert_eq!(PreferencesPage::ALL[8], PreferencesPage::Synchronization);+ }++ /// An empty query does not mark every page and section as a hit.+ #[test]+ fn empty_search_does_not_highlight_everything() {+ assert!(!text_matches("Display", ""));+ assert!(!text_matches("Display", " "));+ }++ /// Page shortcuts accept only the documented primary modifier for each platform.+ #[test]+ fn page_shortcuts_require_the_platform_primary_modifier_only() {+ let command = Modifiers { command: true, ..Modifiers::NONE };+ let off_mac_control = Modifiers { command: true, ctrl: true, ..Modifiers::NONE };+ assert!(page_modifiers(command, true));+ assert!(!page_modifiers(command, false));+ assert!(page_modifiers(off_mac_control, false));+ assert!(!page_modifiers(off_mac_control, true));+ }+}@@ -59,7 +59,7 @@ fn search_chord(m: Modifiers, mac: bool) -> bool { pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { // Cmd+= and Cmd+0 are Mix/Edit and Quantize to Grid, not egui's built-in interface zoom. ctx.options_mut(|o| o.zoom_with_keyboard = false);- if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() {+ if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() || app.ui.show_preferences { return; } // A scripted press is identified by its exact position in this pass's events, not by matching@@ -366,6 +366,8 @@ fn a_preset_changed_in_an_effect_editor_is_stored_once() { fn preferences_saved_before_the_window_was_remembered_keep_full_scale() { let ui: super::UiState = serde_json::from_value(json!({"show_transport": true})).unwrap(); assert_eq!((ui.window_size, ui.window_maximized, ui.ui_scale), ([1600.0, 1000.0], false, 1.0));+ assert_eq!(ui.reduce_visual_effects, crate::preferences::ReduceVisualEffects::Never);+ assert_eq!(ui.undo_levels, 64); } #[test]@@ -70,6 +70,8 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---| | 2026-10-25 | minor | Edit and Mix windows: Tracks and Groups and Clips in full-height dock columns. Readiness estimate unchanged |+| 2026-10-20 | minor | Preferences uses one fixed non-scrolling size with compact stacked choices, removes the duplicate meter-type list, and command-backs visual-effects reduction and undo depth alongside the UI theme |+| 2026-10-19 | minor | Preferences opens from Setup with nine searchable pages, compact grouped controls, staged apply/cancel, platform tab shortcuts and disabled placeholders for unavailable settings | | 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged | | 2026-10-13 | minor | A plugin opens when it's inserted (#486) | | 2026-10-13 | minor | Drag across checkboxes (#399) |