Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
Brandon Thomas committed 2026-10-10 03:52:01 -04:00
commit dc2ffda4d6
16 files changed
+387 -114

No files matched your search

Generated
-37
View File
@@ -837,23 +837,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "dark-light"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f66b71ac6b73812b0bf51cda8dd646a268fbb38623f45b2b7907593c323f745"
dependencies = [
"futures-channel",
"futures-core",
"objc2 0.6.5",
"objc2-foundation 0.3.2",
"wasm-bindgen",
"web-sys",
"windows-sys 0.61.2",
"winreg",
"zbus",
]
[[package]]
name = "dasp_sample"
version = "0.11.0"
@@ -1319,15 +1302,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-channel"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.34"
@@ -3596,7 +3570,6 @@ dependencies = [
name = "soundcraft-ui-egui"
version = "0.4.0"
dependencies = [
"dark-light",
"egui",
"egui_kittest",
"image",
@@ -5106,16 +5079,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.61.2",
]
[[package]]
name = "winresource"
version = "0.1.31"
-1
View File
@@ -44,7 +44,6 @@ cpal = "0.16"
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
dark-light = "3"
rfd = "0.17"
image = { version = "0.25", default-features = false, features = ["png"] }
+5
View File
@@ -53,6 +53,8 @@ pub type Result<T> = std::result::Result<T, EngineError>;
/// Requests for the transport/audio engine, drained by the host app each frame.
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub enum TransportRequest {
/// Stop the old session's transport without applying stop-time edits to the new document.
ResetSession,
Play,
Stop,
TogglePlay,
@@ -158,6 +160,9 @@ impl Engine {
/// Replace the whole document (open / new); clears history.
pub fn replace_session(&mut self, s: Session) {
self.transport = TransportStatus { position: s.edit.selection.start.max(0), ..TransportStatus::default() };
self.transport_requests.clear();
self.transport_requests.push(TransportRequest::ResetSession);
self.doc = Arc::new(s);
self.undo.clear();
self.redo.clear();
+30
View File
@@ -0,0 +1,30 @@
use serde_json::json;
use soundcraft_engine::{Engine, TransportRequest, TransportStatus};
use soundcraft_model::Session;
use soundcraft_time::Range;
#[test]
fn replacing_session_resets_status_and_discards_old_transport_requests() {
let mut engine = Engine::default();
engine.transport = TransportStatus { playing: true, recording: true, position: 48_000 };
engine.execute("transport.play", &json!({"from": 96_000})).unwrap();
engine.execute("transport.record", &json!({})).unwrap();
let mut session = Session::default();
session.edit.selection = Range::point(12_000);
engine.replace_session(session);
assert_eq!(engine.transport, TransportStatus { position: 12_000, ..TransportStatus::default() });
assert_eq!(engine.transport_requests, vec![TransportRequest::ResetSession]);
assert!(!engine.is_dirty());
}
#[test]
fn failed_new_session_keeps_the_current_transport() {
let mut engine = Engine::default();
engine.transport = TransportStatus { playing: true, position: 48_000, ..TransportStatus::default() };
engine.execute("transport.play", &json!({})).unwrap();
let status = engine.transport;
let requests = engine.transport_requests.clone();
assert!(engine.execute("session.new", &json!({"sample_rate": 0})).is_err());
assert_eq!(engine.transport, status);
assert_eq!(engine.transport_requests, requests);
}
-1
View File
@@ -21,7 +21,6 @@ soundcraft-engine.workspace = true
soundcraft-playback.workspace = true
soundcraft-video.workspace = true
egui.workspace = true
dark-light.workspace = true
serde.workspace = true
serde_json.workspace = true
log.workspace = true
+31 -2
View File
@@ -1,9 +1,10 @@
//! Render the real UI offscreen (no window, no focus stealing) and save PNGs.
//!
//! `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png [script.jsonl] [--blank] [--size WxH]`
//! `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png [script.jsonl] [--blank] [--size WxH] [--system-theme light|dark|none]`
//!
//! Script lines are control requests (`{"method": "...", "params": {...}}`), `{"shot": "path.png"}`
//! or `{"steps": n}`. The demo session is loaded unless `--blank`.
//! or `{"steps": n}`. `{"system_theme": "light"}` (also `"dark"` or `"none"`) changes the
//! simulated OS appearance for subsequent frames. The demo session is loaded unless `--blank`.
use soundcraft_ui_egui::{ControlRequest, Services, SoundApp};
use std::sync::atomic::{AtomicBool, Ordering};
@@ -11,11 +12,29 @@ use std::sync::mpsc;
static READY: AtomicBool = AtomicBool::new(false);
fn system_theme(value: &str) -> Option<Option<egui::Theme>> {
match value {
"light" => Some(Some(egui::Theme::Light)),
"dark" => Some(Some(egui::Theme::Dark)),
"none" => Some(None),
_ => None,
}
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let out = args.first().cloned().unwrap_or_else(|| "soundcraft.png".into());
let script = args.get(1).filter(|a| !a.starts_with("--")).cloned();
let blank = args.iter().any(|a| a == "--blank");
let appearance = if let Some(i) = args.iter().position(|a| a == "--system-theme") {
let Some(appearance) = args.get(i + 1).and_then(|value| system_theme(value)) else {
eprintln!("--system-theme requires light, dark or none");
return;
};
appearance
} else {
None
};
let (w, h) = args
.iter()
.position(|a| a == "--size")
@@ -39,6 +58,7 @@ fn main() {
app,
);
harness.input_mut().max_texture_side = Some(8192);
harness.input_mut().system_theme = appearance;
READY.store(true, Ordering::Relaxed);
let step = |h: &mut egui_kittest::Harness<SoundApp>| {
let mut raw = std::mem::take(h.input_mut());
@@ -63,6 +83,15 @@ fn main() {
eprintln!("bad line: {line}");
continue;
};
if let Some(value) = v.get("system_theme") {
let Some(appearance) = value.as_str().and_then(system_theme) else {
eprintln!("system_theme requires light, dark or none: {line}");
continue;
};
harness.input_mut().system_theme = appearance;
step(&mut harness);
continue;
}
if let Some(p) = v.get("shot").and_then(|x| x.as_str()) {
for _ in 0..3 {
step(&mut harness);
+81 -2
View File
@@ -642,6 +642,25 @@ impl SoundApp {
let reqs: Vec<TransportRequest> = std::mem::take(&mut self.engine.transport_requests);
for r in reqs {
match r {
TransportRequest::ResetSession => {
if let Some(p) = &self.player {
p.stop();
p.set_recording(false);
p.set_speed(1.0);
p.locate(self.engine.transport.position);
}
self.sim = None;
// The old capture belongs to the replaced document, never to the new one.
self.recorder = None;
self.recorder_failed = false;
self.punch = None;
self.gesture = None;
self.touched.clear();
self.last_write_at = i64::MIN;
self.edit_layout.follow_hold = false;
self.meters.clear();
self.main_meter = MeterDisplay::default();
}
TransportRequest::Play => {
if !self.is_playing() {
self.play_from_selection();
@@ -761,13 +780,14 @@ impl SoundApp {
self.engine.end_merge();
}
}
// Stop the old playback before sending a replacement session to the audio thread.
self.handle_transport(dt);
if self.engine.revision != self.last_rev {
self.last_rev = self.engine.revision;
if let Some(p) = &self.player {
p.update_session(self.engine.session_arc());
}
}
self.handle_transport(dt);
self.write_automation(ctx);
self.autosave(now);
if self.recorder.is_none()
@@ -818,6 +838,8 @@ impl SoundApp {
/// Lay out the whole window.
pub fn ui(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
// The integration creates this Ui before logic resolves the current frame's palette.
ui.set_style(ctx.global_style());
if !self.fonts_ready {
ctx.request_repaint();
return;
@@ -889,7 +911,64 @@ fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) {
#[cfg(test)]
mod tests {
use super::automation_step_due;
use super::{Services, SoundApp, automation_step_due};
use serde_json::json;
#[test]
fn new_session_stops_playback_and_the_old_clock() {
for (command, params) in [
("session.new", json!({"name": "New project", "sample_rate": 96_000})),
("session.new", json!({"template": "demo"})),
("session.close", json!({})),
] {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.run("transport.play", json!({"from": 48_000})).unwrap();
app.handle_transport(0.25);
assert!(app.is_playing());
assert!(app.position() > 48_000);
app.run(command, params).unwrap();
let insertion = app.engine.session().edit.selection.start;
app.handle_transport(0.25);
assert!(!app.is_playing(), "{command}");
assert_eq!(app.position(), insertion, "{command}");
assert_eq!(app.engine.transport.position, insertion, "{command}");
app.handle_transport(0.25);
assert_eq!(app.engine.transport.position, insertion, "the old clock must stay stopped");
app.run("transport.play", json!({})).unwrap();
app.handle_transport(0.25);
assert!(app.is_playing());
assert_eq!(app.position(), insertion + i64::from(app.engine.session().sample_rate.hz()) / 4);
}
}
#[test]
fn new_session_cancels_play_queued_before_the_next_frame() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.run("transport.play", json!({})).unwrap();
app.run("session.new", json!({})).unwrap();
app.handle_transport(0.25);
assert!(!app.is_playing());
assert_eq!(app.engine.transport.position, 0);
}
#[test]
fn session_reset_does_not_apply_old_stop_edits_to_the_new_session() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.run("transport.play", json!({"from": 48_000})).unwrap();
app.handle_transport(0.25);
let mut session = soundcraft_model::Session::default();
session.edit.selection = soundcraft_time::Range::new(12_000, 24_000);
session.edit.insertion_follows_playback = true;
session.edit.scrolling = "after_playback".into();
let selection = session.edit.selection;
app.engine.replace_session(session);
app.handle_transport(0.25);
assert!(!app.is_playing());
assert_eq!(app.engine.session().edit.selection, selection);
assert_eq!(app.position(), selection.start);
assert_eq!(app.engine.session().edit.zoom.scroll, 0);
assert!(!app.engine.is_dirty());
}
#[test]
fn automation_first_pass_after_start_is_due_without_overflow() {
-3
View File
@@ -550,9 +550,6 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
let Some(mode) = crate::theme::ThemeMode::parse(mode) else {
return Some(Err(format!("unknown theme `{mode}` (system, light or dark)")));
};
if mode == crate::theme::ThemeMode::System {
crate::theme::refresh_system();
}
app.ui.theme = mode;
json!({"mode": mode.id()})
}
+3
View File
@@ -56,6 +56,9 @@ fn is_black(p: i32) -> bool {
}
pub fn show(app: &mut SoundApp, ui: &mut Ui) {
// Painter-only contents do not advance the layout. Claim the resized
// space, including the empty state, so the dock keeps its chosen height.
ui.take_available_space();
let t = Tokens::current();
let full = ui.max_rect();
ui.painter().rect_filled(full, 0.0, t.panel_bg);
+44 -9
View File
@@ -123,6 +123,21 @@ fn beat_detective(app: &mut SoundApp, ctx: &egui::Context) {
app.ui.show_beat_detective = open;
}
/// Command the Tempo Operations Apply button sends. Stretch uses `factor = start / end`
/// so a lower end BPM lengthens the selection (`event.tempo_stretch` has no `end_bpm`).
pub(crate) fn tempo_ops_apply(curve: &str, start_bpm: f64, end_bpm: f64) -> (&'static str, Value) {
let start = if start_bpm.is_finite() { start_bpm.max(1.0) } else { 1.0 };
let end = if end_bpm.is_finite() { end_bpm.max(1.0) } else { 1.0 };
match curve {
"constant" => ("event.tempo_constant", json!({"bpm": start_bpm})),
"parabolic" => ("event.tempo_parabolic", json!({"start_bpm": start_bpm, "end_bpm": end_bpm})),
"s-curve" => ("event.tempo_s_curve", json!({"start_bpm": start_bpm, "end_bpm": end_bpm})),
"scale" => ("event.tempo_scale", json!({"factor": end / start})),
"stretch" => ("event.tempo_stretch", json!({"factor": start / end})),
_ => ("event.tempo_linear", json!({"start_bpm": start_bpm, "end_bpm": end_bpm})),
}
}
fn tempo_ops(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_tempo_ops;
if !open {
@@ -136,16 +151,14 @@ fn tempo_ops(app: &mut SoundApp, ctx: &egui::Context) {
});
ui.add(egui::DragValue::new(&mut app.ops.tempo_start).range(5.0..=999.0).prefix("start bpm "));
ui.add(egui::DragValue::new(&mut app.ops.tempo_end).range(5.0..=999.0).prefix("end bpm "));
ui.label(egui::RichText::new("Applies across the edit selection (constant: at the insertion point; scale: factor = end / start).").small());
ui.label(
egui::RichText::new(
"Applies across the edit selection (constant: at the insertion point; scale: factor = end / start; stretch: factor = start / end).",
)
.small(),
);
if ui.button(egui::RichText::new("Apply").strong()).clicked() {
let (id, p) = match app.ops.tempo_curve.as_str() {
"constant" => ("event.tempo_constant", json!({"bpm": app.ops.tempo_start})),
"parabolic" => ("event.tempo_parabolic", json!({"start_bpm": app.ops.tempo_start, "end_bpm": app.ops.tempo_end})),
"s-curve" => ("event.tempo_s_curve", json!({"start_bpm": app.ops.tempo_start, "end_bpm": app.ops.tempo_end})),
"scale" => ("event.tempo_scale", json!({"factor": app.ops.tempo_end / app.ops.tempo_start.max(1.0)})),
"stretch" => ("event.tempo_stretch", json!({"end_bpm": app.ops.tempo_end})),
_ => ("event.tempo_linear", json!({"start_bpm": app.ops.tempo_start, "end_bpm": app.ops.tempo_end})),
};
let (id, p) = tempo_ops_apply(&app.ops.tempo_curve, app.ops.tempo_start, app.ops.tempo_end);
let r = app.run(id, p);
result_line(ui, &r);
}
@@ -274,3 +287,25 @@ fn rtp(app: &mut SoundApp, ctx: &egui::Context) {
});
app.ui.show_rtp = open;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stretch_uses_start_over_end_and_scale_uses_end_over_start() {
let (id, p) = tempo_ops_apply("stretch", 120.0, 60.0);
assert_eq!(id, "event.tempo_stretch");
assert!((p["factor"].as_f64().unwrap() - 2.0).abs() < 1e-9, "{p}");
let (_, p) = tempo_ops_apply("stretch", 120.0, 120.0);
assert!((p["factor"].as_f64().unwrap() - 1.0).abs() < 1e-9, "{p}");
let (id, p) = tempo_ops_apply("scale", 120.0, 60.0);
assert_eq!(id, "event.tempo_scale");
assert!((p["factor"].as_f64().unwrap() - 0.5).abs() < 1e-9, "{p}");
let (_, p) = tempo_ops_apply("stretch", f64::NAN, f64::INFINITY);
assert!((p["factor"].as_f64().unwrap() - 1.0).abs() < 1e-9, "{p}");
let (id, p) = tempo_ops_apply("linear", 100.0, 140.0);
assert_eq!(id, "event.tempo_linear");
assert_eq!(p["end_bpm"], 140.0);
}
}
+6
View File
@@ -170,6 +170,12 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let _ = app.run(id, json!({}));
true
}
// Ctrl+Shift+Up/Down nudge clip gain off macOS too (the registry's Ctrl is the Mac Control key).
Key::ArrowUp | Key::ArrowDown if !mac && m.ctrl && m.shift && !m.alt => {
let id = if key == Key::ArrowUp { "clip.gain_nudge_up" } else { "clip.gain_nudge_down" };
let _ = app.run(id, json!({}));
true
}
// Commands Keyboard Focus: single-key editing (Edit window only).
k if plain && app.ui.window == MainWindow::Edit && app.engine.session().edit.keyboard_focus == "commands" && commands_focus(app, k) => true,
Key::R | Key::T if plain => {
+84 -46
View File
@@ -1,18 +1,11 @@
//! Design tokens. Colours were chosen by eye to give a dark, studio-style look; they are ours.
use egui::{Color32, FontFamily, FontId};
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::atomic::{AtomicBool, Ordering};
/// Whether the Light palette is active. Dark is the default (the incumbent is dark-only).
static LIGHT_THEME: AtomicBool = AtomicBool::new(false);
/// The operating system's appearance, asked once off the UI thread.
static SYSTEM: AtomicU8 = AtomicU8::new(SYSTEM_UNKNOWN);
const SYSTEM_UNKNOWN: u8 = 0;
const SYSTEM_ASKING: u8 = 1;
const SYSTEM_DARK: u8 = 2;
const SYSTEM_LIGHT: u8 = 3;
/// The Appearance setting (`ui.theme {mode}`). Dark by default; System and Light are opt-in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
@@ -335,49 +328,15 @@ pub fn is_light() -> bool {
LIGHT_THEME.load(Ordering::Relaxed)
}
/// Whether `mode` resolves to the Light palette right now. Never blocks.
/// Resolve each frame from the integration's current OS appearance. Unknown uses Dark.
pub fn wants_light(ctx: &egui::Context, mode: ThemeMode) -> bool {
match mode {
ThemeMode::Light => true,
ThemeMode::Dark => false,
ThemeMode::System => system_is_light(ctx),
ThemeMode::System => ctx.system_theme() == Some(egui::Theme::Light),
}
}
/// Forget the cached system appearance so the next System lookup asks again.
pub fn refresh_system() {
let _ = SYSTEM.compare_exchange(SYSTEM_DARK, SYSTEM_UNKNOWN, Ordering::Relaxed, Ordering::Relaxed);
let _ = SYSTEM.compare_exchange(SYSTEM_LIGHT, SYSTEM_UNKNOWN, Ordering::Relaxed, Ordering::Relaxed);
}
/// The OS appearance. The query (a D-Bus call on Linux) runs once on a background thread; until it
/// answers this reports dark, and `ctx` is repainted when the answer arrives.
#[cfg(not(target_arch = "wasm32"))]
fn system_is_light(ctx: &egui::Context) -> bool {
if SYSTEM.compare_exchange(SYSTEM_UNKNOWN, SYSTEM_ASKING, Ordering::Relaxed, Ordering::Relaxed).is_ok() {
let ctx = ctx.clone();
let spawned = std::thread::Builder::new().name("soundcraft-theme".into()).spawn(move || {
let light = matches!(dark_light::detect(), Ok(dark_light::Mode::Light));
SYSTEM.store(if light { SYSTEM_LIGHT } else { SYSTEM_DARK }, Ordering::Relaxed);
ctx.request_repaint();
});
if spawned.is_err() {
SYSTEM.store(SYSTEM_DARK, Ordering::Relaxed);
}
}
SYSTEM.load(Ordering::Relaxed) == SYSTEM_LIGHT
}
/// On the web the query is a synchronous `matchMedia` lookup (no threads there).
#[cfg(target_arch = "wasm32")]
fn system_is_light(_ctx: &egui::Context) -> bool {
if SYSTEM.compare_exchange(SYSTEM_UNKNOWN, SYSTEM_ASKING, Ordering::Relaxed, Ordering::Relaxed).is_ok() {
let light = matches!(dark_light::detect(), Ok(dark_light::Mode::Light));
SYSTEM.store(if light { SYSTEM_LIGHT } else { SYSTEM_DARK }, Ordering::Relaxed);
}
SYSTEM.load(Ordering::Relaxed) == SYSTEM_LIGHT
}
/// Track colours tinted for clip bodies.
pub fn clip_colors(rgb: [u8; 3], selected: bool) -> (Color32, Color32, Color32) {
let [r, g, b] = rgb;
@@ -430,8 +389,13 @@ pub fn apply(ctx: &egui::Context, light: bool) {
v.widgets.inactive.fg_stroke.color = t.text;
v.window_corner_radius = egui::CornerRadius::same(6);
v.menu_corner_radius = egui::CornerRadius::same(4);
ctx.set_visuals(v);
ctx.global_style_mut(|s| {
// Keep receiving native OS appearance changes even when our palette is manual. On macOS,
// pinning egui's theme also pins the window appearance and hides later OS changes.
ctx.set_theme(egui::ThemePreference::System);
// Both egui style branches must use our resolved palette: egui can select either branch
// as the OS changes, including while SoundCraft's manual Light/Dark choice stays fixed.
ctx.all_styles_mut(|s| {
s.visuals = v.clone();
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
s.spacing.button_padding = egui::vec2(6.0, 2.0);
s.interaction.tooltip_delay = 0.4;
@@ -476,4 +440,78 @@ mod tests {
assert_eq!(app.run("ui.theme", json!({"mode": "system"})), Ok(json!({"mode": "system"})));
assert!(crate::menus::UI_COMMANDS.iter().any(|c| c.0 == "ui.theme"));
}
#[test]
fn application_frames_follow_system_appearance_and_preserve_manual_palettes() {
// Keep all frame assertions in one test: custom-painted tokens share LIGHT_THEME.
fn frame(app: &mut SoundApp, ctx: &egui::Context, system_theme: Option<egui::Theme>, light: bool) {
let mode = app.ui.theme;
let colors = |app: &SoundApp| {
app.engine
.session()
.tracks
.iter()
.map(|track| {
(track.color, track.playlists.iter().flat_map(|playlist| &playlist.clips).map(|clip| clip.color).collect::<Vec<_>>())
})
.collect::<Vec<_>>()
};
let before = colors(app);
let mut output = ctx.run_ui(egui::RawInput { system_theme, ..Default::default() }, |ui| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
let tokens = if light { Tokens::LIGHT } else { Tokens::DARK };
assert_eq!(is_light(), light, "custom palette for {mode:?} with {system_theme:?}");
assert_eq!(Tokens::current().panel_bg, tokens.panel_bg);
assert_eq!(ctx.options(|options| options.theme_preference), egui::ThemePreference::System);
for theme in [egui::Theme::Light, egui::Theme::Dark] {
let style = ctx.style_of(theme);
assert_eq!(style.visuals.dark_mode, !light);
assert_eq!(style.visuals.panel_fill, tokens.panel_bg);
assert_eq!(style.visuals.widgets.inactive.bg_fill, tokens.button);
assert_eq!(style.visuals.widgets.inactive.fg_stroke.color, tokens.text);
assert_eq!(style.spacing.button_padding, egui::vec2(6.0, 2.0));
}
app.ui(ui);
assert_eq!(ui.visuals().panel_fill, tokens.panel_bg);
assert_eq!(ui.visuals().widgets.inactive.bg_fill, tokens.button);
});
output.textures_delta.clear();
assert_eq!(app.ui.theme, mode);
assert_eq!(serde_json::to_value(&app.ui).unwrap()["theme"], mode.id());
assert_eq!(colors(app), before, "appearance must preserve track and clip colors");
}
let appearances = [Some(egui::Theme::Light), Some(egui::Theme::Dark), Some(egui::Theme::Light), None];
for initial in [Some(egui::Theme::Light), Some(egui::Theme::Dark), None] {
let ctx = egui::Context::default();
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
app.ui = serde_json::from_value(json!({"theme": "system"})).unwrap();
frame(&mut app, &ctx, initial, initial == Some(egui::Theme::Light));
}
let ctx = egui::Context::default();
let mut engine = soundcraft_engine::demo::demo_engine();
let track = engine.session_mut().tracks.first_mut().unwrap();
track.color = [19, 83, 147];
track.playlists.iter_mut().flat_map(|playlist| &mut playlist.clips).next().unwrap().color = Some([211, 57, 99]);
let mut app = SoundApp::new(engine, None, Services::default());
for mode in [ThemeMode::System, ThemeMode::Light, ThemeMode::Dark] {
app.run("ui.theme", json!({"mode": mode.id()})).unwrap();
for appearance in appearances {
let light = mode == ThemeMode::Light || (mode == ThemeMode::System && appearance == Some(egui::Theme::Light));
frame(&mut app, &ctx, appearance, light);
}
if mode != ThemeMode::System {
let latest = if mode == ThemeMode::Light { egui::Theme::Dark } else { egui::Theme::Light };
frame(&mut app, &ctx, Some(latest), mode == ThemeMode::Light);
app.run("ui.theme", json!({"mode": "system"})).unwrap();
// Resolve the current context immediately, without another OS appearance event.
app.logic(&ctx);
assert_eq!(is_light(), latest == egui::Theme::Light);
assert_eq!(ctx.global_style().visuals.dark_mode, latest != egui::Theme::Light);
frame(&mut app, &ctx, Some(latest), latest == egui::Theme::Light);
}
}
}
}
+2 -2
View File
@@ -446,11 +446,11 @@ pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) {
let r = ui.text_edit_singleline(&mut edit);
ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone()));
if r.lost_focus() && edit != name {
let _ = app.run("mix.bus_rename", json!({"bus": name, "name": edit}));
let _ = app.run("setup.io", json!({"action": "rename_bus", "name": name, "new_name": edit}));
}
ui.label(fmt);
if ui.small_button("Delete").clicked() {
let _ = app.run("mix.bus_delete", json!({"bus": name}));
let _ = app.run("setup.io", json!({"action": "delete_bus", "name": name}));
}
ui.end_row();
}
+79
View File
@@ -0,0 +1,79 @@
use egui::{Event, Id, PointerButton, Pos2, RawInput, Rect, pos2, vec2};
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp, midi_editor};
fn frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec<Event>) {
let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() };
ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
})
.textures_delta
.clear();
}
fn panel(ctx: &egui::Context) -> Rect {
egui::containers::panel::PanelState::load(ctx, Id::new("midi_editor")).unwrap().outer_rect
}
fn button(pos: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }
}
fn check_resize(with_clip: bool) {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.ui.show_midi_editor = true;
if with_clip {
app.run("edit.select", json!({"tracks": ["Keys"]})).unwrap();
}
assert_eq!(midi_editor::target_clip(&app).is_some(), with_clip);
let ctx = egui::Context::default();
for _ in 0..6 {
frame(&ctx, &mut app, vec![]);
}
let initial = panel(&ctx);
assert!((initial.height() - 320.0).abs() < 1.0, "initial panel: {initial:?}");
let notes = app.engine.session().clone();
// Drag the shared boundary up and down, then allow several idle frames
// to catch a panel that looks resized but snaps back after release.
for height in [440.0, 280.0, 200.0] {
let before = panel(&ctx);
let start = pos2(before.center().x, before.top() + 1.0);
let end = pos2(start.x, before.bottom() - height);
frame(&ctx, &mut app, vec![Event::PointerMoved(start)]);
frame(&ctx, &mut app, vec![button(start, true)]);
for step in 1..=6 {
frame(&ctx, &mut app, vec![Event::PointerMoved(start.lerp(end, step as f32 / 6.0))]);
}
frame(&ctx, &mut app, vec![button(end, false)]);
for _ in 0..4 {
frame(&ctx, &mut app, vec![]);
}
let after = panel(&ctx);
assert!((after.height() - height.max(260.0)).abs() < 1.0, "requested {height}, panel: {after:?}");
assert!((after.bottom() - initial.bottom()).abs() < 1.0);
assert!(app.edit_layout.timeline[3] <= after.top());
}
// Closing and reopening the dock keeps the chosen height.
let resized = panel(&ctx);
app.run("window.midi_editor", json!({})).unwrap();
frame(&ctx, &mut app, vec![]);
app.run("window.midi_editor", json!({})).unwrap();
for _ in 0..4 {
frame(&ctx, &mut app, vec![]);
}
assert_eq!(panel(&ctx), resized);
assert_eq!(*app.engine.session(), notes);
}
#[test]
fn midi_panel_resizes_with_a_selected_clip() {
check_resize(true);
}
#[test]
fn midi_panel_resizes_without_a_selected_clip() {
check_resize(false);
}
+14 -10
View File
@@ -1,3 +1,4 @@
use egui::os::OperatingSystem;
use egui::{Key, Modifiers};
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp, shortcuts};
@@ -9,14 +10,17 @@ fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers
#[test]
fn ctrl_shift_arrows_nudge_the_selected_clip_gain() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id;
app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap();
let ctx = egui::Context::default();
ctx.set_os(egui::os::OperatingSystem::Mac);
let ctrl_shift = Modifiers { ctrl: true, shift: true, ..Modifiers::NONE };
press(&ctx, &mut app, Key::ArrowUp, ctrl_shift);
press(&ctx, &mut app, Key::ArrowUp, ctrl_shift);
press(&ctx, &mut app, Key::ArrowDown, ctrl_shift);
assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0);
let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
for (os, ctrl) in [(OperatingSystem::Mac, Modifiers::CTRL), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id;
app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap();
let ctx = egui::Context::default();
ctx.set_os(os);
let ctrl_shift = Modifiers { shift: true, ..ctrl };
press(&ctx, &mut app, Key::ArrowUp, ctrl_shift);
press(&ctx, &mut app, Key::ArrowUp, ctrl_shift);
press(&ctx, &mut app, Key::ArrowDown, ctrl_shift);
assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0, "{os:?}");
}
}
+8 -1
View File
@@ -64,4 +64,11 @@ soundcraft-cli app --port $P ui.menu.invoke '{"path":"Track > New..."}'
Offscreen (no window): `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png script.jsonl`,
where each script line is a request (`{"method": …, "params": …}`), `{"shot": "path.png"}` or
`{"steps": n}`.
`{"steps": n}`. Use `--system-theme light|dark|none` to supply the initial OS appearance, and
script events such as `{"system_theme": "dark"}` to change it on subsequent frames.
Appearance defaults to Dark. `ui.theme {"mode":"system"}` follows the appearance supplied by
the native or web integration at launch and on later frames, while the saved choice stays System.
When the integration cannot report an appearance, System uses Dark. Manual Light and Dark stay
fixed when the OS appearance changes. An offscreen render without `--system-theme` supplies no
OS appearance and therefore resolves System to Dark.