diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 7e3b989..61f20bd 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -49,6 +49,13 @@ impl eframe::App for App { if self.0.quit_requested { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } + soundcraft_ui_egui::fit_window_to_screen(ctx); + let (inner, maximized) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().maximized.unwrap_or(false))); + self.0.ui.window_maximized = maximized; + if let Some(r) = inner.filter(|_| !maximized) { + self.0.ui.window_size = (r.size() * ctx.zoom_factor()).round().into(); + } + self.0.ui.ui_scale = self.0.extra.ui_scale; // With the system menu bar there is no egui bar showing the window and dirty state, so the // title carries them; elsewhere the egui menu bar already does. let title = if self.0.native_menu_bar { @@ -216,10 +223,12 @@ fn main() -> eframe::Result { } let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() }; + let prefs = load_prefs().unwrap_or_default(); let mut options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("SoundCraft") - .with_inner_size([1600.0, 1000.0]) + .with_inner_size(sane_window_size(prefs.window_size)) + .with_maximized(prefs.window_maximized) .with_min_inner_size([960.0, 600.0]) .with_drag_and_drop(true) .with_app_id("ai.storyteller.soundcraft"), @@ -239,9 +248,8 @@ fn main() -> eframe::Result { let mut app = SoundApp::new(engine, player, services()); app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave"))); app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets"))); - if let Some(ui) = load_prefs() { - app.ui = ui; - } + app.extra.ui_scale = prefs.ui_scale; + app.ui = prefs; if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx); @@ -270,3 +278,8 @@ fn main() -> eframe::Result { }), ) } + +/// A saved window size, or the default when the prefs file holds nonsense (NaN, negative, tiny). +fn sane_window_size([w, h]: [f32; 2]) -> [f32; 2] { + if w.is_finite() && h.is_finite() && w >= 960.0 && h >= 600.0 { [w.min(16_384.0), h.min(16_384.0)] } else { [1600.0, 1000.0] } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8103a09..305ff2e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -101,6 +101,11 @@ pub struct UiState { pub configurations: Vec<(String, Value)>, /// Appearance (`ui.theme`): dark by default, light or follow the system on request. pub theme: theme::ThemeMode, + /// The main window's size (logical points) and maximized state, and the interface scale, as + /// the desktop app last saw them; it restores them at launch. + pub window_size: [f32; 2], + pub window_maximized: bool, + pub ui_scale: f32, } impl Default for UiState { @@ -153,6 +158,9 @@ impl Default for UiState { workspace_dir: String::new(), configurations: Vec::new(), theme: theme::ThemeMode::Dark, + window_size: [1600.0, 1000.0], + window_maximized: false, + ui_scale: 1.0, } } } @@ -170,6 +178,15 @@ pub fn preset_folder_name(plugin: &str) -> String { plugin.chars().map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' { c } else { '_' }).collect() } +/// On the first frame, maximizes a window too big for the screen it opened on (sized on a larger +/// monitor) instead of letting it run off the edge; its saved size is kept for that monitor. +pub fn fit_window_to_screen(ctx: &egui::Context) { + let (inner, monitor) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().monitor_size)); + if ctx.cumulative_pass_nr() == 0 && inner.zip(monitor).is_some_and(|(r, m)| (r.size() - m).max_elem() > 0.0) { + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true)); + } +} + /// Drag-and-drop payload: an audio file (source id) dragged from the Clip List. #[derive(Debug, Clone, Copy)] pub struct DragSource(pub u64); @@ -1023,6 +1040,35 @@ mod tests { assert!(automation_step_due(0, 1_000_000, 960)); } + #[test] + 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)); + } + + #[test] + fn a_window_bigger_than_its_screen_opens_maximized() { + let maximizes = |w: f32, frames: usize| { + let ctx = egui::Context::default(); + let info = egui::ViewportInfo { + inner_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, 1000.0))), + monitor_size: Some(egui::vec2(1728.0, 1117.0)), + ..Default::default() + }; + let mut maximized = false; + for _ in 0..frames { + let raw = egui::RawInput { viewports: [(egui::ViewportId::ROOT, info.clone())].into_iter().collect(), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| super::fit_window_to_screen(ui.ctx())); + out.textures_delta.clear(); + maximized = out.viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Maximized(true)); + } + maximized + }; + assert!(maximizes(2400.0, 1)); + assert!(!maximizes(1600.0, 1)); + assert!(!maximizes(2400.0, 2)); + } + #[test] fn ordinary_play_after_stop_is_full_speed() { use serde_json::json; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 153c983..5522169 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -412,44 +412,46 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { if info.id == "eq_7band" || info.id == "eq_1band" { eq_curve(ui, info.id, &ins); } - egui::Grid::new(("pg", tid.0, slot)).num_columns(3).show(ui, |ui| { - for p in info.params { - let v = ins.params.get(p.id).copied().unwrap_or(p.default); - ui.label(p.name); - let mut x = v; - let resp = if !p.choices.is_empty() { - let mut idx = x.round().max(0.0) as usize; - let r = egui::ComboBox::from_id_salt(("pc", tid.0, slot, p.id)) - .selected_text(p.choices.get(idx).copied().unwrap_or("?")) - .show_ui(ui, |ui| { - for (i, c) in p.choices.iter().enumerate() { - ui.selectable_value(&mut idx, i, *c); - } - }); - x = idx as f32; - r.response - } else { - let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false); - if p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0 { - sl = sl.logarithmic(true); - } - ui.add(sl) - }; - if (x - v).abs() > f32::EPSILON { - let _ = app.engine.execute_merged( - "mix.insert_param", - &json!({"track": tid.0, "slot": slot, "param": p.id, "value": x}), - &format!("param:{}:{slot}:{}", tid.0, p.id), - ); - if let Some(pl) = &app.player { - // Keep the plugin's own editor in step. - pl.editor_set_param(tid, slot, p.id, x); + egui::ScrollArea::vertical().show(ui, |ui| { + egui::Grid::new(("pg", tid.0, slot)).num_columns(3).show(ui, |ui| { + for p in info.params { + let v = ins.params.get(p.id).copied().unwrap_or(p.default); + ui.label(p.name); + let mut x = v; + let resp = if !p.choices.is_empty() { + let mut idx = x.round().max(0.0) as usize; + let r = egui::ComboBox::from_id_salt(("pc", tid.0, slot, p.id)) + .selected_text(p.choices.get(idx).copied().unwrap_or("?")) + .show_ui(ui, |ui| { + for (i, c) in p.choices.iter().enumerate() { + ui.selectable_value(&mut idx, i, *c); + } + }); + x = idx as f32; + r.response + } else { + let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false); + if p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0 { + sl = sl.logarithmic(true); + } + ui.add(sl) + }; + if (x - v).abs() > f32::EPSILON { + let _ = app.engine.execute_merged( + "mix.insert_param", + &json!({"track": tid.0, "slot": slot, "param": p.id, "value": x}), + &format!("param:{}:{slot}:{}", tid.0, p.id), + ); + if let Some(pl) = &app.player { + // Keep the plugin's own editor in step. + pl.editor_set_param(tid, slot, p.id, x); + } } + let _ = resp; + ui.label(egui::RichText::new(p.format(x)).font(mono(11.0))); + ui.end_row(); } - let _ = resp; - ui.label(egui::RichText::new(p.format(x)).font(mono(11.0))); - ui.end_row(); - } + }); }); }); if open { diff --git a/crates/ui-egui/tests/plugin_window.rs b/crates/ui-egui/tests/plugin_window.rs new file mode 100644 index 0000000..2387033 --- /dev/null +++ b/crates/ui-egui/tests/plugin_window.rs @@ -0,0 +1,19 @@ +use egui_kittest::Harness; +use soundcraft_ui_egui::{Services, SoundApp}; + +#[test] +fn a_long_plugin_window_stays_on_a_short_screen() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let kick = app.engine.session().track_by_name("Kick").unwrap().id; + app.ui.plugin_windows = vec![(kick, 0)]; + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 500.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + let window = h.ctx.memory(|m| m.area_rect(egui::Id::new(("plugin", kick.0, 0usize)))).unwrap(); + assert!(window.bottom() <= 500.0, "{window:?}"); +}