mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-11 04:42:09 +00:00
feat(ui): add complete Preferences window and live settings (#819)
* feat(ui): add complete Preferences window shell * fix(ui): refine Preferences layout and live settings * fix(ui): remove Preferences page scrolling * fix(ui): keep Preferences window size constant * feat(ui): add Preferences to the SoundCraft menu * fix(ui): address Preferences review feedback * docs(ui): document Preferences behavior * fix(macOS): add Preferences to the application menu * fix(ui): make Preferences menu behavior modal * fix(ui): preserve live Preferences updates * fix(ui): address latest Preferences review findings * fix(ui): preserve Preferences button actions
This commit is contained in:
1 parent
87f46cf23b
commit
e66a5e7c67
11 files changed
+1656
-55
No files matched your search
@@ -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);
|
||||
|
||||
@@ -343,6 +343,16 @@ impl Engine {
|
||||
self.undo.iter().map(|h| h.label.clone()).collect()
|
||||
}
|
||||
|
||||
/// Bound Undo and Redo together, dropping farthest redo entries before oldest undo entries.
|
||||
pub fn set_history_limit(&mut self, limit: usize) {
|
||||
let limit = limit.max(1);
|
||||
self.history_limit = limit;
|
||||
let excess = self.undo.len().saturating_add(self.redo.len()).saturating_sub(limit);
|
||||
let from_redo = excess.min(self.redo.len());
|
||||
self.redo.drain(..from_redo);
|
||||
self.undo.drain(..excess - from_redo);
|
||||
}
|
||||
|
||||
/// Restore the previous document while retaining current edit mode and live input flags.
|
||||
pub fn undo(&mut self) -> bool {
|
||||
let Some(h) = self.undo.pop() else { return false };
|
||||
@@ -716,6 +726,59 @@ mod merge_tests {
|
||||
mod dirty_tests {
|
||||
use super::*;
|
||||
|
||||
/// Lowering the cap immediately removes the oldest retained Undo steps.
|
||||
#[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());
|
||||
}
|
||||
|
||||
/// A shared history cap retains the closest redo steps without letting either direction exceed it.
|
||||
#[test]
|
||||
fn lowering_history_limit_bounds_undo_and_redo_together() {
|
||||
for (requested, past, future) in [(0, 1, 0), (1, 1, 0), (2, 2, 0), (3, 2, 1), (4, 2, 2), (5, 2, 3), (usize::MAX, 2, 3)] {
|
||||
let mut e = Engine::default();
|
||||
for _ in 0..5 {
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
}
|
||||
for _ in 0..3 {
|
||||
assert!(e.undo());
|
||||
}
|
||||
e.mark_clean();
|
||||
e.set_history_limit(requested);
|
||||
let limit = requested.max(1);
|
||||
assert_eq!(e.history_limit, limit);
|
||||
assert_eq!((e.undo.len(), e.redo.len()), (past, future), "requested {requested}");
|
||||
assert!(!e.is_dirty(), "history capacity is not a document edit");
|
||||
assert_eq!(e.session().tracks.len(), 2);
|
||||
for step in 1..=future {
|
||||
assert!(e.redo());
|
||||
assert_eq!(e.session().tracks.len(), 2 + step, "keep the nearest redo entries");
|
||||
assert!(e.undo.len() + e.redo.len() <= limit);
|
||||
}
|
||||
assert!(!e.redo());
|
||||
for step in 1..=past + future {
|
||||
assert!(e.undo());
|
||||
assert_eq!(e.session().tracks.len(), 2 + future - step, "keep the nearest undo entries");
|
||||
assert!(e.undo.len() + e.redo.len() <= limit);
|
||||
}
|
||||
assert!(!e.undo());
|
||||
for _ in 0..past + future {
|
||||
assert!(e.redo());
|
||||
assert!(e.undo.len() + e.redo.len() <= limit);
|
||||
}
|
||||
assert!(!e.redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_locating_and_zooming_leave_the_session_clean() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::state::MainWindow;
|
||||
use super::{
|
||||
ControlRequest, Engine, Gesture, HashMap, MeterDisplay, OutputTrouble, Player, Range, Receiver, Samples, SoundApp, TrackId, TransportRequest,
|
||||
Trouble, UiState, Value, VecDeque, bounce, dialogs, edit_window, extra_windows, fonts, import, json, menus, midi_editor, mix_window, ops_windows,
|
||||
palette, panels, recovery, sampler_window, score_editor, shortcuts, theme, updates_ui, video_track, video_window, windows,
|
||||
palette, panels, preferences, recovery, sampler_window, score_editor, shortcuts, theme, updates_ui, video_track, video_window, windows,
|
||||
};
|
||||
|
||||
mod audio;
|
||||
@@ -63,6 +63,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,
|
||||
@@ -137,6 +138,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 {
|
||||
@@ -333,6 +336,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);
|
||||
|
||||
@@ -27,6 +27,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;
|
||||
@@ -114,6 +115,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
|
||||
@@ -182,6 +190,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,
|
||||
@@ -302,6 +315,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
|
||||
|
||||
+150
-45
@@ -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.")
|
||||
{
|
||||
@@ -587,6 +615,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),
|
||||
@@ -640,6 +677,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;
|
||||
@@ -694,6 +733,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()) {
|
||||
@@ -706,8 +762,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}"))),
|
||||
@@ -731,6 +793,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()}));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -408,7 +408,7 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
app.ui.show_configurations = open;
|
||||
}
|
||||
|
||||
/// Restore a saved window configuration, keeping the configuration list and the appearance.
|
||||
/// Restore a window layout while retaining current application preferences and any open draft.
|
||||
/// A configuration saved before the current panel layouts gets the default layouts.
|
||||
pub fn recall_configuration(app: &mut SoundApp, saved: &Value) -> Result<(), String> {
|
||||
let mut saved = saved.clone();
|
||||
@@ -417,8 +417,14 @@ pub fn recall_configuration(app: &mut SoundApp, saved: &Value) -> Result<(), Str
|
||||
crate::docking::validate(&st.docking).and_then(|()| crate::docking::validate_mix(&st.mix_docking))?;
|
||||
st.configurations = app.ui.configurations.clone();
|
||||
st.theme = app.ui.theme;
|
||||
st.show_preferences = app.ui.show_preferences;
|
||||
st.preferences_page = app.ui.preferences_page;
|
||||
st.preferences_window_pos = app.ui.preferences_window_pos;
|
||||
st.reduce_visual_effects = app.ui.reduce_visual_effects;
|
||||
st.undo_levels = app.ui.undo_levels.clamp(1, 64);
|
||||
st.show_configurations = true;
|
||||
crate::docking::cancel_replaced_state(app, &st);
|
||||
app.engine.set_history_limit(st.undo_levels);
|
||||
app.ui = st;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -457,6 +457,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]
|
||||
|
||||
@@ -72,6 +72,8 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
|
||||
|---|---|---|
|
||||
| 2026-10-25 | minor | Zoom Toggle row: Alt+Shift+E and Alt-click cancel Zoom Toggle and keep the view. Readiness estimate unchanged |
|
||||
| 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) |
|
||||
|
||||
Reference in new issue
Block a user