mirror of
https://github.com/aphrody-labs/soundcraft.git
synced 2026-10-14 03:29:05 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
commit
dc2ffda4d6
16 files changed
+387
-114
No files matched your search
Generated
-37
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user