diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
index 2bfee3a..d3fdde9 100644
--- a/.github/workflows/cd.yml
+++ b/.github/workflows/cd.yml
@@ -215,7 +215,7 @@ jobs:
if: env.SIGNED != 'true'
run: scripts/bundle-app.sh
- name: Start check
- run: dist/stemd.app/Contents/MacOS/stemd --version
+ run: dist/Stemd.app/Contents/MacOS/stemd --version
- name: Remove credentials
if: always()
run: |
@@ -271,14 +271,14 @@ jobs:
Test-Version dist\stemd-windows\stemd-server.exe
$setup = Get-Item dist\stemd-*-setup.exe
Start-Process $setup.FullName -ArgumentList '/S' -Wait
- $installed = Join-Path $env:LOCALAPPDATA 'Programs\stemd'
+ $installed = Join-Path $env:LOCALAPPDATA 'Programs\Stemd'
Test-Version (Join-Path $installed 'stemd-server.exe')
Test-Version (Join-Path $installed 'stemd-cli.exe')
# The runner has no NVIDIA driver.
- name: CPU separation
timeout-minutes: 30
run: |
- $installed = Join-Path $env:LOCALAPPDATA 'Programs\stemd'
+ $installed = Join-Path $env:LOCALAPPDATA 'Programs\Stemd'
.github/scripts/cpu-separation.ps1 -Server (Join-Path $installed 'stemd-server.exe') -Cli (Join-Path $installed 'stemd-cli.exe')
- uses: actions/upload-artifact@v4
with:
diff --git a/Cargo.lock b/Cargo.lock
index cad6290..dcd5983 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8,6 +8,8 @@ version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
dependencies = [
+ "enumn",
+ "serde",
"uuid",
]
@@ -26,6 +28,7 @@ dependencies = [
"cfg-if",
"getrandom 0.3.4",
"once_cell",
+ "serde",
"version_check",
"zerocopy",
]
@@ -312,6 +315,9 @@ name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+dependencies = [
+ "serde_core",
+]
[[package]]
name = "block-buffer"
@@ -859,6 +865,7 @@ checksum = "6758be723a3f298bbfda4db75748bc2ba0abafe096b6383c7c32da264764fbc3"
dependencies = [
"bytemuck",
"emath",
+ "serde",
]
[[package]]
@@ -877,6 +884,7 @@ dependencies = [
"glow",
"glutin",
"glutin-winit",
+ "home",
"image",
"js-sys",
"log",
@@ -887,6 +895,8 @@ dependencies = [
"percent-encoding",
"profiling",
"raw-window-handle",
+ "ron",
+ "serde",
"static_assertions",
"wasm-bindgen",
"wasm-bindgen-futures",
@@ -911,6 +921,8 @@ dependencies = [
"log",
"nohash-hasher",
"profiling",
+ "ron",
+ "serde",
"smallvec",
"unicode-segmentation",
]
@@ -950,6 +962,7 @@ dependencies = [
"objc2-ui-kit 0.3.2",
"profiling",
"raw-window-handle",
+ "serde",
"smithay-clipboard",
"web-time",
"webbrowser",
@@ -984,6 +997,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd4ec073c9898516584d8c6cfdcee95b530b3d941cd5031ef4050aa36812308b"
dependencies = [
"bytemuck",
+ "serde",
]
[[package]]
@@ -995,6 +1009,17 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "enumn"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "epaint"
version = "0.35.0"
@@ -1013,6 +1038,7 @@ dependencies = [
"parking_lot",
"profiling",
"self_cell",
+ "serde",
"skrifa",
"smallvec",
"unicode-general-category",
@@ -1153,6 +1179,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7"
dependencies = [
"bytemuck",
+ "serde",
]
[[package]]
@@ -1483,6 +1510,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
+[[package]]
+name = "home"
+version = "0.5.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "hound"
version = "3.5.1"
@@ -2904,6 +2940,19 @@ dependencies = [
"windows-sys 0.52.0",
]
+[[package]]
+name = "ron"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97b3fe97c18ff24299ea56f802c5693eb34266fb0bc8317f4e101debc8a3ceaf"
+dependencies = [
+ "bitflags 2.13.1",
+ "serde",
+ "serde_derive",
+ "typeid",
+ "unicode-ident",
+]
+
[[package]]
name = "rubato"
version = "5.0.0"
@@ -3246,6 +3295,9 @@ name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+dependencies = [
+ "serde",
+]
[[package]]
name = "smithay-client-toolkit"
@@ -3422,6 +3474,7 @@ dependencies = [
"dirs",
"eframe",
"if-addrs",
+ "libc",
"parking_lot",
"serde",
"serde_json",
@@ -3435,6 +3488,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"ureq",
+ "windows-sys 0.61.2",
"winit",
"winresource",
"zip",
@@ -3981,6 +4035,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
+[[package]]
+name = "typeid"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
+
[[package]]
name = "typenum"
version = "1.20.1"
@@ -4081,6 +4141,9 @@ name = "uuid"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
+dependencies = [
+ "serde_core",
+]
[[package]]
name = "valuable"
diff --git a/Cargo.toml b/Cargo.toml
index 219b2f2..199b0a8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,9 +15,8 @@ members = [
[workspace.package]
version = "0.1.4"
edition = "2024"
-# Stated so cargo says "requires rustc 1.85" rather than failing to parse the
-# edition. It is the edition's floor, not a tested one.
-rust-version = "1.85"
+# 1.89 for `File::try_lock`, which keeps a second server off a cache in use.
+rust-version = "1.89"
license = "MIT OR Apache-2.0"
repository = "https://github.com/nsaintot/stemd"
readme = "README.md"
diff --git a/README.md b/README.md
index 1141dd8..c9ff261 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,15 @@
-
+
-# stemd
+# Stemd
**Split any track into stems, on your own machine.**
Drop a file on the window. Get `harmonics`, `vocals` and `drums` in a folder
beside it.
-
+
[](#license)
[](#install)
@@ -43,7 +43,7 @@ track bit for bit.
**macOS.** Open the `macos-arm64` disk image from the
[latest release](https://github.com/nsaintot/stemd/releases/latest) and drag
-stemd to Applications. Signed and notarized, and the ticket is stapled to the
+Stemd to Applications. Signed and notarized, and the ticket is stapled to the
app as well as to the image, so it opens without argument and the first launch
does not need a network.
@@ -54,7 +54,7 @@ installer.
On a machine with an NVIDIA card, tick the box at the end of the installer or run
`install-cuda.cmd` once afterwards: about 1.2 GB of NVIDIA's own runtime, fetched
-once and pinned by digest. No toolkit and no repository. Without it stemd runs on
+once and pinned by digest. No toolkit and no repository. Without it Stemd runs on
the CPU, correctly and far slower.
**Linux.** Take the package for your distribution and architecture
@@ -81,7 +81,7 @@ Drop a track on the window, or click to choose one. The stems appear in
### From another machine
-stemd is also a server. It announces itself over mDNS, so a client does not need
+Stemd is also a server. It announces itself over mDNS, so a client does not need
to be told an address, and it speaks plain HTTP:
```bash
@@ -126,7 +126,7 @@ by the audio and by everything else that changes the output.
## Build from source
-Needs `cmake`, Rust 1.85 or later, and a recursive clone. Per-platform
+Needs `cmake`, Rust 1.89 or later, and a recursive clone. Per-platform
toolchains, packaging and every flag are in
[docs/building.md](docs/building.md).
diff --git a/crates/stemd-cli/src/discovery.rs b/crates/stemd-cli/src/discovery.rs
index 80500ce..4115df3 100644
--- a/crates/stemd-cli/src/discovery.rs
+++ b/crates/stemd-cli/src/discovery.rs
@@ -30,7 +30,7 @@ pub fn discover(timeout: Duration) -> Result
{
let _ = daemon.shutdown();
bail!(
"no stemd server found on the network after {}s — is it running? \
- start it with `open dist/stemd.app`, or pass --host host:port",
+ start it with `open dist/Stemd.app`, or pass --host host:port",
timeout.as_secs()
)
}
diff --git a/crates/stemd-core/src/stemfmt.rs b/crates/stemd-core/src/stemfmt.rs
index 4c2df75..d47e73b 100644
--- a/crates/stemd-core/src/stemfmt.rs
+++ b/crates/stemd-core/src/stemfmt.rs
@@ -131,9 +131,9 @@ impl StemFormat {
match self {
Self::Flac => "FLAC",
Self::Wav => "WAV",
- Self::Mp3 => "MP3 320",
- Self::Pcm16 => "PCM 16",
- Self::Pcm32 => "PCM 32",
+ Self::Mp3 => "MP3",
+ Self::Pcm16 => "PCM 16-bit",
+ Self::Pcm32 => "PCM 32-bit float",
}
}
}
diff --git a/crates/stemd-mlx/src/device.rs b/crates/stemd-mlx/src/device.rs
index 844d23f..4819d50 100644
--- a/crates/stemd-mlx/src/device.rs
+++ b/crates/stemd-mlx/src/device.rs
@@ -74,7 +74,7 @@ impl Accelerator {
if !gpu {
return (Self::Cpu, false);
}
- if !gpu_computes() {
+ if !gpu_computes_on_kept_thread() {
return (Self::demote(), true);
}
let which = if cfg!(target_vendor = "apple") {
@@ -118,6 +118,24 @@ impl std::fmt::Display for Accelerator {
}
}
+/// [`gpu_computes`] on a thread that never exits.
+///
+/// MLX keeps CUDA state per thread, and on Windows its destructor throws when the
+/// thread exits. Windows reports that as a crash and freezes the whole process for
+/// about 3 s while it writes the report.
+fn gpu_computes_on_kept_thread() -> bool {
+ let (tx, rx) = std::sync::mpsc::channel();
+ let spawned = std::thread::Builder::new()
+ .name("stemd-gpu-probe".into())
+ .spawn(move || {
+ let _ = tx.send(gpu_computes());
+ loop {
+ std::thread::park();
+ }
+ });
+ spawned.is_ok() && rx.recv().unwrap_or(false)
+}
+
/// Whether the GPU MLX offered returns right answers.
///
/// One operation of each kind the models use, on inputs small enough that the
diff --git a/crates/stemd-server/Cargo.toml b/crates/stemd-server/Cargo.toml
index f4da714..dccca20 100644
--- a/crates/stemd-server/Cargo.toml
+++ b/crates/stemd-server/Cargo.toml
@@ -22,7 +22,8 @@ clap = { version = "4.6.6", features = ["derive"] }
tokio-util = { version = "0.7.19", features = ["io"] }
parking_lot = "0.12.5"
thiserror.workspace = true
-eframe = { version = "0.35.0", default-features = false, features = ["glow", "default_fonts"] }
+# `persistence` saves the window's position and size between launches.
+eframe = { version = "0.35.0", default-features = false, features = ["glow", "default_fonts", "persistence"] }
if-addrs = "0.15.0"
# First-run model fetch: HTTP plus a pinned digest.
ureq = "3.4.0"
@@ -51,6 +52,12 @@ winit = { version = "0.30.13", default-features = false, features = ["x11"] }
# download only there. See `cuda`.
[target.'cfg(windows)'.dependencies]
zip = { version = "2", default-features = false, features = ["deflate"] }
+# Free disk space, see `disk.rs`.
+windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
+
+[target.'cfg(unix)'.dependencies]
+# statvfs and sysctl, see `disk.rs`.
+libc = "0.2"
# `default-features = false` above is what keeps wgpu and accesskit out, but it
# also drops `x11` and `wayland`, and those are not extras — winit has no Linux
@@ -62,6 +69,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
eframe = { version = "0.35.0", default-features = false, features = [
"glow",
"default_fonts",
+ "persistence",
"x11",
"wayland",
] }
diff --git a/crates/stemd-server/src/api.rs b/crates/stemd-server/src/api.rs
index 3abe2d3..2eaa5f0 100644
--- a/crates/stemd-server/src/api.rs
+++ b/crates/stemd-server/src/api.rs
@@ -31,7 +31,6 @@ use stemd_core::{Audio, DspMode, OutputRate, Parts, PcmFormat, Stage, resample};
use tokio_util::io::ReaderStream;
use crate::cache::{Cache, Ingredients, Output};
-use crate::discovery::Advertiser;
use crate::jobs::JobStore;
use crate::logbuf::LogBuffer;
use crate::models::Preset;
@@ -53,9 +52,9 @@ pub struct AppState {
/// `/v1/health` so a client can refuse locally instead of uploading a track
/// it will be told to take back.
pub max_track_secs: f64,
- /// Present unless --no-mdns. Shared so every exit path can withdraw the
- /// advertisement rather than relying on Drop, which macOS skips on Cmd-Q.
- pub advertiser: Option>,
+ /// Whether the API answers, and its mDNS advertisement. Shared so every
+ /// exit path can withdraw the advertisement: macOS skips Drop on Cmd-Q.
+ pub serving: crate::serving::Serving,
}
pub fn router(state: Arc) -> Router {
@@ -67,9 +66,30 @@ pub fn router(state: Arc) -> Router {
.route("/v1/jobs/{id}/stems/{name}", get(get_stem))
.route("/v1/logs", get(get_logs))
.merge(crate::session_api::routes())
+ .route_layer(axum::middleware::from_fn_with_state(
+ Arc::clone(&state),
+ refuse_when_off,
+ ))
.with_state(state)
}
+/// 503 for every request while the server is turned off in the window.
+async fn refuse_when_off(
+ State(state): State>,
+ request: axum::extract::Request,
+ next: axum::middleware::Next,
+) -> Response {
+ if state.serving.is_on() {
+ next.run(request).await
+ } else {
+ ApiError::new(
+ StatusCode::SERVICE_UNAVAILABLE,
+ "the server is turned off in the Stemd window",
+ )
+ .into_response()
+ }
+}
+
#[derive(Debug, Serialize)]
struct Health {
version: &'static str,
@@ -518,6 +538,16 @@ async fn create_job(
return Ok((StatusCode::OK, Json(job.view())).into_response());
}
+ // Stems are written to the cache before they are served, and a full disk
+ // also leaves the system no room to swap.
+ if let Err(full) = crate::disk::ensure_room(state.cache.root()) {
+ job.fail(full.to_string());
+ return Err(ApiError::new(
+ StatusCode::INSUFFICIENT_STORAGE,
+ full.to_string(),
+ ));
+ }
+
let mix = decode_upload(body, submission, &job).await?;
enqueue(&state, &job, mix, submission)?;
Ok((StatusCode::ACCEPTED, Json(job.view())).into_response())
@@ -538,6 +568,7 @@ fn enqueue(
mix,
output: submission.output,
model_id: state.switcher.published_identity(),
+ from_api: true,
})
.map_err(|full| {
job.fail(format!("{full}"));
@@ -626,6 +657,7 @@ async fn get_stem(
// Only once the bytes are provably readable. Marking on request would let a
// failed fetch count as consumption and keep an entry nobody can use.
+ entry.hand_out();
entry.mark_fetched(&name);
Ok(Response::builder()
diff --git a/crates/stemd-server/src/cache.rs b/crates/stemd-server/src/cache.rs
index 9b65981..871449b 100644
--- a/crates/stemd-server/src/cache.rs
+++ b/crates/stemd-server/src/cache.rs
@@ -168,6 +168,9 @@ pub struct Entry {
/// Set when the cache deletes this entry's directory, so a job holding the
/// last reference can tell that its stems are gone.
evicted: AtomicBool,
+ /// Set once [`Cache::get`] or a stem download gives the entry to a caller,
+ /// so a drop that made it does not delete it from under them.
+ handed_out: AtomicBool,
state: Mutex,
}
@@ -192,6 +195,11 @@ impl Entry {
self.evicted.load(Ordering::SeqCst)
}
+ /// Record that a client outside the window has the entry. See `handed_out`.
+ pub fn hand_out(&self) {
+ self.handed_out.store(true, Ordering::SeqCst);
+ }
+
/// Record that `name` was pulled, and restart the idle clock.
///
/// Touching here is what stops rule 1 from collecting an entry between a
@@ -258,6 +266,8 @@ pub struct Cache {
/// Makes staging directory names unique. The worker is serialised today, so
/// two publishes cannot overlap, this keeps that from being load-bearing.
seq: AtomicU64,
+ /// Whether the cap shrinks to keep [`crate::disk::FLOOR`] free.
+ disk_aware: AtomicBool,
}
/// Where entries live when no directory is configured.
@@ -284,6 +294,40 @@ pub fn default_dir() -> Result {
})
}
+/// The cache root `args` names, or [`default_dir`].
+pub fn root(args: &crate::cli::Args) -> Result {
+ match args.cache_dir.clone() {
+ Some(dir) => Ok(dir),
+ None => default_dir(),
+ }
+}
+
+/// The lock file in the cache root, which the startup clear keeps.
+const LOCK_FILE: &str = "stemd.lock";
+
+/// Lock `root` for this process, or fail if another stemd holds it, whatever
+/// port that one serves on. The system releases the lock when the process ends.
+pub fn lock(root: &Path) -> Result {
+ std::fs::create_dir_all(root).with_context(|| format!("creating {}", root.display()))?;
+ let path = root.join(LOCK_FILE);
+ let file = std::fs::OpenOptions::new()
+ .create(true)
+ .truncate(false)
+ .write(true)
+ .open(&path)
+ .with_context(|| format!("opening {}", path.display()))?;
+ match file.try_lock() {
+ Ok(()) => Ok(file),
+ Err(std::fs::TryLockError::WouldBlock) => anyhow::bail!(
+ "another stemd is already running with its cache in {}",
+ root.display()
+ ),
+ Err(std::fs::TryLockError::Error(err)) => {
+ Err(err).with_context(|| format!("locking {}", path.display()))
+ }
+ }
+}
+
impl Cache {
/// Prepare `root`, discarding whatever a previous run left behind.
///
@@ -300,6 +344,7 @@ impl Cache {
max_bytes,
unfetched_ttl,
seq: AtomicU64::new(0),
+ disk_aware: AtomicBool::new(true),
}))
}
@@ -316,6 +361,7 @@ impl Cache {
let entries = self.entries.lock();
let entry = entries.get(key).cloned()?;
entry.touch();
+ entry.hand_out();
entry
};
@@ -354,6 +400,21 @@ impl Cache {
(tracks, freed)
}
+ /// Delete `entry` and its stems now, unless it was replaced under its key or
+ /// [`Cache::get`] has handed it to anyone. Returns whether it went.
+ pub fn discard_unshared(&self, entry: &Arc) -> bool {
+ let mut entries = self.entries.lock();
+ let ours = entries
+ .get(&entry.key)
+ .is_some_and(|held| Arc::ptr_eq(held, entry));
+ if !ours || entry.handed_out.load(Ordering::SeqCst) {
+ return false;
+ }
+ entries.remove(&entry.key);
+ self.delete(entry);
+ true
+ }
+
/// One consistent snapshot, rather than three locks that can disagree.
pub fn stats(&self) -> Stats {
let entries = self.entries.lock();
@@ -387,8 +448,16 @@ impl Cache {
let dir = self.root.join(key);
let staging = self.staging_dir(key)?;
- let files = write_stems(&staging, &dir, &shipped, output.format)?;
- install(&staging, &dir)?;
+ let written = write_stems(&staging, &dir, &shipped, output.format)
+ .and_then(|files| install(&staging, &dir).map(|()| files));
+ let files = match written {
+ Ok(files) => files,
+ Err(err) => {
+ // A half-written entry would hold its bytes until the next start.
+ let _ = std::fs::remove_dir_all(&staging);
+ return Err(err);
+ }
+ };
let entry = Arc::new(Entry {
key: key.to_owned(),
@@ -405,6 +474,7 @@ impl Cache {
derived: output.parts.derived(),
separation_secs,
evicted: AtomicBool::new(false),
+ handed_out: AtomicBool::new(false),
state: Mutex::new(State {
unfetched: shipped.iter().map(|(name, _)| (*name).to_owned()).collect(),
last_used: Instant::now(),
@@ -481,13 +551,36 @@ impl Cache {
/// the store under it, so waiting for the cap would make rule 1 nearly
/// unreachable.
fn under_pressure(&self, entries: &HashMap>) -> bool {
- total_bytes(entries) > self.max_bytes / 2
+ let total = total_bytes(entries);
+ total > self.budget(total) / 2
+ }
+
+ /// The cap, or less when the disk is short of [`crate::disk::FLOOR`].
+ fn budget(&self, total: u64) -> u64 {
+ if self.disk_aware.load(Ordering::Relaxed) {
+ crate::disk::budget(self.max_bytes, total, &self.root)
+ } else {
+ self.max_bytes
+ }
+ }
+
+ /// Hold to the cap alone, whatever the disk has free, so tests do not
+ /// depend on the machine running them.
+ #[cfg(test)]
+ pub fn ignore_disk(&self) {
+ self.disk_aware.store(false, Ordering::Relaxed);
+ }
+
+ /// The directory the stems live in.
+ pub fn root(&self) -> &Path {
+ &self.root
}
/// Drop least-recently-used entries until the store fits.
fn enforce_cap(&self, entries: &mut HashMap>) -> (usize, u64) {
let mut total = total_bytes(entries);
- if total <= self.max_bytes {
+ let budget = self.budget(total);
+ if total <= budget {
return (0, 0);
}
@@ -504,7 +597,7 @@ impl Cache {
let mut count = 0;
let mut freed = 0;
for (_, key) in order {
- if total <= self.max_bytes {
+ if total <= budget {
break;
}
if let Some(entry) = entries.remove(&key) {
@@ -534,6 +627,9 @@ impl Cache {
fn clear_contents(dir: &Path) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
+ if path.file_name().is_some_and(|name| name == LOCK_FILE) {
+ continue;
+ }
if path.is_dir() {
std::fs::remove_dir_all(&path)?;
} else {
@@ -724,6 +820,19 @@ mod tests {
use crate::testkit::{cache, publish};
+ #[test]
+ fn a_second_lock_on_the_same_root_fails_and_survives_the_startup_clear() {
+ let root = std::env::temp_dir().join(format!("stemd-lock-test-{}", std::process::id()));
+ let held = lock(&root).unwrap();
+ let err = lock(&root).unwrap_err();
+ assert!(err.to_string().contains("already running"), "{err:#}");
+ clear_contents(&root).unwrap();
+ assert!(root.join(LOCK_FILE).exists());
+ drop(held);
+ lock(&root).unwrap();
+ std::fs::remove_dir_all(&root).unwrap();
+ }
+
/// A 200 must be followed by stems the client can actually download.
///
/// The TTL is zero so every published entry is collectable at once; a nonzero one
@@ -818,6 +927,30 @@ mod tests {
assert_eq!(cache.stats().tracks, 0);
}
+ /// What a drop does once its stems are copied out: that entry goes, others stay.
+ #[test]
+ fn discarding_takes_one_entry_and_its_stems() {
+ let cache = cache(u64::MAX, Duration::from_secs(60));
+ let dropped = publish(&cache, "dropped", 64, 1.0);
+ let kept = publish(&cache, "kept", 32, 1.0);
+
+ assert!(cache.discard_unshared(&dropped));
+ assert!(!dropped.dir.exists() && dropped.evicted());
+ assert!(cache.get(&dropped.key).is_none());
+ assert!(kept.dir.exists() && cache.get(&kept.key).is_some());
+ assert_eq!(cache.stats().tracks, 1);
+ }
+
+ /// An entry someone was handed by a hit stays, whoever made it.
+ #[test]
+ fn an_entry_handed_out_is_not_discarded() {
+ let cache = cache(u64::MAX, Duration::from_secs(60));
+ let made = publish(&cache, "shared", 64, 1.0);
+ let _hit = cache.get(&made.key).expect("a hit");
+ assert!(!cache.discard_unshared(&made));
+ assert!(made.dir.exists());
+ }
+
#[test]
fn clearing_takes_the_index_and_the_stems_together() {
let cache = cache(u64::MAX, Duration::from_secs(60));
diff --git a/crates/stemd-server/src/disk.rs b/crates/stemd-server/src/disk.rs
new file mode 100644
index 0000000..13ce365
--- /dev/null
+++ b/crates/stemd-server/src/disk.rs
@@ -0,0 +1,180 @@
+//! Free disk space and memory pressure, read so work is refused or trimmed
+//! before the machine runs out.
+//!
+//! With the disk full, macOS cannot grow swap, and a separation that needs more
+//! memory stalls the whole process inside an allocation.
+
+use std::path::Path;
+
+/// Free space below which new work is refused and caches stop growing.
+pub const FLOOR: u64 = 2_000_000_000;
+
+/// Bytes free to this user on the volume holding `path`, or `None` if it cannot
+/// be read. A path that does not exist yet is measured at its nearest existing
+/// ancestor.
+pub fn free_bytes(path: &Path) -> Option {
+ let existing = path.ancestors().find(|p| p.exists())?;
+ free_at(existing)
+}
+
+/// Whether the volume holding `path` is under [`FLOOR`].
+pub fn low(path: &Path) -> bool {
+ free_bytes(path).is_some_and(|free| free < FLOOR)
+}
+
+/// Work refused for want of disk space.
+#[derive(Debug, thiserror::Error)]
+#[error(
+ "not enough disk space: {:.1} GB free, {:.1} GB needed; free some space and try again",
+ *free as f64 / 1e9,
+ *needed as f64 / 1e9
+)]
+pub struct DiskFull {
+ pub free: u64,
+ pub needed: u64,
+}
+
+/// Fail unless the volume holding `path` has [`FLOOR`] free. Passes when the
+/// free space cannot be read.
+pub fn ensure_room(path: &Path) -> Result<(), DiskFull> {
+ ensure_room_for(path, 0)
+}
+
+/// Fail unless the volume holding `path` can take `bytes` and keep [`FLOOR`]
+/// free.
+pub fn ensure_room_for(path: &Path, bytes: u64) -> Result<(), DiskFull> {
+ let needed = bytes.saturating_add(FLOOR);
+ match free_bytes(path) {
+ Some(free) if free < needed => Err(DiskFull { free, needed }),
+ _ => Ok(()),
+ }
+}
+
+/// Room a cache may hold: its cap, reduced so the volume keeps [`FLOOR`] free.
+/// `used` is what the cache already holds on that volume.
+pub fn budget(cap: u64, used: u64, path: &Path) -> u64 {
+ free_bytes(path).map_or(cap, |free| fit(cap, used, free))
+}
+
+/// `cap`, cut so that a cache holding `used` with `free` left keeps [`FLOOR`] free.
+const fn fit(cap: u64, used: u64, free: u64) -> u64 {
+ let room = (used + free).saturating_sub(FLOOR);
+ if cap < room { cap } else { room }
+}
+
+/// Whether the system is short of memory: macOS's warning level or worse.
+/// Always false elsewhere.
+pub fn memory_pressure() -> bool {
+ #[cfg(target_os = "macos")]
+ {
+ let mut level: libc::c_int = 0;
+ let mut size = std::mem::size_of::();
+ // SAFETY: the name is NUL-terminated and `level` is a live int of `size` bytes.
+ let read = unsafe {
+ libc::sysctlbyname(
+ c"kern.memorystatus_vm_pressure_level".as_ptr(),
+ (&raw mut level).cast(),
+ &raw mut size,
+ std::ptr::null_mut(),
+ 0,
+ )
+ };
+ // 1 normal, 2 warning, 4 critical.
+ read == 0 && level >= 2
+ }
+ #[cfg(not(target_os = "macos"))]
+ {
+ false
+ }
+}
+
+/// Bytes of physical memory, or `None` where it is not read.
+pub fn physical_memory() -> Option {
+ #[cfg(unix)]
+ {
+ // SAFETY: sysconf only reads system constants.
+ let (pages, size) = unsafe {
+ (
+ libc::sysconf(libc::_SC_PHYS_PAGES),
+ libc::sysconf(libc::_SC_PAGESIZE),
+ )
+ };
+ (pages > 0 && size > 0).then(|| pages as u64 * size as u64)
+ }
+ #[cfg(not(unix))]
+ {
+ None
+ }
+}
+
+#[cfg(unix)]
+fn free_at(path: &Path) -> Option {
+ use std::os::unix::ffi::OsStrExt;
+ let name = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
+ // SAFETY: an all-zero statvfs is a valid value for the call to overwrite.
+ let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
+ // SAFETY: `name` is NUL-terminated and `stat` is a live statvfs.
+ if unsafe { libc::statvfs(name.as_ptr(), &raw mut stat) } != 0 {
+ return None;
+ }
+ #[allow(clippy::useless_conversion)]
+ Some(u64::from(stat.f_bavail) * u64::from(stat.f_frsize))
+}
+
+#[cfg(windows)]
+fn free_at(path: &Path) -> Option {
+ use std::os::windows::ffi::OsStrExt;
+ let wide: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect();
+ let mut free = 0u64;
+ // SAFETY: `wide` is NUL-terminated and `free` is a live u64; the other two
+ // outputs are optional.
+ let ok = unsafe {
+ windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW(
+ wide.as_ptr(),
+ &raw mut free,
+ std::ptr::null_mut(),
+ std::ptr::null_mut(),
+ )
+ };
+ (ok != 0).then_some(free)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_temp_volume_has_a_free_size() {
+ let free = free_bytes(&std::env::temp_dir()).expect("statvfs on the temp dir");
+ assert!(free > 0);
+ }
+
+ #[test]
+ fn a_missing_path_is_measured_where_it_would_go() {
+ let missing = std::env::temp_dir().join("stemd-no-such-dir/below/it");
+ assert!(free_bytes(&missing).is_some());
+ }
+
+ #[test]
+ fn a_refusal_says_what_is_free_and_what_is_needed() {
+ let full = DiskFull {
+ free: 1_200_000_000,
+ needed: 2_300_000_000,
+ };
+ assert_eq!(
+ full.to_string(),
+ "not enough disk space: 1.2 GB free, 2.3 GB needed; free some space and try again"
+ );
+ }
+
+ #[test]
+ fn the_budget_leaves_the_floor_free() {
+ let gb = 1_000_000_000;
+ // Plenty free: the cap stands.
+ assert_eq!(fit(4 * gb, gb, 50 * gb), 4 * gb);
+ // 3 GB free with 1 GB held: 2 GB may be held in all.
+ assert_eq!(fit(4 * gb, gb, 3 * gb), 2 * gb);
+ // Under the floor already: nothing.
+ assert_eq!(fit(4 * gb, 0, gb), 0);
+ }
+}
diff --git a/crates/stemd-server/src/drops.rs b/crates/stemd-server/src/drops.rs
index 5fdbb6d..8714b66 100644
--- a/crates/stemd-server/src/drops.rs
+++ b/crates/stemd-server/src/drops.rs
@@ -12,13 +12,15 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::{Context, Result};
-use parking_lot::Mutex;
+use parking_lot::{Condvar, Mutex};
use stemd_core::{Audio, DspMode, PART_NAMES, PcmFormat, Progress, Stage};
use crate::api::AppState;
use crate::cache::{Entry, Ingredients, Output};
+use crate::jobs::Job;
use crate::queue::QueuedWork;
use crate::settings::DropParts;
@@ -79,6 +81,10 @@ pub struct Dropped {
/// File name without the extension, which is what the window shows.
pub title: String,
state: Mutex,
+ /// The job this drop waits on, once claimed. Whoever takes it releases it.
+ job: Mutex>>,
+ /// Set by [`Drops::stop`].
+ stopped: AtomicBool,
}
impl Dropped {
@@ -100,6 +106,20 @@ impl Dropped {
secs: 1.0,
cached: false,
})),
+ job: Mutex::new(None),
+ stopped: AtomicBool::new(false),
+ })
+ }
+
+ /// A drop part way through, for the window's tests.
+ #[cfg(test)]
+ pub fn working(title: &str, progress: Progress) -> Arc {
+ Arc::new(Self {
+ source: PathBuf::from(format!("/Music/{title}.wav")),
+ title: title.to_owned(),
+ state: Mutex::new(State::Working(progress)),
+ job: Mutex::new(None),
+ stopped: AtomicBool::new(false),
})
}
@@ -110,12 +130,105 @@ impl Dropped {
pub fn is_running(&self) -> bool {
matches!(self.state(), State::Reading | State::Working(_))
}
+
+ fn stopped(&self) -> bool {
+ self.stopped.load(Ordering::SeqCst)
+ }
+
+ /// Let go of the job, and cancel it if nobody else waits on it. Returns
+ /// whether this drop was the last waiter.
+ fn release(&self, state: &AppState) -> bool {
+ let Some(job) = self.job.lock().take() else {
+ return false;
+ };
+ if job.release() > 0 {
+ return false;
+ }
+ if !job.view().progress.stage.is_terminal() {
+ state.queue.cancel(&job.id);
+ state.store.remove(&job.id);
+ }
+ true
+ }
}
/// The window's list of drops, newest first.
#[derive(Default)]
pub struct Drops {
recent: Mutex>>,
+ turns: Turnstile,
+}
+
+/// Queue positions in drop order: a short track decodes faster than a long one
+/// dropped before it, and must still be queued after it.
+#[derive(Default)]
+struct Turnstile {
+ next: Mutex,
+ moved: Condvar,
+}
+
+#[derive(Default)]
+struct Line {
+ issued: u64,
+ serving: u64,
+}
+
+impl Turnstile {
+ fn ticket(&self) -> u64 {
+ let mut line = self.next.lock();
+ line.issued += 1;
+ line.issued - 1
+ }
+}
+
+/// One drop's place in line. [`Turn::wait`] takes the turn and [`Turn::pass`]
+/// hands it on; dropping a turn that was never passed does both, so a drop
+/// that fails or panics cannot hold up the ones behind it.
+struct Turn<'a> {
+ turns: &'a Turnstile,
+ ticket: u64,
+ waited: bool,
+ passed: bool,
+}
+
+impl<'a> Turn<'a> {
+ const fn new(turns: &'a Turnstile, ticket: u64) -> Self {
+ Self {
+ turns,
+ ticket,
+ waited: false,
+ passed: false,
+ }
+ }
+
+ /// Block until every earlier drop has been queued or has failed.
+ fn wait(&mut self) {
+ if self.waited {
+ return;
+ }
+ let mut line = self.turns.next.lock();
+ while line.serving != self.ticket {
+ self.turns.moved.wait(&mut line);
+ }
+ self.waited = true;
+ }
+
+ /// Let the next drop go.
+ fn pass(&mut self) {
+ if self.passed {
+ return;
+ }
+ self.wait();
+ self.turns.next.lock().serving += 1;
+ self.turns.moved.notify_all();
+ self.passed = true;
+ }
+}
+
+impl Drop for Turn<'_> {
+ fn drop(&mut self) {
+ self.pass();
+ }
}
impl Drops {
@@ -133,25 +246,61 @@ impl Drops {
/// track takes long enough to drop frames, and the window has to keep
/// painting the progress of the job this creates.
pub fn accept(self: &Arc, state: &Arc, path: PathBuf) -> Arc {
+ // A second drop of a track still in progress would write the same folder.
+ if let Some(running) = self
+ .recent
+ .lock()
+ .iter()
+ .find(|d| d.source == path && d.is_running())
+ {
+ return Arc::clone(running);
+ }
let dropped = Arc::new(Dropped {
title: title_of(&path),
source: path,
state: Mutex::new(State::Reading),
+ job: Mutex::new(None),
+ stopped: AtomicBool::new(false),
});
self.remember(Arc::clone(&dropped));
let state = Arc::clone(state);
+ let drops = Arc::clone(self);
+ let ticket = self.turns.ticket();
let started = Arc::clone(&dropped);
- std::thread::Builder::new()
+ let spawned = std::thread::Builder::new()
.name("stemd-drop".into())
.spawn(move || {
- if let Err(err) = run(&state, &started) {
- tracing::error!("{}: {err:#}", started.title);
- started.set(State::Failed(format!("{err:#}")));
+ let mut turn = Turn::new(&drops.turns, ticket);
+ let ran = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ run(&state, &started, &mut turn)
+ }));
+ let failure = match ran {
+ Ok(Ok(())) => None,
+ Ok(Err(err)) => Some(format!("{err:#}")),
+ Err(payload) => Some(format!(
+ "the drop panicked: {}",
+ crate::panics::message(&*payload)
+ )),
+ };
+ // Marked failed before the turn is handed on, which may wait.
+ if let Some(why) = failure {
+ if started.stopped() {
+ tracing::info!("{}: stopped", started.title);
+ } else {
+ tracing::error!("{}: {why}", started.title);
+ }
+ started.set(State::Failed(why));
}
- })
- .expect("spawning the drop worker");
+ turn.pass();
+ });
+ if let Err(err) = spawned {
+ // Hand the turn on, or every later drop waits for it.
+ std::mem::drop(Turn::new(&self.turns, ticket));
+ tracing::error!("{}: could not start: {err}", dropped.title);
+ dropped.set(State::Failed(format!("could not start: {err}")));
+ }
dropped
}
@@ -168,12 +317,18 @@ impl Drops {
self.recent.lock().retain(|other| !Arc::ptr_eq(other, item));
}
+ /// Stop a drop that is still running and take it out of the list. Its job
+ /// is cancelled unless an API client also waits on it.
+ pub fn stop(&self, state: &AppState, item: &Arc) {
+ item.stopped.store(true, Ordering::SeqCst);
+ item.release(state);
+ self.forget(item);
+ }
+
/// Forget a drop and delete the stems it wrote.
///
- /// Deleted outright rather than moved to the Trash, which would need Finder
- /// automation and a permission prompt inside an `.app`. The separation is still in
- /// the cache, so dropping the same file again writes the folder back without
- /// running the model.
+ /// The folder is deleted outright. Moving it to the Trash needs Finder
+ /// automation and a permission prompt inside an `.app`.
pub fn discard(&self, item: &Arc) {
if let State::Done(finished) = item.state() {
remove_stems(&finished.dir);
@@ -204,8 +359,11 @@ fn remove_stems(dir: &Path) {
}
/// Decode, separate, and write the stems out.
-fn run(state: &Arc, item: &Arc) -> Result<()> {
+fn run(state: &Arc, item: &Arc, turn: &mut Turn) -> Result<()> {
let mix = read(state, &item.source)?;
+ if item.stopped() {
+ anyhow::bail!("stopped");
+ }
let settings = state.settings.get();
let output = Output {
format: settings.format,
@@ -216,8 +374,20 @@ fn run(state: &Arc, item: &Arc) -> Result<()> {
parts: settings.drop_parts.request(),
};
- let (entry, cached) = separate(state, item, &mix, output)?;
- let finished = write_out(&item.source, &entry, cached, settings.drop_parts)?;
+ let (entry, origin) = separate(state, item, mix, output, turn)?;
+ let written = write_out(
+ &item.source,
+ &entry,
+ matches!(origin, Origin::Cached),
+ settings.drop_parts,
+ );
+ // The folder is the drop's only copy. The last drop waiting on the job
+ // deletes the entry, unless an API client holds the job or fetched the
+ // entry. Kept when writing failed, so a second drop needs no separation.
+ if matches!(origin, Origin::Queued) && item.release(state) && written.is_ok() {
+ state.cache.discard_unshared(&entry);
+ }
+ let finished = written?;
tracing::info!(
"{}: {} parts written to {}",
item.title,
@@ -266,13 +436,23 @@ fn read(state: &AppState, path: &Path) -> Result {
stemd_core::resample::to_rate(&audio, wanted).context("resampling the input")
}
+/// Where a drop's cache entry came from.
+enum Origin {
+ /// Already in the cache, made by an earlier request.
+ Cached,
+ /// Separated by a job this drop started or joined, held in `Dropped::job`.
+ /// The job counts every drop and API client waiting on it.
+ Queued,
+}
+
/// Put the mix through the same queue an upload uses, and wait for the stems.
fn separate(
state: &Arc,
item: &Arc,
- mix: &Audio,
+ mix: Audio,
output: Output,
-) -> Result<(Arc, bool)> {
+ turn: &mut Turn,
+) -> Result<(Arc, Origin)> {
// Hashed from the same bytes an upload would have carried, so a drop and a
// deck asking for the same track land on the same cache entry.
let pcm = mix.to_interleaved(PcmFormat::S16le);
@@ -290,40 +470,71 @@ fn separate(
});
std::mem::drop(pcm);
+ // Ahead of the turn: a cache hit queues nothing, so it need not wait for
+ // the drops before it. It still passes its turn when it ends.
if let Some(entry) = state.cache.get(&key) {
// The row in the window already says "already separated"; this is only
// for reading afterwards.
tracing::debug!("{}: already separated, reusing it", item.title);
- return Ok((entry, true));
+ return Ok((entry, Origin::Cached));
+ }
+
+ turn.wait();
+ if item.stopped() {
+ anyhow::bail!("stopped");
}
// Claim before enqueuing, exactly as the HTTP path does: it is the same
// store, so a drop can join a separation a deck already started.
let job = match state.store.claim(&key) {
Ok(job) => {
- state.queue.submit(QueuedWork {
- job: Arc::clone(&job),
- mix: mix.clone(),
- output,
- model_id: state.switcher.published_identity(),
- })?;
+ // The stems pass through the cache first. The folder beside the
+ // track is checked for the stems' own size once they exist.
+ let queued = crate::disk::ensure_room(state.cache.root())
+ .map_err(anyhow::Error::from)
+ .and_then(|()| {
+ state
+ .queue
+ .submit(QueuedWork {
+ job: Arc::clone(&job),
+ mix,
+ output,
+ model_id: state.switcher.published_identity(),
+ from_api: false,
+ })
+ .map_err(anyhow::Error::from)
+ });
+ // A job left at Queued would hold its key, and later requests for
+ // the track would join it and wait forever.
+ if let Err(err) = queued {
+ job.fail(format!("{err:#}"));
+ return Err(err);
+ }
job
}
+ // `claim` counted this drop as a waiter.
Err(existing) => {
tracing::debug!("{}: joining {}, already under way", item.title, existing.id);
- existing.join();
existing
}
};
+ turn.pass();
+ *item.job.lock() = Some(Arc::clone(&job));
+ let origin = Origin::Queued;
loop {
+ // `stop` releases the job itself unless it came before the job was stored.
+ if item.stopped() {
+ item.release(state);
+ anyhow::bail!("stopped");
+ }
let view = job.view();
match view.progress.stage {
Stage::Done => {
let entry = job.entry.lock().clone();
return Ok((
entry.context("the job finished with no stems attached")?,
- false,
+ origin,
));
}
Stage::Failed => {
@@ -342,11 +553,15 @@ fn separate(
}
/// Copy the stems out of the cache into a folder beside the source file.
-///
-/// Copied rather than moved: the entry stays in the cache, so a deck asking for
-/// the same track over the network still gets it without separating again.
fn write_out(source: &Path, entry: &Entry, cached: bool, wanted: DropParts) -> Result {
let dir = output_dir(source);
+ let needed = entry
+ .stems
+ .iter()
+ .filter(|stem| wanted.has(&stem.name))
+ .map(|stem| stem.bytes)
+ .sum();
+ crate::disk::ensure_room_for(&dir, needed)?;
std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
clear_parts(&dir)?;
@@ -362,8 +577,11 @@ fn write_out(source: &Path, entry: &Entry, cached: bool, wanted: DropParts) -> R
.path
.file_name()
.map_or_else(|| stem.name.clone(), |n| n.to_string_lossy().into_owned());
- std::fs::copy(&stem.path, dir.join(&name))
- .with_context(|| format!("copying {} into {}", name, dir.display()))?;
+ if let Err(err) = std::fs::copy(&stem.path, dir.join(&name)) {
+ // Half a set of stems reads as a finished one.
+ let _ = clear_parts(&dir);
+ return Err(err).with_context(|| format!("copying {} into {}", name, dir.display()));
+ }
// Tells the cache this entry was collected, so the rule that reaps
// separations nobody pulled leaves it alone.
entry.mark_fetched(&stem.name);
@@ -445,6 +663,44 @@ fn title_of(path: &Path) -> String {
mod tests {
use super::*;
+ /// Drops reach the queue in the order they were dropped, whichever
+ /// finishes decoding first.
+ #[test]
+ fn drops_take_their_turn_in_drop_order() {
+ let turns = Arc::new(Turnstile::default());
+ let tickets: Vec = (0..4).map(|_| turns.ticket()).collect();
+ let order = Arc::new(Mutex::new(Vec::new()));
+ // Started last ticket first, as if the later drops decoded faster.
+ let threads: Vec<_> = tickets
+ .into_iter()
+ .rev()
+ .map(|ticket| {
+ let turns = Arc::clone(&turns);
+ let order = Arc::clone(&order);
+ std::thread::spawn(move || {
+ let mut turn = Turn::new(&turns, ticket);
+ turn.wait();
+ order.lock().push(ticket);
+ })
+ })
+ .collect();
+ for thread in threads {
+ thread.join().unwrap();
+ }
+ assert_eq!(*order.lock(), vec![0, 1, 2, 3]);
+ }
+
+ /// A drop that fails before reaching the queue still hands on its turn.
+ #[test]
+ fn a_failed_drop_does_not_hold_up_the_next() {
+ let turns = Turnstile::default();
+ let (first, second) = (turns.ticket(), turns.ticket());
+ std::mem::drop(Turn::new(&turns, first));
+ let mut next = Turn::new(&turns, second);
+ next.wait();
+ next.pass();
+ }
+
#[test]
fn only_audio_is_worth_decoding() {
for good in ["a.wav", "A.WAV", "b.flac", "c.mp3", "d.m4a", "e.aiff"] {
diff --git a/crates/stemd-server/src/main.rs b/crates/stemd-server/src/main.rs
index 008fded..fa219b0 100644
--- a/crates/stemd-server/src/main.rs
+++ b/crates/stemd-server/src/main.rs
@@ -7,9 +7,8 @@
//! shutdown the one way the process ends, however it was asked to
//! ```
//!
-//! The port is taken first so that a launch which cannot have it stops before it
-//! loads a model or clears a cache another server is serving from. See
-//! [`serve::bind`].
+//! The cache root is locked and the port taken first, so a second launch stops
+//! before it loads a model or clears the cache of the server already running.
#![cfg_attr(windows, windows_subsystem = "windows")]
mod api;
@@ -18,6 +17,7 @@ mod cli;
#[cfg(windows)]
mod cuda;
mod discovery;
+mod disk;
mod drops;
mod ident;
mod jobs;
@@ -29,6 +29,7 @@ mod pieces;
mod precision;
mod queue;
mod serve;
+mod serving;
mod session_api;
mod sessions;
mod settings;
@@ -57,9 +58,8 @@ fn main() -> Result<()> {
if args.install_cuda {
return install_cuda();
}
- // Before `prepare`, and the reason is in `serve::bind`: a launch that cannot
- // have the port should find that out before it loads a model or touches the
- // cache that another server is using.
+ // Before `prepare`, which clears the cache root. Held until the process ends.
+ let _instance = cache::lock(&cache::root(&args)?)?;
let listener = serve::bind(args.bind)?;
if args.headless {
serve::headless(&args, logs, listener)
diff --git a/crates/stemd-server/src/models/download.rs b/crates/stemd-server/src/models/download.rs
index 35fe8d0..3bb221a 100644
--- a/crates/stemd-server/src/models/download.rs
+++ b/crates/stemd-server/src/models/download.rs
@@ -47,6 +47,8 @@ pub fn fetch_one(
on_progress: &mut impl FnMut(&str, u64, u64),
) -> Result<()> {
tracing::info!("fetching {} ({:.0} MB)", file.name, file.bytes as f64 / 1e6);
+ crate::disk::ensure_room_for(dir, file.bytes)
+ .with_context(|| format!("downloading {}", file.name))?;
let response = ureq::get(file.url)
.call()
@@ -54,9 +56,16 @@ pub fn fetch_one(
let total = content_length(&response).unwrap_or(file.bytes);
let partial = dir.join(format!("{}.part", file.name));
- let (digest, written) = stream_to_file(response.into_body().into_reader(), &partial, |done| {
+ let streamed = stream_to_file(response.into_body().into_reader(), &partial, |done| {
on_progress(file.name, done, total);
- })?;
+ });
+ let (digest, written) = match streamed {
+ Ok(done) => done,
+ Err(err) => {
+ let _ = std::fs::remove_file(&partial);
+ return Err(err);
+ }
+ };
on_progress(file.name, written, total);
if digest != file.sha256 {
diff --git a/crates/stemd-server/src/models/preset.rs b/crates/stemd-server/src/models/preset.rs
index 8e4e5bf..ecfe6da 100644
--- a/crates/stemd-server/src/models/preset.rs
+++ b/crates/stemd-server/src/models/preset.rs
@@ -59,12 +59,12 @@ impl Preset {
}
}
- /// One line for the dropdown. Times are for a 6:38 track on an idle M1 Pro.
+ /// One line about the model. Times are for a 6:38 track on an idle M1 Pro.
pub const fn detail(self) -> &'static str {
match self {
- Self::Fast => "htdemucs v4 — ~22 s per track",
- Self::Balanced => "htdemucs ft — ~48 s per track",
- Self::Quality => "stemd RoFormer — ~110 s per track",
+ Self::Fast => "htdemucs v4 · about 22 s per track",
+ Self::Balanced => "htdemucs ft · about 48 s per track",
+ Self::Quality => "Stemd RoFormer · about 110 s per track",
}
}
diff --git a/crates/stemd-server/src/pieces.rs b/crates/stemd-server/src/pieces.rs
index b968158..35c7c5f 100644
--- a/crates/stemd-server/src/pieces.rs
+++ b/crates/stemd-server/src/pieces.rs
@@ -153,14 +153,22 @@ impl Pieces {
Ok(())
}
+ /// The directory the pieces live in.
+ pub fn root(&self) -> &Path {
+ &self.root
+ }
+
/// Write a piece file through a `.part` and a rename, so a reader never
/// sees half of one.
fn write_in(&self, dir: &Path, k: usize, extension: &str, bytes: &[u8]) -> Result<()> {
+ crate::disk::ensure_room(&self.root)?;
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
let path = dir.join(file(k, extension));
let partial = path.with_extension(format!("{extension}.part"));
- std::fs::write(&partial, bytes)
- .with_context(|| format!("writing {}", partial.display()))?;
+ if let Err(err) = std::fs::write(&partial, bytes) {
+ let _ = std::fs::remove_file(&partial);
+ return Err(err).with_context(|| format!("writing {}", partial.display()));
+ }
std::fs::rename(&partial, &path)
.with_context(|| format!("publishing {}", path.display()))?;
self.bytes.fetch_add(bytes.len() as u64, Ordering::Relaxed);
@@ -175,6 +183,11 @@ impl Pieces {
let Some(id) = track.verified() else {
return;
};
+ // Pieces are a cache, so a nearly full disk skips them and the job goes on.
+ if crate::disk::low(&self.root) {
+ tracing::debug!(track = %id, "disk nearly full, not writing pieces");
+ return;
+ }
let stride = track.spec.grid.stride().unsigned_abs() as usize;
let count = track.spec.grid.len();
let first = touched.start.saturating_sub(RESAMPLE_CONTEXT) / stride;
@@ -210,15 +223,17 @@ impl Pieces {
}
}
- /// Delete the least recently used files until the store is under budget.
- fn evict(&self) {
- if self.bytes() <= self.max_bytes {
+ /// Delete the least recently used files until the store is under budget:
+ /// its cap, or less when the disk is short of [`crate::disk::FLOOR`].
+ pub fn evict(&self) {
+ let budget = crate::disk::budget(self.max_bytes, self.bytes(), &self.root);
+ if self.bytes() <= budget {
return;
}
let _held = self.lock.lock();
let mut all = files(&self.root);
all.sort_by_key(|(_, _, used)| *used);
- let target = self.max_bytes / 10 * 9;
+ let target = budget / 10 * 9;
let mut freed = 0u64;
for (path, size, _) in all {
if self.bytes().saturating_sub(freed) <= target {
diff --git a/crates/stemd-server/src/queue.rs b/crates/stemd-server/src/queue.rs
index d69794c..8585dcb 100644
--- a/crates/stemd-server/src/queue.rs
+++ b/crates/stemd-server/src/queue.rs
@@ -48,6 +48,10 @@ pub struct QueuedWork {
pub output: Output,
/// The `model_id` the job was submitted under, for the pieces it writes.
pub model_id: String,
+ /// Submitted over the API. Its stems are kept as session pieces too, and
+ /// turning the server off cancels it. Off for a drop, whose stems end up
+ /// only in its folder.
+ pub from_api: bool,
}
#[derive(Debug, thiserror::Error)]
@@ -63,6 +67,7 @@ pub struct QueueFull {
/// flag on a `Running` the worker has already dropped.
struct Running {
id: String,
+ from_api: bool,
cancel: Arc,
}
@@ -96,6 +101,10 @@ struct Inner {
/// Whether the loaded separator needs the whole mix's normalisation.
normalises: AtomicBool,
sessions: Arc,
+ /// Set when the worker has left its loop and dropped its separator. The
+ /// thread then parks and never exits: MLX's per-thread CUDA state throws
+ /// from its destructor when a thread exits on Windows.
+ left: AtomicBool,
}
pub struct Queue {
@@ -136,6 +145,7 @@ impl Queue {
segmenting: Mutex::new(None),
normalises: AtomicBool::new(false),
sessions: Arc::new(Sessions::default()),
+ left: AtomicBool::new(false),
});
let (reply, built) = mpsc::sync_channel(1);
@@ -145,10 +155,13 @@ impl Queue {
.spawn(move || {
// The first thing this thread does, and the reason it is handed
// a recipe rather than a model.
- let Some(separator) = worker_inner.build_here(build, &reply) else {
- return;
- };
- worker_loop(&worker_inner, separator, &cache);
+ if let Some(separator) = worker_inner.build_here(build, &reply) {
+ worker_loop(&worker_inner, separator, &cache);
+ }
+ worker_inner.left.store(true, Ordering::SeqCst);
+ loop {
+ thread::park();
+ }
})
.expect("spawning the separation worker");
@@ -160,12 +173,8 @@ impl Queue {
},
info,
)),
- // The worker returns the moment the build fails, so joining costs
- // nothing and keeps a refused launch from leaving a thread behind.
- Ok(Err(err)) => {
- let _ = worker.join();
- Err(err)
- }
+ // The worker parks the moment the build fails; see `Inner::left`.
+ Ok(Err(err)) => Err(err),
Err(_) => {
let _ = worker.join();
anyhow::bail!("the separation worker died before it could load the model")
@@ -244,14 +253,15 @@ impl Queue {
// must not happen here: a worker wedged in the model would hang the quit
// instead of crashing it, which is not an improvement.
let deadline = std::time::Instant::now() + grace;
- while !worker.is_finished() {
+ // The thread only finishes if it panicked.
+ while !self.inner.left.load(Ordering::SeqCst) && !worker.is_finished() {
if std::time::Instant::now() >= deadline {
tracing::warn!("the separation worker did not stop within {:.0?}", grace);
return false;
}
thread::sleep(POLL_INTERVAL);
}
- worker.join().is_ok()
+ self.inner.left.load(Ordering::SeqCst)
}
/// Enqueue a job, returning its position (0 = next up).
@@ -296,6 +306,30 @@ impl Queue {
}
}
+ /// Cancel every job submitted over the API, waiting or running, and return
+ /// how many. Drops keep their place.
+ pub fn cancel_api_jobs(&self) -> usize {
+ let dropped = {
+ let mut pending = self.inner.pending.lock();
+ let (api, kept): (VecDeque<_>, VecDeque<_>) =
+ pending.drain(..).partition(|w| w.from_api);
+ *pending = kept;
+ api
+ };
+ for work in &dropped {
+ work.job.cancelled();
+ }
+ // `pending` released first, as in `cancel`.
+ let stopped = match self.inner.running.lock().as_ref() {
+ Some(job) if job.from_api => {
+ job.cancel.store(true, Ordering::Relaxed);
+ 1
+ }
+ _ => 0,
+ };
+ dropped.len() + stopped
+ }
+
pub fn depth(&self) -> usize {
self.inner.pending.lock().len()
}
@@ -415,6 +449,7 @@ impl Inner {
let cancel = Arc::new(AtomicBool::new(false));
*self.running.lock() = Some(Running {
id: work.job.id.clone(),
+ from_api: work.from_api,
cancel: Arc::clone(&cancel),
});
return Some(Next::Job(Claimed { work, cancel }));
@@ -524,6 +559,7 @@ fn worker_loop(inner: &Arc, mut separator: Box, cache: &Cac
.fail(format!("the separation panicked: {what}"));
}
*inner.running.lock() = None;
+ trim_after_job(cache);
}
Next::Segment(track, k) => {
run_segment(separator.as_mut(), &inner.sessions, &track, k, &mut passes);
@@ -593,6 +629,41 @@ fn run_segment(
passes.remove(&track.uid);
}
+/// Whether there is room to write a finished job's pieces: they hold a copy of
+/// the mix and stems in memory while they encode, and land on disk.
+fn pieces_affordable(pieces: &Pieces) -> bool {
+ if crate::disk::low(pieces.root()) {
+ tracing::info!("disk nearly full, not writing this job's pieces");
+ return false;
+ }
+ if crate::disk::memory_pressure() {
+ tracing::info!("memory is short, not writing this job's pieces");
+ return false;
+ }
+ true
+}
+
+/// Hand back MLX's allocator cache after a job if keeping it is a risk: when
+/// memory or disk is short, or it holds over half the machine's memory. Kept
+/// otherwise, so the next track in a queue starts warm.
+fn trim_after_job(cache: &Cache) {
+ let held = stemd_core::memory::cached();
+ let ceiling = crate::disk::physical_memory().map_or(8 << 30, |ram| ram / 2);
+ let short = crate::disk::memory_pressure() || crate::disk::low(cache.root());
+ if held as u64 > ceiling || (short && held > 0) {
+ let freed = stemd_core::memory::release();
+ tracing::info!(
+ "returned {:.0} MB of MLX allocator cache after the job{}",
+ freed as f64 / 1e6,
+ if short {
+ ", memory or disk is short"
+ } else {
+ ""
+ }
+ );
+ }
+}
+
/// Write a finished job's stems as pieces, so a later session on the same track
/// starts with them.
fn write_job_pieces(
@@ -694,7 +765,9 @@ fn separate_one(
// Off the worker: the job is done for its client, and the next one
// should not wait on encoding.
if let (Some(pieces), Some(segmenting)) = (pieces, segmenting)
+ && work.from_api
&& work.output.format != stemd_core::StemFormat::Mp3
+ && pieces_affordable(&pieces)
&& let Ok(grid) = segmenting.grid(work.mix.frames())
{
let (mix, output, model_id) =
@@ -1128,6 +1201,7 @@ mod tests {
parts: stemd_core::Parts::DEFAULT,
},
model_id: "test".into(),
+ from_api: true,
})
.expect("queue has room");
}
@@ -1809,6 +1883,7 @@ mod session_tests {
mix: track(80 * 3307, 4),
output: output(StemFormat::Pcm32),
model_id: "toy-00000000".into(),
+ from_api: true,
})
.expect("room");
thread::sleep(Duration::from_millis(100));
@@ -1828,6 +1903,46 @@ mod session_tests {
queue.cancel(&job.id);
}
+ #[test]
+ fn turning_the_server_off_cancels_api_jobs_and_keeps_drops() {
+ let _gpu = GPU.lock();
+ let mut toy = Toy::new(SEGMENT, 0.25);
+ toy.delay = Duration::from_millis(20);
+ let queue = start(toy);
+
+ let store = crate::jobs::JobStore::new(Duration::from_secs(60));
+ let submit = |key: &str, from_api: bool| {
+ let job = store.create(key.into());
+ queue
+ .submit(QueuedWork {
+ job: Arc::clone(&job),
+ mix: track(40 * 3307, 8),
+ output: output(StemFormat::Pcm32),
+ model_id: "toy-00000000".into(),
+ from_api,
+ })
+ .expect("room");
+ job
+ };
+ let running = submit("running", true);
+ thread::sleep(Duration::from_millis(100));
+ let waiting = submit("waiting", true);
+ let dropped = submit("dropped", false);
+
+ assert_eq!(queue.cancel_api_jobs(), 2);
+ assert_eq!(waiting.view().progress.stage, Stage::Cancelled);
+ let deadline = std::time::Instant::now() + Duration::from_secs(20);
+ while running.view().progress.stage != Stage::Cancelled {
+ assert!(
+ std::time::Instant::now() < deadline,
+ "the running job kept going"
+ );
+ thread::sleep(Duration::from_millis(10));
+ }
+ assert_ne!(dropped.view().progress.stage, Stage::Cancelled);
+ queue.cancel(&dropped.id);
+ }
+
#[test]
fn bonus_segments_wait_for_waiting_jobs() {
let _gpu = GPU.lock();
@@ -1843,6 +1958,7 @@ mod session_tests {
mix: track(40 * 3307, 6),
output: output(StemFormat::Pcm32),
model_id: "toy-00000000".into(),
+ from_api: true,
})
.expect("room");
thread::sleep(Duration::from_millis(50));
@@ -1972,6 +2088,7 @@ mod session_tests {
mix: mix.clone(),
output: output(StemFormat::Flac),
model_id: "toy-00000000".into(),
+ from_api: true,
})
.expect("room");
let grid = queue
diff --git a/crates/stemd-server/src/serve.rs b/crates/stemd-server/src/serve.rs
index 1e487c8..d14b376 100644
--- a/crates/stemd-server/src/serve.rs
+++ b/crates/stemd-server/src/serve.rs
@@ -14,18 +14,8 @@ use crate::cli::Args;
use crate::launch::{Launch, Stage};
use crate::logbuf::LogBuffer;
-/// Take the port, before anything else is done.
-///
-/// Called ahead of [`crate::startup::prepare`], which is the point of it being
-/// separate from [`headless`] and [`windowed`]. Two things follow.
-///
-/// A second launch fails here, in milliseconds, instead of after the model is
-/// loaded, which was four seconds on a warm CUDA machine and over a minute on a
-/// CPU-only one.
-///
-/// And it fails before [`crate::cache::Cache::new`] clears the cache root. Two
-/// instances share a root by default, so the second used to empty the first's
-/// cache on its way to discovering it was the second.
+/// Take the port before [`crate::startup::prepare`] loads the model, so a port
+/// in use fails in milliseconds.
///
/// Non-blocking because tokio requires it of a listener adopted from std, and
/// doing it here means [`listen`] cannot forget to.
diff --git a/crates/stemd-server/src/serving.rs b/crates/stemd-server/src/serving.rs
new file mode 100644
index 0000000..96b2411
--- /dev/null
+++ b/crates/stemd-server/src/serving.rs
@@ -0,0 +1,122 @@
+//! Whether the HTTP API answers, and the mDNS advertisement that goes with it.
+//!
+//! Turning the server off keeps the port bound, so a second instance still fails
+//! at launch; requests get 503 until it is turned back on. Drops in the window
+//! work either way.
+
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+
+use parking_lot::Mutex;
+
+use crate::discovery::Advertiser;
+
+pub struct Serving {
+ shared: Arc,
+ /// Instance name and port to advertise under; `None` with `--no-mdns`.
+ announce: Option<(String, u16)>,
+}
+
+#[derive(Default)]
+struct Shared {
+ on: AtomicBool,
+ /// Bumped on every change, so an advertisement that finishes starting after
+ /// the switch moved again withdraws itself.
+ generation: AtomicU64,
+ advertiser: Mutex>>,
+ /// Held while registering, so a quick off and on cannot register twice.
+ registering: Mutex<()>,
+}
+
+impl Serving {
+ /// Start in `on`, advertising `model` if on and announcing is allowed.
+ pub fn new(on: bool, announce: Option<(String, u16)>, model: &str) -> Self {
+ let serving = Self {
+ shared: Arc::new(Shared {
+ on: AtomicBool::new(on),
+ ..Shared::default()
+ }),
+ announce,
+ };
+ if on {
+ serving.advertise(model);
+ }
+ serving
+ }
+
+ pub fn is_on(&self) -> bool {
+ self.shared.on.load(Ordering::SeqCst)
+ }
+
+ /// Turn the API on or off, advertising `model` when it comes on.
+ pub fn set(&self, on: bool, model: &str) {
+ if self.shared.on.swap(on, Ordering::SeqCst) == on {
+ return;
+ }
+ if on {
+ self.shared.generation.fetch_add(1, Ordering::SeqCst);
+ self.advertise(model);
+ tracing::info!("server on");
+ } else {
+ self.withdraw();
+ tracing::info!("server off: requests get 503 until it is turned back on");
+ }
+ }
+
+ /// Take the advertisement down, for turning off and for quitting.
+ pub fn withdraw(&self) {
+ self.shared.generation.fetch_add(1, Ordering::SeqCst);
+ if let Some(advertiser) = self.shared.advertiser.lock().take() {
+ advertiser.withdraw();
+ }
+ }
+
+ /// Announce over mDNS on a background thread: registering can take long
+ /// enough to stall the window. A failure is a warning: a client with the
+ /// address configured out of band still works.
+ fn advertise(&self, model: &str) {
+ let Some((instance, port)) = self.announce.clone() else {
+ return;
+ };
+ let shared = Arc::clone(&self.shared);
+ let model = model.to_owned();
+ let generation = shared.generation.load(Ordering::SeqCst);
+ let spawned = std::thread::Builder::new()
+ .name("stemd-mdns".into())
+ .spawn(move || {
+ let _one = shared.registering.lock();
+ if shared.generation.load(Ordering::SeqCst) != generation {
+ return;
+ }
+ match Advertiser::start(&instance, port, &model) {
+ Ok(advertiser) => {
+ let mut slot = shared.advertiser.lock();
+ if shared.generation.load(Ordering::SeqCst) == generation {
+ *slot = Some(Arc::new(advertiser));
+ } else {
+ advertiser.withdraw();
+ }
+ }
+ Err(err) => tracing::warn!("could not advertise over mDNS: {err:#}"),
+ }
+ });
+ if let Err(err) = spawned {
+ tracing::warn!("could not advertise over mDNS: {err}");
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_switch_reports_what_it_was_set_to() {
+ let serving = Serving::new(false, None, "model");
+ assert!(!serving.is_on());
+ serving.set(true, "model");
+ assert!(serving.is_on());
+ serving.set(false, "model");
+ assert!(!serving.is_on());
+ }
+}
diff --git a/crates/stemd-server/src/settings.rs b/crates/stemd-server/src/settings.rs
index 85239f1..798ce67 100644
--- a/crates/stemd-server/src/settings.rs
+++ b/crates/stemd-server/src/settings.rs
@@ -1,7 +1,8 @@
//! Preferences the window changes and the next launch remembers.
//!
-//! Three choices are worth persisting: which model loads, and the output format
-//! and sample rate a client gets when it does not ask for one.
+//! Persisted: which model loads, the output format and sample rate a client gets
+//! when it does not ask for one, the parts a drop writes, and whether the server
+//! answers.
//!
//! Precedence is flag, then file, then built-in default. A flag is for one run and
//! is never written back, so a launch script that pins `--output-format f32le`
@@ -33,6 +34,9 @@ pub struct Settings {
pub rate: OutputRate,
/// Parts a dropped track writes beside it. Jobs over HTTP name their own.
pub drop_parts: DropParts,
+ /// Whether the HTTP API answers. The window has a switch; headless always
+ /// serves.
+ pub serving: bool,
}
/// Which parts a drop writes.
@@ -127,6 +131,7 @@ impl Default for Settings {
format: StemFormat::Flac,
rate: OutputRate::default(),
drop_parts: DropParts::DEFAULT,
+ serving: true,
}
}
}
@@ -148,6 +153,7 @@ struct Stored {
output_sample_rate: Option,
/// Comma-separated, as [`DropParts`] prints it.
drop_parts: Option,
+ serving: Option,
}
impl Stored {
@@ -157,6 +163,7 @@ impl Stored {
output_format: Some(settings.format.to_string()),
output_sample_rate: Some(settings.rate.hz()),
drop_parts: Some(settings.drop_parts.to_string()),
+ serving: Some(settings.serving),
}
}
@@ -202,6 +209,9 @@ impl Stored {
None => warn_ignored("drop_parts", &list, &settings.drop_parts.to_string()),
}
}
+ if let Some(serving) = self.serving {
+ settings.serving = serving;
+ }
settings.reconcile();
settings
}
@@ -311,6 +321,10 @@ impl SettingsStore {
self.write(|s| s.drop_parts = parts);
}
+ pub fn set_serving(&self, serving: bool) {
+ self.write(|s| s.serving = serving);
+ }
+
pub fn set_rate(&self, rate: OutputRate) {
if !self.pinned.rate {
self.write(|s| s.rate = rate);
diff --git a/crates/stemd-server/src/shutdown.rs b/crates/stemd-server/src/shutdown.rs
index 0310122..894ee25 100644
--- a/crates/stemd-server/src/shutdown.rs
+++ b/crates/stemd-server/src/shutdown.rs
@@ -59,9 +59,7 @@ pub fn now(state: &AppState, reason: &str) -> ! {
// A withdrawal a client can act on, rather than one it waits out. Costs a
// few milliseconds and saves every client on the network a TTL of chasing a
// server that is gone.
- if let Some(advertiser) = &state.advertiser {
- advertiser.withdraw();
- }
+ state.serving.withdraw();
let withdrawn = began.elapsed();
// Quitting is meant to look instant. It is the last thing anyone sees the
diff --git a/crates/stemd-server/src/startup.rs b/crates/stemd-server/src/startup.rs
index 7365331..60c802a 100644
--- a/crates/stemd-server/src/startup.rs
+++ b/crates/stemd-server/src/startup.rs
@@ -7,6 +7,7 @@
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
+use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use stemd_core::{
@@ -17,7 +18,6 @@ use stemd_core::{
use crate::api::AppState;
use crate::cache::{self, Cache};
use crate::cli::Args;
-use crate::discovery::Advertiser;
use crate::jobs::JobStore;
use crate::launch::{Launch, Stage};
use crate::logbuf::LogBuffer;
@@ -79,8 +79,13 @@ pub fn prepare(args: &Args, logs: LogBuffer, addr: SocketAddr, launch: &Launch)
let max_upload = max_body_bytes(args.max_track_secs(), highest_rate(), info.channels);
log_model(&info, precisions, args.max_track_secs(), max_upload);
- // Built before the app state so every exit path can reach it.
- let advertiser = advertise(args, &info, addr.port());
+ // Built before the app state so every exit path can reach it. Headless
+ // there is no switch to turn it back on, so it always serves.
+ let serving = crate::serving::Serving::new(
+ args.headless || settings.get().serving,
+ announce(args, addr.port()),
+ &info.model,
+ );
// Both identities, once, at debug. The next time the published one moves
// when it should not, the question is which field did it, and that is not
@@ -116,7 +121,7 @@ pub fn prepare(args: &Args, logs: LogBuffer, addr: SocketAddr, launch: &Launch)
drops: Arc::default(),
settings,
max_track_secs: args.max_track_secs(),
- advertiser,
+ serving,
}),
max_upload,
cache_summary,
@@ -466,10 +471,7 @@ fn joined(dirs: &[PathBuf]) -> String {
}
fn open_stem_cache(args: &Args) -> Result<(Arc, String)> {
- let root = match args.cache_dir.clone() {
- Some(dir) => dir,
- None => cache::default_dir()?,
- };
+ let root = cache::root(args)?;
let max_bytes = args.cache_max_bytes();
let cache = Cache::new(root.clone(), max_bytes, args.unfetched_ttl())
.with_context(|| format!("preparing the stem cache in {}", root.display()))?;
@@ -501,62 +503,64 @@ fn open_pieces(args: &Args) -> Result> {
Ok(pieces)
}
-/// Announce over mDNS unless asked not to.
-///
-/// Discovery is a convenience, not a precondition: a client with the address
-/// configured out of band still works, so a failure here is a warning.
-fn advertise(args: &Args, info: &stemd_core::BackendInfo, port: u16) -> Option> {
+/// The name and port to announce over mDNS, unless asked not to.
+fn announce(args: &Args, port: u16) -> Option<(String, u16)> {
if args.no_mdns {
tracing::info!("mDNS advertisement disabled");
return None;
}
- match Advertiser::start(&args.instance, port, &info.model) {
- Ok(advertiser) => Some(Arc::new(advertiser)),
- Err(err) => {
- tracing::warn!("could not advertise over mDNS: {err:#}");
- None
- }
- }
+ Some((args.instance.clone(), port))
}
/// Run the cache rules on a timer, drop job handles they invalidate, and hand
-/// MLX's allocator cache back once the server has stopped separating.
+/// MLX's allocator cache back once the server has been idle for [`IDLE_RELEASE`].
fn spawn_reaper(cache: Arc, store: Arc, queue: Arc) {
let interval = cache.sweep_interval();
std::thread::Builder::new()
.name("stemd-reaper".into())
.spawn(move || {
- let mut idle_sweeps = 0u32;
+ let mut swept = Instant::now();
+ let mut idle_since: Option = None;
+ let mut released = false;
loop {
- std::thread::sleep(interval);
- let reaped = cache.reap();
- let pruned = store.prune();
- if reaped.any() {
- tracing::info!(
- "reaped {} uncollected and rotated {} for space ({:.0} MB), \
- dropped {pruned} job handles",
- reaped.unfetched,
- reaped.rotated,
- reaped.bytes as f64 / 1e6
- );
+ std::thread::sleep(IDLE_TICK);
+ if swept.elapsed() >= interval {
+ swept = Instant::now();
+ let reaped = cache.reap();
+ let pruned = store.prune();
+ if reaped.any() {
+ tracing::info!(
+ "reaped {} uncollected and rotated {} for space ({:.0} MB), \
+ dropped {pruned} job handles",
+ reaped.unfetched,
+ reaped.rotated,
+ reaped.bytes as f64 / 1e6
+ );
+ }
+ }
+
+ // Writes stop when the disk is low, so eviction has to run
+ // from here too, or the store never gives space back.
+ if let Some(pieces) = queue.sessions().pieces() {
+ pieces.evict();
}
- idle_sweeps = if queue.running().is_some()
- || queue.depth() > 0
- || !queue.sessions().is_empty()
- {
- 0
- } else {
- idle_sweeps + 1
- };
- // Two sweeps rather than one, so this is a server that has
- // stopped rather than one between tracks. The gap between two
- // tracks costs a fifth of the second one if the cache is empty
- // when it starts, and that is exactly the case worth not
- // hitting; see `stemd_core::memory`.
- if idle_sweeps == IDLE_SWEEPS_BEFORE_RELEASE {
+
+ let busy =
+ queue.running().is_some() || queue.depth() > 0 || !queue.sessions().is_empty();
+ if busy {
+ idle_since = None;
+ released = false;
+ continue;
+ }
+ let since = *idle_since.get_or_insert_with(Instant::now);
+ // After a minute idle: the gap between two tracks costs a
+ // fifth of the second one if the cache is empty when it starts;
+ // see `stemd_core::memory`. Once per idle spell.
+ if !released && since.elapsed() >= IDLE_RELEASE {
+ released = true;
let freed = stemd_core::memory::release();
if freed > 0 {
- tracing::debug!(
+ tracing::info!(
"idle, so returned {:.0} MB of MLX allocator cache; \
{:.0} MB still live",
freed as f64 / 1e6,
@@ -584,12 +588,11 @@ fn spawn_session_reaper(queue: Arc) {
.expect("spawning the session reaper");
}
-/// Consecutive idle sweeps before MLX's cache is handed back.
-///
-/// Counted rather than compared against a deadline so it scales with the sweep
-/// interval, which is itself derived from `--unfetched-ttl`. Released once per
-/// idle spell: the counter keeps climbing past this and only a job resets it.
-const IDLE_SWEEPS_BEFORE_RELEASE: u32 = 2;
+/// Idle time before MLX's allocator cache is handed back.
+const IDLE_RELEASE: Duration = Duration::from_secs(60);
+
+/// How often the reaper looks at the queue.
+const IDLE_TICK: Duration = Duration::from_secs(5);
/// Bytes the longest permitted track can occupy, at the widest sample format the
/// API accepts. `f32le`, the larger of the two. This bounds what axum buffers
diff --git a/crates/stemd-server/src/switch.rs b/crates/stemd-server/src/switch.rs
index 1d4dab4..083c89c 100644
--- a/crates/stemd-server/src/switch.rs
+++ b/crates/stemd-server/src/switch.rs
@@ -388,6 +388,7 @@ mod tests {
parts: stemd_core::Parts::DEFAULT,
},
model_id: "test".into(),
+ from_api: true,
})
.expect("queue has room");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(900);
diff --git a/crates/stemd-server/src/testkit.rs b/crates/stemd-server/src/testkit.rs
index b43a116..069f480 100644
--- a/crates/stemd-server/src/testkit.rs
+++ b/crates/stemd-server/src/testkit.rs
@@ -20,7 +20,9 @@ pub fn cache(max_bytes: u64, ttl: Duration) -> Arc {
std::process::id(),
ROOT_SEQ.fetch_add(1, Ordering::Relaxed)
));
- Cache::new(root, max_bytes, ttl).expect("cache root")
+ let cache = Cache::new(root, max_bytes, ttl).expect("cache root");
+ cache.ignore_disk();
+ cache
}
/// A mix and its three parts, each `frames` long.
diff --git a/crates/stemd-server/src/ui/blur.rs b/crates/stemd-server/src/ui/blur.rs
new file mode 100644
index 0000000..2c4e818
--- /dev/null
+++ b/crates/stemd-server/src/ui/blur.rs
@@ -0,0 +1,424 @@
+//! Backdrop blur for menus and dialogs.
+//!
+//! A paint callback copies what is already drawn under a rect, blurs it with a
+//! dual Kawase filter at half, quarter and eighth resolution, and draws it back
+//! clipped to a rounded rect. The popup's translucent fill goes on top of it.
+//! Costs a handful of small draws, and only while a popup is open.
+
+use std::sync::{Arc, Mutex};
+
+use eframe::egui::{self, Rect};
+use eframe::egui_glow;
+use eframe::glow::{self, HasContext as _};
+
+/// Points sampled around the rect, so its edges blur with what is beside them.
+const MARGIN: f32 = 24.0;
+/// Downsampling steps. Each one doubles the blur radius.
+const LEVELS: usize = 3;
+/// Tap spread, in texels of the level being read.
+const SPREAD: f32 = 1.5;
+
+/// The blurred backdrop of `rect` with corner `radius`, as a shape to paint
+/// before the popup's own fill.
+pub fn shape(rect: Rect, radius: f32) -> egui::Shape {
+ let callback = egui_glow::CallbackFn::new(move |info, painter| {
+ let mut state = STATE
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let resources = state.get_or_insert_with(|| {
+ // SAFETY: called on the GL thread with egui's context current.
+ unsafe { Resources::new(painter.gl()) }
+ .inspect_err(|err| tracing::warn!("backdrop blur unavailable: {err}"))
+ .ok()
+ });
+ if let Some(resources) = resources {
+ // SAFETY: as above; egui restores its own state after the callback.
+ unsafe { resources.draw(painter, &info, rect, radius) };
+ }
+ });
+ egui::Shape::Callback(egui::PaintCallback {
+ rect: rect.expand(MARGIN),
+ callback: Arc::new(callback),
+ })
+}
+
+/// `None` inside once setting up failed, so it is not retried every frame.
+static STATE: Mutex>> = Mutex::new(None);
+
+struct Level {
+ texture: glow::Texture,
+ framebuffer: glow::Framebuffer,
+ size: [i32; 2],
+}
+
+struct Program {
+ program: glow::Program,
+ step: Option,
+ region: Option,
+ shape: Option,
+ radius: Option,
+}
+
+struct Resources {
+ down: Program,
+ up: Program,
+ last: Program,
+ vertex_array: glow::VertexArray,
+ /// Level 0 holds the copy at full resolution.
+ levels: Vec,
+}
+
+const VERTEX: &str = r"
+out vec2 v_uv;
+void main() {
+ vec2 p = vec2(gl_VertexID == 1 ? 2.0 : 0.0, gl_VertexID == 2 ? 2.0 : 0.0);
+ v_uv = p;
+ gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
+}
+";
+
+const DOWN: &str = r"
+uniform sampler2D u_tex;
+uniform vec2 u_step;
+in vec2 v_uv;
+out vec4 o_colour;
+void main() {
+ vec4 sum = texture(u_tex, v_uv) * 4.0;
+ sum += texture(u_tex, v_uv - u_step);
+ sum += texture(u_tex, v_uv + u_step);
+ sum += texture(u_tex, v_uv + vec2(u_step.x, -u_step.y));
+ sum += texture(u_tex, v_uv - vec2(u_step.x, -u_step.y));
+ o_colour = sum / 8.0;
+}
+";
+
+const UP: &str = r"
+uniform sampler2D u_tex;
+uniform vec2 u_step;
+in vec2 v_uv;
+out vec4 o_colour;
+vec4 up(vec2 uv) {
+ vec2 s = u_step;
+ vec4 sum = texture(u_tex, uv + vec2(-2.0 * s.x, 0.0));
+ sum += texture(u_tex, uv + vec2(-s.x, s.y)) * 2.0;
+ sum += texture(u_tex, uv + vec2(0.0, 2.0 * s.y));
+ sum += texture(u_tex, uv + vec2(s.x, s.y)) * 2.0;
+ sum += texture(u_tex, uv + vec2(2.0 * s.x, 0.0));
+ sum += texture(u_tex, uv + vec2(s.x, -s.y)) * 2.0;
+ sum += texture(u_tex, uv + vec2(0.0, -2.0 * s.y));
+ sum += texture(u_tex, uv + vec2(-s.x, -s.y)) * 2.0;
+ return sum / 12.0;
+}
+void main() {
+ o_colour = up(v_uv);
+}
+";
+
+/// The last upsample, drawn onto the screen inside a rounded rect.
+const LAST: &str = r"
+uniform sampler2D u_tex;
+uniform vec2 u_step;
+uniform vec4 u_region;
+uniform vec4 u_shape;
+uniform float u_radius;
+in vec2 v_uv;
+out vec4 o_colour;
+vec4 up(vec2 uv) {
+ vec2 s = u_step;
+ vec4 sum = texture(u_tex, uv + vec2(-2.0 * s.x, 0.0));
+ sum += texture(u_tex, uv + vec2(-s.x, s.y)) * 2.0;
+ sum += texture(u_tex, uv + vec2(0.0, 2.0 * s.y));
+ sum += texture(u_tex, uv + vec2(s.x, s.y)) * 2.0;
+ sum += texture(u_tex, uv + vec2(2.0 * s.x, 0.0));
+ sum += texture(u_tex, uv + vec2(s.x, -s.y)) * 2.0;
+ sum += texture(u_tex, uv + vec2(0.0, -2.0 * s.y));
+ sum += texture(u_tex, uv + vec2(-s.x, -s.y)) * 2.0;
+ return sum / 12.0;
+}
+void main() {
+ vec2 uv = (gl_FragCoord.xy - u_region.xy) / u_region.zw;
+ vec2 q = abs(gl_FragCoord.xy - u_shape.xy) - u_shape.zw + u_radius;
+ float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - u_radius;
+ float cover = clamp(0.5 - d, 0.0, 1.0);
+ o_colour = vec4(up(uv).rgb, 1.0) * cover;
+}
+";
+
+impl Resources {
+ unsafe fn new(gl: &glow::Context) -> Result {
+ let version = egui_glow::ShaderVersion::get(gl);
+ if !version.is_new_shader_interface() {
+ return Err(format!("{version:?} shaders lack in/out"));
+ }
+ let header = format!(
+ "{}{}",
+ version.version_declaration(),
+ if version.is_embedded() {
+ "precision mediump float;\n"
+ } else {
+ ""
+ }
+ );
+ unsafe {
+ Ok(Self {
+ down: Program::new(gl, &header, DOWN)?,
+ up: Program::new(gl, &header, UP)?,
+ last: Program::new(gl, &header, LAST)?,
+ vertex_array: gl.create_vertex_array()?,
+ levels: Vec::new(),
+ })
+ }
+ }
+
+ /// Size the levels for a region of `size` pixels, reallocating on change.
+ unsafe fn fit(&mut self, gl: &glow::Context, size: [i32; 2]) -> Result<(), String> {
+ unsafe {
+ if self.levels.len() == LEVELS + 1 && self.levels[0].size == size {
+ return Ok(());
+ }
+ for level in self.levels.drain(..) {
+ gl.delete_framebuffer(level.framebuffer);
+ gl.delete_texture(level.texture);
+ }
+ for i in 0..=LEVELS {
+ let size = [(size[0] >> i).max(1), (size[1] >> i).max(1)];
+ // A level set left short by a failure is rebuilt next frame.
+ let texture = gl.create_texture()?;
+ gl.bind_texture(glow::TEXTURE_2D, Some(texture));
+ for (key, value) in [
+ (glow::TEXTURE_MIN_FILTER, glow::LINEAR),
+ (glow::TEXTURE_MAG_FILTER, glow::LINEAR),
+ (glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE),
+ (glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE),
+ ] {
+ gl.tex_parameter_i32(glow::TEXTURE_2D, key, value as i32);
+ }
+ gl.tex_image_2d(
+ glow::TEXTURE_2D,
+ 0,
+ glow::RGBA8 as i32,
+ size[0],
+ size[1],
+ 0,
+ glow::RGBA,
+ glow::UNSIGNED_BYTE,
+ glow::PixelUnpackData::Slice(None),
+ );
+ let framebuffer = match gl.create_framebuffer() {
+ Ok(framebuffer) => framebuffer,
+ Err(err) => {
+ gl.delete_texture(texture);
+ return Err(err);
+ }
+ };
+ gl.bind_framebuffer(glow::FRAMEBUFFER, Some(framebuffer));
+ gl.framebuffer_texture_2d(
+ glow::FRAMEBUFFER,
+ glow::COLOR_ATTACHMENT0,
+ glow::TEXTURE_2D,
+ Some(texture),
+ 0,
+ );
+ self.levels.push(Level {
+ texture,
+ framebuffer,
+ size,
+ });
+ }
+ Ok(())
+ }
+ }
+
+ unsafe fn draw(
+ &mut self,
+ painter: &egui_glow::Painter,
+ info: &egui::PaintCallbackInfo,
+ rect: Rect,
+ radius: f32,
+ ) {
+ let gl = painter.gl().clone();
+ let ppp = info.pixels_per_point;
+ let [width, height] = info.screen_size_px.map(|v| v as f32);
+ // Pixel rect, origin bottom left as GL counts it.
+ let region = rect.expand(MARGIN);
+ let left = (region.left() * ppp).floor().max(0.0);
+ let right = (region.right() * ppp).ceil().min(width);
+ let top = (region.top() * ppp).floor().max(0.0);
+ let bottom = (region.bottom() * ppp).ceil().min(height);
+ let size = [(right - left) as i32, (bottom - top) as i32];
+ if size[0] < 8 || size[1] < 8 {
+ return;
+ }
+ let origin = [left as i32, (height - bottom) as i32];
+
+ unsafe {
+ if let Err(err) = self.fit(&gl, size) {
+ tracing::warn!("backdrop blur: {err}");
+ return;
+ }
+ gl.disable(glow::SCISSOR_TEST);
+ gl.disable(glow::BLEND);
+ gl.active_texture(glow::TEXTURE0);
+ gl.bind_vertex_array(Some(self.vertex_array));
+
+ gl.bind_framebuffer(glow::FRAMEBUFFER, painter.intermediate_fbo());
+ gl.bind_texture(glow::TEXTURE_2D, Some(self.levels[0].texture));
+ gl.copy_tex_sub_image_2d(
+ glow::TEXTURE_2D,
+ 0,
+ 0,
+ 0,
+ origin[0],
+ origin[1],
+ size[0],
+ size[1],
+ );
+
+ let pass = |program: &Program, from: usize, to: Option| {
+ let source = &self.levels[from];
+ gl.use_program(Some(program.program));
+ gl.uniform_2_f32(
+ program.step.as_ref(),
+ SPREAD / source.size[0] as f32,
+ SPREAD / source.size[1] as f32,
+ );
+ gl.bind_texture(glow::TEXTURE_2D, Some(source.texture));
+ if let Some(to) = to {
+ let target = &self.levels[to];
+ gl.bind_framebuffer(glow::FRAMEBUFFER, Some(target.framebuffer));
+ gl.viewport(0, 0, target.size[0], target.size[1]);
+ gl.draw_arrays(glow::TRIANGLES, 0, 3);
+ }
+ };
+ for i in 1..=LEVELS {
+ pass(&self.down, i - 1, Some(i));
+ }
+ for i in (1..LEVELS).rev() {
+ pass(&self.up, i + 1, Some(i));
+ }
+
+ pass(&self.last, 1, None);
+ gl.bind_framebuffer(glow::FRAMEBUFFER, painter.intermediate_fbo());
+ gl.viewport(origin[0], origin[1], size[0], size[1]);
+ let clip = info.clip_rect_in_pixels();
+ gl.enable(glow::SCISSOR_TEST);
+ gl.scissor(
+ clip.left_px,
+ clip.from_bottom_px,
+ clip.width_px,
+ clip.height_px,
+ );
+ gl.enable(glow::BLEND);
+ gl.blend_func_separate(
+ glow::ONE,
+ glow::ONE_MINUS_SRC_ALPHA,
+ glow::ONE_MINUS_DST_ALPHA,
+ glow::ONE,
+ );
+ let centre = rect.center();
+ gl.uniform_4_f32(
+ self.last.region.as_ref(),
+ origin[0] as f32,
+ origin[1] as f32,
+ size[0] as f32,
+ size[1] as f32,
+ );
+ gl.uniform_4_f32(
+ self.last.shape.as_ref(),
+ centre.x * ppp,
+ height - centre.y * ppp,
+ rect.width() * ppp / 2.0,
+ rect.height() * ppp / 2.0,
+ );
+ gl.uniform_1_f32(self.last.radius.as_ref(), radius * ppp);
+ gl.draw_arrays(glow::TRIANGLES, 0, 3);
+
+ gl.bind_vertex_array(None);
+ gl.bind_texture(glow::TEXTURE_2D, None);
+ }
+ }
+}
+
+impl Program {
+ unsafe fn new(gl: &glow::Context, header: &str, fragment: &str) -> Result {
+ unsafe {
+ let program = gl.create_program()?;
+ let mut shaders = Vec::new();
+ for (kind, source) in [
+ (glow::VERTEX_SHADER, VERTEX),
+ (glow::FRAGMENT_SHADER, fragment),
+ ] {
+ let shader = gl.create_shader(kind)?;
+ gl.shader_source(shader, &format!("{header}{source}"));
+ gl.compile_shader(shader);
+ if !gl.get_shader_compile_status(shader) {
+ return Err(gl.get_shader_info_log(shader));
+ }
+ gl.attach_shader(program, shader);
+ shaders.push(shader);
+ }
+ gl.link_program(program);
+ if !gl.get_program_link_status(program) {
+ return Err(gl.get_program_info_log(program));
+ }
+ for shader in shaders {
+ gl.detach_shader(program, shader);
+ gl.delete_shader(shader);
+ }
+ gl.use_program(Some(program));
+ gl.uniform_1_i32(gl.get_uniform_location(program, "u_tex").as_ref(), 0);
+ Ok(Self {
+ program,
+ step: gl.get_uniform_location(program, "u_step"),
+ region: gl.get_uniform_location(program, "u_region"),
+ shape: gl.get_uniform_location(program, "u_shape"),
+ radius: gl.get_uniform_location(program, "u_radius"),
+ })
+ }
+ }
+}
+
+/// Look of a frosted panel: blurred backdrop, tinted fill, hairline, drop shadow.
+pub struct Frosted {
+ pub radius: f32,
+ pub margin: i8,
+ pub fill: egui::Color32,
+ pub outline: egui::Color32,
+}
+
+/// Lay `add` out on a frosted panel. Use inside a popup or modal whose own frame
+/// is [`egui::Frame::NONE`].
+pub fn frosted(
+ ui: &mut egui::Ui,
+ look: &Frosted,
+ add: impl FnOnce(&mut egui::Ui) -> R,
+) -> egui::InnerResponse {
+ let painter = ui.painter().clone();
+ let slots = [(); 4].map(|()| painter.add(egui::Shape::Noop));
+ let shown = egui::Frame::NONE.inner_margin(look.margin).show(ui, add);
+ let rect = shown.response.rect;
+ let radius = look.radius;
+ let shadow = egui::epaint::Shadow {
+ offset: [0, 16],
+ blur: 36,
+ spread: 0,
+ color: super::theme::black(0.45),
+ };
+ painter.set(slots[0], shadow.as_shape(rect, radius));
+ painter.set(slots[1], shape(rect, radius));
+ painter.set(
+ slots[2],
+ egui::epaint::RectShape::new(
+ rect,
+ radius,
+ look.fill,
+ egui::Stroke::new(1.0, look.outline),
+ egui::StrokeKind::Inside,
+ ),
+ );
+ painter.set(
+ slots[3],
+ super::theme::top_highlight(rect, radius, super::theme::white(0.16)),
+ );
+ shown
+}
diff --git a/crates/stemd-server/src/ui/dropzone.rs b/crates/stemd-server/src/ui/dropzone.rs
index 27c8727..469046c 100644
--- a/crates/stemd-server/src/ui/dropzone.rs
+++ b/crates/stemd-server/src/ui/dropzone.rs
@@ -1,40 +1,47 @@
-//! The main region: where a track is dropped, and what is happening to it.
+//! The main region: the globe, what is happening, and the way to add a track.
//!
-//! It takes whatever height the window has left rather than a fixed box, so the
-//! target is the window rather than a rectangle drawn inside one.
-//!
-//! Three states, one rectangle: idle it invites a drop, armed it lights up,
-//! working it becomes the progress of the track. Swapping the contents rather than
-//! the container stops the layout jumping under the pointer mid-drop.
+//! It takes whatever height the window has left. Idle it invites a drop, armed
+//! it lights up with a dashed edge, working it shows the track's progress. The
+//! container never changes, so the layout does not jump under the pointer.
-use eframe::egui::{self, Color32, Stroke};
+use eframe::egui::{self, Color32, Rect, Sense, Stroke, pos2, vec2};
use crate::drops::{Dropped, State};
+use crate::models::Preset;
+use crate::settings::DropParts;
+
+use super::globe::{self, Motion};
+use super::theme::{self, Brushes, Palette, Tween, Weight, white};
+use super::widgets::{self, Column, Pill};
-use super::theme::{Palette, Sheen, ZONE_RADIUS};
+/// Below this the zone stops shrinking.
+pub const MIN_HEIGHT: f32 = 300.0;
-/// Below this the zone stops shrinking and the window scrolls instead. Enough
-/// for the icon, the title and its line of help.
-pub const MIN_HEIGHT: f32 = 150.0;
+const RADIUS: f32 = 22.0;
-/// The drop icon, square.
-const ARROW: f32 = 44.0;
+/// Width of the progress bar.
+const BAR_WIDTH: f32 = 260.0;
+const BAR_HEIGHT: f32 = 8.0;
-/// Height of the working layout (title, bar and caption with their spacing)
-/// used to centre it. The inviting layout measures itself instead; this one is
-/// still laid out with widgets, because the progress bar is one.
-const WORKING_BLOCK: f32 = 124.0;
+/// Room for the figure beside the bar.
+const PERCENT_WIDTH: f32 = 38.0;
+const PERCENT_GAP: f32 = 12.0;
-/// Room for the figure beside the bar. Fixed rather than measured, so the bar
-/// does not twitch as the number goes from one digit to three.
-pub(super) const PERCENT_WIDTH: f32 = 34.0;
+/// Seconds the fill takes to catch up with a track's progress.
+const TRACK_EASE: f32 = 0.16;
+/// The same for a download, whose byte count arrives in uneven steps.
+const DOWNLOAD_EASE: f32 = 0.6;
-/// Height of the bar-and-figure row. Tall enough for the figure, which is what
-/// decides it: the bar itself is five points.
+/// Height of the bar-and-figure row.
const BAR_ROW_HEIGHT: f32 = 16.0;
-/// The hint under the arrow, listing the ticked parts.
-fn hint(parts: crate::settings::DropParts) -> String {
+const BUTTON_HEIGHT: f32 = 30.0;
+
+/// Longest title before it is cut short with an ellipsis.
+const TITLE_WIDTH: f32 = 420.0;
+
+/// The hint under the title, listing the ticked parts.
+fn hint(parts: DropParts) -> String {
let names: Vec<&str> = parts.names().collect();
let list = match names.split_last() {
Some((last, rest)) if !rest.is_empty() => format!("{} and {last}", rest.join(", ")),
@@ -43,193 +50,532 @@ fn hint(parts: crate::settings::DropParts) -> String {
format!("{list}, beside the file")
}
+/// A model switch, shown in place of the invitation to drop.
+#[derive(Clone, Copy)]
+pub enum Switch<'a> {
+ Downloading {
+ preset: Preset,
+ file: &'a str,
+ done: u64,
+ total: u64,
+ },
+ Verifying(Preset),
+ /// `waiting`: the worker finishes the track in flight first.
+ Loading {
+ preset: Preset,
+ waiting: bool,
+ },
+ Failed {
+ preset: Preset,
+ message: &'a str,
+ },
+}
+
+/// The hand-over from the startup screen: the globe glides from where it was,
+/// and everything else in the zone fades in.
+#[derive(Clone, Copy)]
+pub struct Intro {
+ /// The globe's stage on the startup screen.
+ pub from: Rect,
+ /// How far its slices were spread there.
+ pub spread: f32,
+ /// Progress, eased, 0 to 1.
+ pub t: f32,
+}
+
+/// What a click in the zone asked for.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum Action {
+ ChooseFile,
+ KeepModel,
+}
+
+/// Tracks dropped since the window was last idle.
+#[derive(Clone, Default)]
+pub struct Batch {
+ /// Whether each track, in drop order, has finished or failed.
+ pub finished: Vec,
+ /// The track the zone shows.
+ pub shown: Option,
+}
+
+impl Batch {
+ fn total(&self) -> usize {
+ self.finished.len()
+ }
+
+ fn done(&self) -> usize {
+ self.finished.iter().filter(|f| **f).count()
+ }
+}
+
pub struct Zone<'a> {
pub palette: &'a Palette,
- pub sheen: &'a mut Sheen,
+ pub brushes: &'a Brushes,
+ pub motion: &'a Motion,
/// A file is being dragged over the window right now.
pub armed: bool,
- /// The most recent drop, if it is still being worked on.
+ /// The drop being shown, while it is worked on.
pub current: Option<&'a Dropped>,
- /// What a drop writes, for the hint under the arrow.
- pub parts: crate::settings::DropParts,
+ pub parts: DropParts,
+ pub batch: Batch,
+ /// Shown in place of the subtitle while set.
+ pub note: Option<&'a str>,
+ /// Quality is selected: the globe wears its halo.
+ pub maxed: bool,
+ pub switch: Option>,
+ pub intro: Option,
}
impl Zone<'_> {
- /// Draws the zone and returns whether it was clicked, which opens a picker.
- pub fn show(&mut self, ui: &mut egui::Ui) -> bool {
+ /// Draws the zone and returns what its button asked for.
+ pub fn show(&self, ui: &mut egui::Ui) -> Option {
let height = ui.available_height().max(MIN_HEIGHT);
- let (rect, _) = ui.allocate_exact_size(
- egui::vec2(ui.available_width(), height),
- egui::Sense::hover(),
- );
- // A fixed id rather than an allocation counter, for the same reason the
- // separated rows use one: the counter shifts as the layout above changes
- // and an id that moves between frames cannot pair a press with its
- // release.
- let response = ui.interact(rect, ui.id().with("dropzone"), egui::Sense::click());
-
- let busy = self.current.is_some_and(Dropped::is_running);
- let lit = self.armed || (response.hovered() && !busy);
- // Idle, the surface carries no outline at all: the gradient is what
- // separates it from the window behind. A border here would draw the eye
- // to the edge of the target rather than to the middle of it, and the
- // edge is not the part anyone needs to find.
- let (fill, stroke) = if lit {
- (
- Palette::wash(self.palette.accent, 0x24),
- Stroke::new(1.5, self.palette.accent),
- )
- } else {
- (self.palette.card, Stroke::NONE)
- };
- self.sheen.card(ui, rect, ZONE_RADIUS, fill, stroke);
-
- match self.current {
- Some(item) if item.is_running() => {
- let mut content = ui.new_child(
- egui::UiBuilder::new()
- .max_rect(rect.shrink(20.0))
- .layout(egui::Layout::top_down(egui::Align::Center)),
- );
- content.add_space(((rect.height() - WORKING_BLOCK) / 2.0).max(0.0));
- self.working(&mut content, item);
- }
- _ => self.inviting(ui, rect),
+ let (rect, response) =
+ ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover());
+ let ctx = ui.ctx().clone();
+ let now = ctx.input(|i| i.time);
+ let id = ui.id().with("dropzone");
+ // The globe stays opaque while the rest fades in.
+ let globe_painter = ui.painter().clone();
+ if let Some(intro) = self.intro {
+ ui.multiply_opacity(intro.t);
}
- !busy
- && response
- .on_hover_cursor(egui::CursorIcon::PointingHand)
- .clicked()
- }
+ let working = self.current.is_some_and(Dropped::is_running);
+ let fx = self.motion.fx(now);
+ let cheering = matches!(fx, Some((globe::Fx::Cheer, _)));
+
+ self.surface(ui, rect, id);
- /// Painted rather than laid out as widgets.
- ///
- /// A `ui.label` registers its own interaction rect on top of the zone's, so egui
- /// hands the pointer to the label and the zone beneath stops seeing the hover. The
- /// painter takes no part in interaction.
- fn inviting(&self, ui: &egui::Ui, rect: egui::Rect) {
- let colour = if self.armed {
- self.palette.accent
+ let switch = self.switch.filter(|_| !self.armed && !working && !cheering);
+ let title = if self.armed {
+ "Release to separate".to_owned()
+ } else if cheering {
+ "All done".to_owned()
+ } else if let Some(item) = self.current.filter(|_| working) {
+ item.title.clone()
+ } else if let Some(switch) = switch {
+ switch_title(switch)
} else {
- self.palette.muted
+ "Drop a track".to_owned()
};
- let title = if self.armed {
- "Release to separate"
+ let title_colour = if matches!(switch, Some(Switch::Failed { .. })) {
+ self.palette.danger
+ } else {
+ self.palette.text
+ };
+ let progress = match switch {
+ Some(Switch::Downloading { done, total, .. }) => Some((
+ (total > 0).then(|| done as f32 / total as f32),
+ String::new(),
+ )),
+ Some(Switch::Verifying(_)) => Some((None, String::new())),
+ _ => self.current.filter(|_| working).map(progress_of),
+ };
+ let ease = if switch.is_some() {
+ DOWNLOAD_EASE
} else {
- "Drop a track"
+ TRACK_EASE
+ };
+ let sub = if self.armed {
+ hint(self.parts)
+ } else if let Some(switch) = switch {
+ switch_caption(switch)
+ } else if let Some(note) = self.note {
+ note.to_owned()
+ } else if let Some((_, caption)) = &progress {
+ if let Some(shown) = self.batch.shown
+ && self.batch.total() > 1
+ {
+ format!(
+ "{caption}{}track {} of {}",
+ theme::DOT,
+ shown + 1,
+ self.batch.total()
+ )
+ } else {
+ caption.clone()
+ }
+ } else {
+ hint(self.parts)
};
- let title_font = egui::FontId::proportional(19.0);
- let hint_font = egui::FontId::proportional(11.5);
- let (title_h, hint_h) = ui
- .ctx()
- .fonts_mut(|fonts| (fonts.row_height(&title_font), fonts.row_height(&hint_font)));
+ let title_font = theme::font(20.0, Weight::Semibold);
+ let sub_font = theme::font(12.5, Weight::Regular);
+ let (title_h, sub_h) =
+ ctx.fonts_mut(|f| (f.row_height(&title_font), f.row_height(&sub_font)));
+ let stacked = progress.is_some() && switch.is_none() && self.batch.total() > 1;
+ let button = match switch {
+ Some(Switch::Failed { .. }) => Some(("Keep current model", Action::KeepModel)),
+ Some(_) => None,
+ None if working => Some(("Queue another track", Action::ChooseFile)),
+ None => Some(("Choose a file", Action::ChooseFile)),
+ };
- // Centred as one block, so the zone reads the same whether it is 150
- // points tall or 400.
- let block = ARROW + 16.0 + title_h + 5.0 + hint_h;
- let mut y = rect.center().y - block / 2.0;
+ // Each row's height and the gap under it, once; the block is centred.
+ let mut column = Column::default();
+ let stage_at = column.add(globe::STAGE.y, 8.0);
+ let title_at = column.add(title_h, 6.0);
+ let sub_at = column.add(sub_h, 18.0);
+ let bar_at = progress
+ .is_some()
+ .then(|| column.add(BAR_ROW_HEIGHT, if stacked { 6.0 } else { 14.0 }));
+ let segments_at = stacked.then(|| column.add(4.0, 14.0));
+ let button_at = button.is_some().then(|| column.add(BUTTON_HEIGHT, 0.0));
+ let top = rect.center().y - column.height() / 2.0;
let mid = rect.center().x;
- arrow(ui, egui::pos2(mid, y), colour, self.armed);
- y += ARROW + 16.0;
+ let stage = Rect::from_min_size(
+ pos2(mid - globe::STAGE.x / 2.0, top + stage_at),
+ globe::STAGE,
+ );
+ let spread = ctx.animate_bool_with_time_and_easing(
+ id.with("spread"),
+ self.armed || working,
+ 0.5,
+ ease_spread,
+ );
+ let lift = Tween::follow(
+ &ctx,
+ id.with("lift"),
+ if response.contains_pointer() {
+ 3.0
+ } else {
+ 0.0
+ },
+ 0.3,
+ theme::ease_spring,
+ );
+ let halo = ctx.animate_bool_with_time(id.with("halo"), self.maxed, 0.7);
+ // From the startup screen's pose to this one.
+ let (stage, spread, halo) = match self.intro {
+ Some(intro) => (
+ Rect::from_min_size(intro.from.min.lerp(stage.min, intro.t), stage.size()),
+ intro.spread + (spread - intro.spread) * intro.t,
+ halo * intro.t,
+ ),
+ None => (stage, spread, halo),
+ };
+ // The halo and rings stay inside the card. During the intro the globe
+ // can still be outside it, so its box is kept too.
+ let globe_painter = globe_painter.with_clip_rect(rect.union(stage));
+ globe::paint(
+ &globe_painter,
+ self.brushes,
+ self.palette,
+ stage,
+ self.motion,
+ globe::Look { spread, lift, halo },
+ now,
+ );
let painter = ui.painter();
- painter.text(
- egui::pos2(mid, y),
- egui::Align2::CENTER_TOP,
+ let mut job = egui::text::LayoutJob::single_section(
title,
- title_font,
- self.palette.text,
+ egui::TextFormat::simple(title_font, title_colour),
+ );
+ job.wrap =
+ egui::text::TextWrapping::truncate_at_width(TITLE_WIDTH.min(rect.width() - 40.0));
+ let galley = painter.layout_job(job);
+ painter.galley(
+ pos2(mid - galley.size().x / 2.0, top + title_at),
+ galley,
+ title_colour,
+ );
+ let mut job = egui::text::LayoutJob::single_section(
+ sub,
+ egui::TextFormat::simple(sub_font, self.palette.muted),
);
- y += title_h + 5.0;
- painter.text(
- egui::pos2(mid, y),
- egui::Align2::CENTER_TOP,
- hint(self.parts),
- hint_font,
+ job.wrap = egui::text::TextWrapping::truncate_at_width(rect.width() - 40.0);
+ let galley = painter.layout_job(job);
+ painter.galley(
+ pos2(mid - galley.size().x / 2.0, top + sub_at),
+ galley,
self.palette.muted,
);
+
+ if let (Some((fraction, _)), Some(bar_at)) = (progress, bar_at) {
+ // The bar is centred; the figure hangs off its right end.
+ let row = Rect::from_min_size(
+ pos2(mid - BAR_WIDTH / 2.0, top + bar_at),
+ vec2(BAR_WIDTH + PERCENT_GAP + PERCENT_WIDTH, BAR_ROW_HEIGHT),
+ );
+ progress_row(
+ ui,
+ row,
+ fraction,
+ ease,
+ self.palette,
+ self.brushes,
+ self.motion.clock,
+ );
+ if let Some(segments_at) = segments_at {
+ self.segments(
+ ui,
+ Rect::from_min_size(pos2(row.left(), top + segments_at), vec2(BAR_WIDTH, 4.0)),
+ fraction,
+ );
+ }
+ }
+
+ let ((label, action), button_at) = button.zip(button_at)?;
+ let width = widgets::pill_width(ui, label, BUTTON_HEIGHT);
+ let rect = Rect::from_min_size(
+ pos2(mid - width / 2.0, top + button_at),
+ vec2(width, BUTTON_HEIGHT),
+ );
+ widgets::pill(
+ ui,
+ rect,
+ id.with("choose"),
+ label,
+ Pill::Ghost,
+ true,
+ self.palette,
+ self.brushes,
+ )
+ .clicked()
+ .then_some(action)
}
- fn working(&self, ui: &mut egui::Ui, item: &Dropped) {
- ui.label(
- egui::RichText::new(&item.title)
- .size(16.0)
- .color(self.palette.text),
+ /// The zone's own surface: faint at rest, brighter with a dashed edge and an
+ /// inner blue glow while a file is held over it.
+ fn surface(&self, ui: &egui::Ui, rect: Rect, id: egui::Id) {
+ let painter = ui.painter();
+ let armed = ui
+ .ctx()
+ .animate_bool_with_time(id.with("armed"), self.armed, 0.2);
+ painter.rect_filled(rect, RADIUS, white(0.025 + 0.045 * armed));
+ theme::inner_glow(
+ painter,
+ rect,
+ RADIUS,
+ 48.0,
+ Color32::from_rgba_unmultiplied(91, 132, 216, (0.22 * 255.0 * armed) as u8),
);
- ui.add_space(16.0);
-
- let (fraction, caption) = match item.state() {
- State::Reading => (None, "reading the file".to_owned()),
- State::Working(progress) => {
- let caption = match progress.total {
- 0 => stage_label(progress.stage).to_owned(),
- total => format!(
- "{} · {}/{}",
- stage_label(progress.stage),
- progress.completed,
- total
- ),
- };
- (Some(progress.fraction), caption)
+ if armed < 1.0 {
+ painter.rect_stroke(
+ rect,
+ RADIUS,
+ Stroke::new(1.5, white(0.06 * (1.0 - armed))),
+ egui::StrokeKind::Inside,
+ );
+ }
+ if armed > 0.0 {
+ theme::dashed_outline(
+ painter,
+ rect,
+ RADIUS,
+ Stroke::new(1.5, self.palette.band.gamma_multiply(armed)),
+ );
+ }
+ }
+
+ /// One short bar per track in the batch, under the main bar. A batch too
+ /// large for 4-point bars gets one bar for the whole batch.
+ fn segments(&self, ui: &egui::Ui, row: Rect, fraction: Option) {
+ let total = self.batch.total();
+ let gap = 3.0;
+ let mut n = total;
+ let mut width = (row.width() - gap * (n as f32 - 1.0)) / n as f32;
+ if width < 4.0 {
+ n = 1;
+ width = row.width();
+ }
+ for i in 0..n {
+ let seg = Rect::from_min_size(
+ pos2(row.left() + i as f32 * (width + gap), row.top()),
+ vec2(width, row.height()),
+ );
+ ui.painter().rect_filled(seg, 2.0, white(0.12));
+ let target = if n < total {
+ (self.batch.done() as f32 + fraction.unwrap_or(0.0)) / total as f32
+ } else if self.batch.finished[i] {
+ 1.0
+ } else if self.batch.shown == Some(i) {
+ fraction.unwrap_or(0.0)
+ } else {
+ 0.0
+ };
+ let shown =
+ ui.ctx()
+ .animate_value_with_time(ui.id().with(("segment", i)), target, 0.16);
+ if shown > 0.0 {
+ ui.painter().rect_filled(
+ Rect::from_min_size(seg.min, vec2(width * shown, seg.height())),
+ 2.0,
+ self.palette.soft,
+ );
}
- // Not reachable while `is_running`, but a state machine read across
- // two locks can always land between them.
- State::Done(_) | State::Failed(_) => (None, "finishing".to_owned()),
- };
+ }
+ }
+}
- progress_row(ui, fraction, self.palette);
- ui.add_space(14.0);
- ui.label(
- egui::RichText::new(caption)
- .size(11.5)
- .color(self.palette.muted),
- );
+fn switch_title(switch: Switch) -> String {
+ match switch {
+ Switch::Downloading { preset, .. } => format!("Downloading the {} model", preset.label()),
+ Switch::Verifying(preset) => format!("Checking the {} model", preset.label()),
+ Switch::Loading { preset, .. } => format!("Loading {}", preset.label()),
+ Switch::Failed { preset, .. } => format!("Could not switch to {}", preset.label()),
+ }
+}
+
+fn switch_caption(switch: Switch) -> String {
+ match switch {
+ Switch::Downloading {
+ file, done, total, ..
+ } => format!(
+ "{file}{}{:.0} / {:.0} MB",
+ theme::DOT,
+ done as f64 / 1e6,
+ total as f64 / 1e6
+ ),
+ Switch::Verifying(_) => "verifying local data".to_owned(),
+ Switch::Loading { waiting: true, .. } => "after the track in flight finishes".to_owned(),
+ Switch::Loading { waiting: false, .. } => "onto the GPU, a few seconds".to_owned(),
+ Switch::Failed { message, .. } => message.to_owned(),
}
}
-/// The bar and the figure beside it, as one centred row.
+/// `cubic-bezier(.2, .9, .2, 1)`: the slices settling apart.
+fn ease_spread(t: f32) -> f32 {
+ theme::cubic_bezier(0.2, 0.9, 0.2, 1.0, t)
+}
+
+/// The fraction done, if it can be counted, and the stage in words.
+fn progress_of(item: &Dropped) -> (Option, String) {
+ match item.state() {
+ State::Reading => (None, "reading the file".to_owned()),
+ State::Working(progress) => {
+ let caption = match progress.total {
+ 0 => stage_label(progress.stage).to_owned(),
+ total => format!(
+ "{}{}{}/{}",
+ stage_label(progress.stage),
+ theme::DOT,
+ progress.completed,
+ total
+ ),
+ };
+ (Some(progress.fraction), caption)
+ }
+ // Not reachable while running, but a state read across two locks can
+ // land between them.
+ State::Done(_) | State::Failed(_) => (None, "finishing".to_owned()),
+ }
+}
+
+/// The bar and the figure beside it, in `row`.
///
-/// The figure sits beside the bar rather than inside it: the bar is five points
-/// tall and egui would clip the text. Allocating exactly the row's width lets the
-/// enclosing `Align::Center` do its job, which a plain `ui.horizontal` prevents by
-/// taking the whole width.
-pub(super) fn progress_row(ui: &mut egui::Ui, fraction: Option, palette: &Palette) {
- let bar = match fraction {
- Some(fraction) => egui::ProgressBar::new(fraction),
- // An indeterminate stage is honest about it rather than showing a bar
- // parked at zero, which reads as stuck.
- None => egui::ProgressBar::new(0.0).animate(true),
- };
- let gap = ui.spacing().item_spacing.x;
- // Clamped, not just capped: a narrow enough window would otherwise ask for
- // a negative width once the figure's share is taken off.
- let width = (ui.available_width() - PERCENT_WIDTH - gap).clamp(60.0, 260.0);
-
- ui.allocate_ui_with_layout(
- egui::vec2(width + gap + PERCENT_WIDTH, BAR_ROW_HEIGHT),
- egui::Layout::left_to_right(egui::Align::Center),
- |ui| {
- ui.add(
- bar.desired_width(width)
- .desired_height(5.0)
- .corner_radius(3)
- .fill(palette.accent),
- );
- // Blank while indeterminate, so the row does not change width when
- // a stage that cannot be counted starts or ends.
- let figure = fraction.map_or_else(String::new, crate::ui::status::percent);
- ui.add_sized(
- [PERCENT_WIDTH, BAR_ROW_HEIGHT],
- egui::Label::new(egui::RichText::new(figure).size(11.5).color(palette.muted)),
- );
- },
+/// The fill is the red, green and blue gradient anchored to the whole track, with
+/// a gloss and a moving shimmer. `None` is a stage that cannot be counted: an
+/// empty track with the shimmer running across it.
+pub(super) fn progress_row(
+ ui: &egui::Ui,
+ row: Rect,
+ fraction: Option,
+ ease: f32,
+ palette: &Palette,
+ brushes: &Brushes,
+ clock: f32,
+) {
+ let painter = ui.painter();
+ let track = Rect::from_min_size(
+ pos2(row.left(), row.center().y - BAR_HEIGHT / 2.0),
+ vec2(
+ BAR_WIDTH.min(row.width() - PERCENT_GAP - PERCENT_WIDTH),
+ BAR_HEIGHT,
+ ),
);
+ let r = BAR_HEIGHT / 2.0;
+ painter.rect_filled(track, r, theme::black(0.3));
+ // `inset 0 1px 2px` shade along the top, `0 1px 0` light under the bottom.
+ painter.line_segment(
+ [
+ pos2(track.left() + r, track.top() + 0.75),
+ pos2(track.right() - r, track.top() + 0.75),
+ ],
+ Stroke::new(1.5, theme::black(0.3)),
+ );
+ painter.line_segment(
+ [
+ pos2(track.left() + r, track.bottom() + 0.5),
+ pos2(track.right() - r, track.bottom() + 0.5),
+ ],
+ Stroke::new(1.0, white(0.10)),
+ );
+
+ let target = fraction.unwrap_or(0.0).clamp(0.0, 1.0);
+ let shown = Tween::follow(
+ ui.ctx(),
+ ui.id().with("progress-fill"),
+ target,
+ ease,
+ theme::ease_out,
+ );
+ let fill = Rect::from_min_size(track.min, vec2(track.width() * shown, BAR_HEIGHT));
+ if fill.width() > 0.5 {
+ brushes.spectrum(painter, track, fill, r, Color32::WHITE);
+ brushes.gloss(painter, fill, r);
+ }
+ let lane = if fraction.is_some() { fill } else { track };
+ let alpha = if fraction.is_some() { 0.55 } else { 0.25 };
+ shimmer(painter, lane, r, clock, alpha);
+
+ let figure = fraction.map_or_else(String::new, super::status::percent);
+ painter.text(
+ pos2(track.right() + PERCENT_GAP, row.center().y),
+ egui::Align2::LEFT_CENTER,
+ figure,
+ theme::font(13.0, Weight::Medium),
+ palette.text,
+ );
+}
+
+/// A 60-point band of light sweeping across `lane` every 1.6 seconds, clipped to
+/// its rounded ends.
+fn shimmer(painter: &egui::Painter, lane: Rect, radius: f32, clock: f32, alpha: f32) {
+ const BAND: f32 = 60.0;
+ if lane.width() < 1.0 {
+ return;
+ }
+ let t = theme::ease_in_out((clock / 1.6).rem_euclid(1.0));
+ let start = lane.left() - BAND + t * (lane.width() + BAND);
+ let (from, to) = (start.max(lane.left()), (start + BAND).min(lane.right()));
+ if to <= from {
+ return;
+ }
+ // Half the height of the rounded lane at `x`.
+ let half = |x: f32| {
+ let h = lane.height() / 2.0;
+ let edge = (x - lane.left()).min(lane.right() - x);
+ if edge >= radius {
+ h
+ } else {
+ let dx = radius - edge;
+ (radius * radius - dx * dx).max(0.0).sqrt().min(h)
+ }
+ };
+ let mut mesh = egui::Mesh::default();
+ let steps = ((to - from) / 2.0).ceil().max(1.0) as u32;
+ for n in 0..=steps {
+ let x = from + (to - from) * n as f32 / steps as f32;
+ let u = (x - start) / BAND;
+ let colour = white(alpha * (1.0 - (2.0 * u - 1.0).abs()));
+ let h = half(x);
+ mesh.colored_vertex(pos2(x, lane.center().y - h), colour);
+ mesh.colored_vertex(pos2(x, lane.center().y + h), colour);
+ }
+ for n in 0..steps {
+ let a = 2 * n;
+ mesh.add_triangle(a, a + 1, a + 2);
+ mesh.add_triangle(a + 1, a + 3, a + 2);
+ }
+ painter.add(mesh);
}
-/// The stage in words rather than `{:?}`, which is what the old window showed.
+/// The stage in words.
pub fn stage_label(stage: stemd_core::Stage) -> &'static str {
use stemd_core::Stage;
match stage {
@@ -243,48 +589,3 @@ pub fn stage_label(stage: stemd_core::Stage) -> &'static str {
Stage::Cancelled => "stopped",
}
}
-
-/// A downward arrow into a tray, painted rather than typed.
-///
-/// egui bundles three fonts and most arrow glyphs are in none of them; a missing
-/// glyph draws nothing at all. `top_centre` is the middle of its top edge; it
-/// occupies [`ARROW`] square.
-fn arrow(ui: &egui::Ui, top_centre: egui::Pos2, colour: Color32, armed: bool) {
- const SIZE: f32 = ARROW;
- let rect = egui::Rect::from_min_size(
- egui::pos2(top_centre.x - SIZE / 2.0, top_centre.y),
- egui::vec2(SIZE, SIZE),
- );
- let painter = ui.painter();
- let stroke = Stroke::new(if armed { 2.4 } else { 1.9 }, colour);
- let mid = rect.center().x;
- // Armed, the arrow sits lower in its box: the smallest hint that the file
- // is on its way in.
- let travel = if armed { 4.0 } else { 0.0 };
- let top = rect.top() + 2.0 + travel;
- let stem_bottom = rect.top() + SIZE * 0.54 + travel;
-
- painter.line_segment([egui::pos2(mid, top), egui::pos2(mid, stem_bottom)], stroke);
- let head = SIZE * 0.16;
- for side in [-1.0, 1.0] {
- painter.line_segment(
- [
- egui::pos2(mid + side * head, stem_bottom - head),
- egui::pos2(mid, stem_bottom),
- ],
- stroke,
- );
- }
-
- // The tray: two shoulders and a floor, open at the top.
- let tray_top = rect.top() + SIZE * 0.70;
- let bottom = rect.bottom() - 2.0;
- let (left, right) = (rect.left() + 6.0, rect.right() - 6.0);
- for x in [left, right] {
- painter.line_segment([egui::pos2(x, tray_top), egui::pos2(x, bottom)], stroke);
- }
- painter.line_segment(
- [egui::pos2(left, bottom), egui::pos2(right, bottom)],
- stroke,
- );
-}
diff --git a/crates/stemd-server/src/ui/globe.rs b/crates/stemd-server/src/ui/globe.rs
new file mode 100644
index 0000000..977fa53
--- /dev/null
+++ b/crates/stemd-server/src/ui/globe.rs
@@ -0,0 +1,771 @@
+//! The globe: three slices of a sphere in the stem colours, and what moves them.
+//!
+//! Geometry is in the design's SVG units: a circle of radius 52 at (60, 60),
+//! cut into vertical strips at x 0..43, 47..73 and 77..120, drawn in a
+//! 114 by 127 point stage whose view box starts at (-8, -12). Each slice is
+//! clipped on the CPU and transformed point by point, because slices rotate and
+//! egui only clips to axis-aligned rectangles.
+
+use std::f32::consts::{FRAC_PI_2, TAU};
+
+use eframe::egui::{self, Color32, Pos2, Rect, Stroke, Vec2, pos2, vec2};
+
+use super::theme::{self, Brushes, Palette};
+
+/// The stage the globe is drawn in, in points.
+pub const STAGE: Vec2 = Vec2 { x: 114.0, y: 127.0 };
+
+/// Points per SVG unit.
+const K: f32 = 114.0 / 136.0;
+const VIEW_MIN: Vec2 = Vec2 { x: -8.0, y: -12.0 };
+
+const CENTRE: Pos2 = Pos2 { x: 60.0, y: 60.0 };
+const RADIUS: f32 = 52.0;
+
+/// What CSS `transform-origin: 50% 50%` resolves to for the whole globe.
+const GLOBE_ORIGIN: Pos2 = Pos2 { x: 60.0, y: 63.5 };
+
+/// Left and right edge of each slice.
+const STRIPS: [(f32, f32); 3] = [(0.0, 43.0), (47.0, 73.0), (77.0, 120.0)];
+const PARALLELS: [f32; 6] = [21.0, 34.0, 47.0, 73.0, 86.0, 99.0];
+const BAND: (f32, f32) = (56.5, 63.5);
+
+/// One-shot effects.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Fx {
+ /// A separation started: the globe flinches and a ring goes out.
+ Start,
+ /// The batch finished: a wobble and three coloured rings.
+ Cheer,
+ /// Picked Fast: the meridians whip round and streaks fly past.
+ Fast,
+ /// Picked Balanced: the globe tilts back and forth.
+ Balanced,
+ /// Picked Quality: the globe swells.
+ Quality,
+}
+
+impl Fx {
+ /// How long the effect, and any note shown with it, lasts.
+ pub const fn duration(self) -> f32 {
+ match self {
+ Self::Start => 0.9,
+ Self::Cheer => 2.4,
+ Self::Fast | Self::Balanced | Self::Quality => 1.7,
+ }
+ }
+}
+
+/// What drives the motion this frame.
+pub struct Drive {
+ /// `Some` while separating: which of the five motions to play.
+ pub variant: Option,
+ /// How fast the motion runs: 1.6 on Fast, 1 on Balanced, 0.6 on Quality.
+ pub speed: f32,
+ /// Seconds per meridian turn while working, `None` at rest.
+ pub spin: Option,
+}
+
+/// Where [`Motion::clock`] wraps: a multiple of every period drawn from it
+/// (spinner 1 s, shimmer 1.6 s, sheen 2.5 s, halo 7 s), so nothing jumps.
+const CLOCK_WRAP: f32 = 280.0;
+
+/// Seconds since the previous frame, measured on egui's clock.
+///
+/// egui's `stable_dt` reports a fixed 1/60 s after a frame that asked for a
+/// delayed repaint, which runs animations fast on a faster display.
+pub fn frame_dt(ctx: &egui::Context) -> f32 {
+ let id = egui::Id::new("stemd-frame-dt");
+ let now = ctx.input(|i| i.time);
+ let last = ctx.data(|d| d.get_temp::(id));
+ ctx.data_mut(|d| d.insert_temp(id, now));
+ last.map_or(1.0 / 60.0, |last| (now - last).max(0.0) as f32)
+}
+
+/// The animation state, stepped once per frame.
+#[derive(Clone, Debug)]
+pub struct Motion {
+ phase: f32,
+ amp: f32,
+ speed: f32,
+ from: u8,
+ to: u8,
+ mix: f32,
+ /// Meridian position, in turns.
+ meridian: f32,
+ /// Seconds of ambient time, which only runs while the window is live.
+ /// Wraps at [`CLOCK_WRAP`].
+ pub clock: f32,
+ fx: Option<(Fx, f64)>,
+}
+
+impl Default for Motion {
+ fn default() -> Self {
+ Self {
+ phase: 0.0,
+ amp: 0.0,
+ speed: 1.0,
+ from: 0,
+ to: 0,
+ mix: 1.0,
+ meridian: 0.0,
+ clock: 0.0,
+ fx: None,
+ }
+ }
+}
+
+/// Pick one of the five motions for a job, stable for its id.
+pub fn variant_of(id: &str) -> u8 {
+ // FNV-1a.
+ let hash = id.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| {
+ (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3)
+ });
+ (hash % 5) as u8
+}
+
+impl Motion {
+ /// Start a one-shot, replacing any in flight.
+ pub fn fire(&mut self, fx: Fx, now: f64) {
+ self.fx = Some((fx, now));
+ }
+
+ /// The live one-shot and seconds since it started.
+ pub fn fx(&self, now: f64) -> Option<(Fx, f32)> {
+ let (fx, start) = self.fx?;
+ let elapsed = (now - start) as f32;
+ (elapsed < fx.duration()).then_some((fx, elapsed))
+ }
+
+ /// Advance by `dt` seconds. Returns whether anything is moving, which is
+ /// whether the window should repaint every frame.
+ pub fn step(&mut self, dt: f32, now: f64, drive: &Drive) -> bool {
+ let dt = dt.min(0.05);
+ if let Some(v) = drive.variant {
+ if self.amp < 0.05 {
+ self.from = v;
+ self.to = v;
+ self.mix = 1.0;
+ } else if v != self.to {
+ self.from = self.to;
+ self.to = v;
+ self.mix = 0.0;
+ }
+ }
+ let k = 1.0 - (-dt * 3.5).exp();
+ let target = if drive.variant.is_some() { 1.0 } else { 0.0 };
+ self.amp += (target - self.amp) * k;
+ self.speed += (drive.speed - self.speed) * k;
+ self.mix = (self.mix + dt / 1.1).min(1.0);
+ // Only ever read through sin and cos, so it wraps without a seam.
+ self.phase = (self.phase + dt * self.speed * 4.4).rem_euclid(TAU);
+ if self.amp <= 0.003 && drive.variant.is_none() {
+ self.amp = 0.0;
+ }
+
+ let whip = matches!(self.fx(now), Some((Fx::Fast, _)));
+ let period = if whip { Some(0.45) } else { drive.spin };
+ if let Some(period) = period {
+ self.meridian = (self.meridian + dt / period).rem_euclid(1.0);
+ }
+ if self.fx.is_some() && self.fx(now).is_none() {
+ self.fx = None;
+ }
+
+ let live = self.amp > 0.0 || self.fx.is_some() || period.is_some();
+ if live {
+ self.clock = (self.clock + dt).rem_euclid(CLOCK_WRAP);
+ }
+ live
+ }
+
+ /// Translate x, translate y, rotation in degrees, and extra scale x and y,
+ /// for slice `i` under motion `v`.
+ fn motion(&self, v: u8, i: usize) -> [f32; 5] {
+ let a = self.phase - i as f32 * 1.05;
+ let sn = a.sin();
+ let up = sn.max(0.0);
+ let dn = (-sn).max(0.0);
+ match v {
+ // Wave.
+ 0 => [0.0, 10.0 * sn, 3.0 * a.cos(), 0.0, 0.0],
+ // Accordion.
+ 1 => {
+ let c = 0.5 - 0.5 * self.phase.cos();
+ if i == 1 {
+ [0.0, 2.0 - 8.0 * c, 0.0, 0.07 * c, 0.07 * c]
+ } else {
+ let side = if i == 0 { -1.0 } else { 1.0 };
+ [side * (9.0 * c - 3.0), 0.0, 0.0, 0.0, 0.0]
+ }
+ }
+ // Hop.
+ 2 => {
+ let h = up.powf(1.6);
+ [0.0, -14.0 * h, 0.0, 0.0, 0.07 * h - 0.06 * dn * dn]
+ }
+ // Swell.
+ 3 => {
+ let h = up * up;
+ [0.0, -3.0 * h, 0.0, 0.13 * h, 0.13 * h]
+ }
+ // Rock.
+ _ => [0.0, 0.0, 12.0 * sn, 0.0, 0.0],
+ }
+ }
+
+ /// Slice `i`'s transform, cross-faded between the old and new motion and
+ /// scaled by how far the motion has eased in.
+ fn slice(&self, i: usize) -> [f32; 5] {
+ let m = self.mix * self.mix * (3.0 - 2.0 * self.mix);
+ let a = self.motion(self.from, i);
+ let b = self.motion(self.to, i);
+ std::array::from_fn(|n| (a[n] * (1.0 - m) + b[n] * m) * self.amp)
+ }
+
+ fn meridians(&self) -> [f32; 3] {
+ std::array::from_fn(|n| {
+ let turn = (self.meridian + n as f32 / 3.0).rem_euclid(1.0);
+ (FRAC_PI_2 * turn).sin()
+ })
+ }
+}
+
+/// Everything about the globe's look that is not its motion.
+#[derive(Clone, Copy, Default)]
+pub struct Look {
+ /// 0 at rest, 1 with the outer slices pushed apart.
+ pub spread: f32,
+ /// Points the globe is raised by, under a hovering pointer.
+ pub lift: f32,
+ /// Opacity of the Quality halo, 0 to 1.
+ pub halo: f32,
+}
+
+/// A 2D affine map: `x' = a x + c y + e`, `y' = b x + d y + f`.
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct Affine([f32; 6]);
+
+impl Affine {
+ #[cfg(test)]
+ const IDENTITY: Self = Self([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
+
+ fn translate(v: Vec2) -> Self {
+ Self([1.0, 0.0, 0.0, 1.0, v.x, v.y])
+ }
+
+ fn rotate(degrees: f32) -> Self {
+ let (s, c) = degrees.to_radians().sin_cos();
+ Self([c, s, -s, c, 0.0, 0.0])
+ }
+
+ fn scale(x: f32, y: f32) -> Self {
+ Self([x, 0.0, 0.0, y, 0.0, 0.0])
+ }
+
+ /// `self` applied after `inner`.
+ fn after(self, inner: Self) -> Self {
+ let [a, b, c, d, e, f] = self.0;
+ let [a2, b2, c2, d2, e2, f2] = inner.0;
+ Self([
+ a * a2 + c * b2,
+ b * a2 + d * b2,
+ a * c2 + c * d2,
+ b * c2 + d * d2,
+ a * e2 + c * f2 + e,
+ b * e2 + d * f2 + f,
+ ])
+ }
+
+ /// `self` with its origin moved to `origin`.
+ fn about(self, origin: Pos2) -> Self {
+ Self::translate(origin.to_vec2())
+ .after(self)
+ .after(Self::translate(-origin.to_vec2()))
+ }
+
+ fn apply(self, p: Pos2) -> Pos2 {
+ let [a, b, c, d, e, f] = self.0;
+ pos2(a * p.x + c * p.y + e, b * p.x + d * p.y + f)
+ }
+
+ /// How much the map scales lengths, on average.
+ fn scale_factor(self) -> f32 {
+ let [a, b, c, d, ..] = self.0;
+ (a * d - b * c).abs().sqrt()
+ }
+}
+
+/// Keep the part of convex `polygon` on one side of an axis-aligned line.
+fn clip(polygon: &[Pos2], inside: impl Fn(Pos2) -> f32) -> Vec {
+ let mut out = Vec::with_capacity(polygon.len() + 2);
+ for (i, &p) in polygon.iter().enumerate() {
+ let q = polygon[(i + 1) % polygon.len()];
+ let (dp, dq) = (inside(p), inside(q));
+ if dp >= 0.0 {
+ out.push(p);
+ }
+ if (dp >= 0.0) != (dq >= 0.0) {
+ out.push(p + (q - p) * (dp / (dp - dq)));
+ }
+ }
+ out
+}
+
+fn clip_x(polygon: &[Pos2], x0: f32, x1: f32) -> Vec {
+ clip(&clip(polygon, |p| p.x - x0), |p| x1 - p.x)
+}
+
+fn clip_y(polygon: &[Pos2], y0: f32, y1: f32) -> Vec {
+ clip(&clip(polygon, |p| p.y - y0), |p| y1 - p.y)
+}
+
+fn circle(steps: usize) -> Vec {
+ (0..steps)
+ .map(|i| {
+ let a = TAU * i as f32 / steps as f32;
+ CENTRE + RADIUS * vec2(a.cos(), a.sin())
+ })
+ .collect()
+}
+
+/// Split a polyline into the runs with `x0 <= x <= x1`.
+fn runs_within(points: &[Pos2], x0: f32, x1: f32) -> Vec> {
+ let inside = |p: Pos2| p.x >= x0 && p.x <= x1;
+ let crossing = |p: Pos2, q: Pos2| {
+ let x = if (p.x < x0) != (q.x < x0) { x0 } else { x1 };
+ p + (q - p) * ((x - p.x) / (q.x - p.x))
+ };
+ let mut runs = Vec::new();
+ let mut run: Vec = Vec::new();
+ for pair in points.windows(2) {
+ let (p, q) = (pair[0], pair[1]);
+ match (inside(p), inside(q)) {
+ (true, true) => {
+ if run.is_empty() {
+ run.push(p);
+ }
+ run.push(q);
+ }
+ (true, false) => {
+ if run.is_empty() {
+ run.push(p);
+ }
+ run.push(crossing(p, q));
+ runs.push(std::mem::take(&mut run));
+ }
+ (false, true) => {
+ run.push(crossing(p, q));
+ run.push(q);
+ }
+ (false, false) => {}
+ }
+ }
+ if run.len() > 1 {
+ runs.push(run);
+ }
+ // A closed loop that starts inside the strip: join its last run to its first.
+ let closed = points.len() > 2 && points[0].distance(points[points.len() - 1]) < 1e-3;
+ if closed && runs.len() > 1 && inside(points[0]) {
+ let first = runs.remove(0);
+ if let Some(last) = runs.last_mut() {
+ last.extend(first.into_iter().skip(1));
+ }
+ }
+ runs
+}
+
+/// The globe's transform for the live one-shot: scale and rotation.
+fn fx_pose(fx: Option<(Fx, f32)>) -> (f32, f32) {
+ /// Offset, scale, rotation.
+ type Key = (f32, f32, f32);
+ let keys: (&[Key], f32, fn(f32) -> f32) = match fx {
+ Some((Fx::Start, _)) => (
+ &[(0.0, 1.0, 0.0), (0.4, 0.9, 0.0), (1.0, 1.0, 0.0)],
+ 0.5,
+ theme::ease_spring,
+ ),
+ Some((Fx::Cheer, _)) => (
+ &[
+ (0.0, 1.0, 0.0),
+ (0.35, 1.14, -4.0),
+ (0.7, 0.98, 2.0),
+ (1.0, 1.0, 0.0),
+ ],
+ 0.9,
+ theme::ease_spring,
+ ),
+ Some((Fx::Quality, _)) => (
+ &[(0.0, 1.0, 0.0), (0.45, 1.16, 0.0), (1.0, 1.0, 0.0)],
+ 0.8,
+ theme::ease_spring,
+ ),
+ Some((Fx::Balanced, _)) => (
+ &[
+ (0.0, 1.0, 0.0),
+ (0.25, 1.0, -11.0),
+ (0.55, 1.0, 7.0),
+ (0.8, 1.0, -3.0),
+ (1.0, 1.0, 0.0),
+ ],
+ 0.9,
+ |t| theme::cubic_bezier(0.3, 0.6, 0.3, 1.0, t),
+ ),
+ Some((Fx::Fast, _)) | None => return (1.0, 0.0),
+ };
+ let (stops, duration, ease) = keys;
+ let t = fx.map_or(1.0, |(_, elapsed)| (elapsed / duration).min(1.0));
+ for pair in stops.windows(2) {
+ let ((a, sa, ra), (b, sb, rb)) = (pair[0], pair[1]);
+ if t <= b {
+ let e = ease((t - a) / (b - a));
+ return (sa + (sb - sa) * e, ra + (rb - ra) * e);
+ }
+ }
+ (1.0, 0.0)
+}
+
+/// Paint the globe and its effects into `stage`.
+pub fn paint(
+ painter: &egui::Painter,
+ brushes: &Brushes,
+ palette: &Palette,
+ stage: Rect,
+ motion: &Motion,
+ look: Look,
+ now: f64,
+) {
+ let fx = motion.fx(now);
+ let to_screen = |p: Pos2| stage.min + (p.to_vec2() - VIEW_MIN) * K;
+ let globe_centre = to_screen(CENTRE);
+
+ halo(painter, brushes, globe_centre, motion.clock, look.halo);
+ rings(painter, palette, globe_centre, fx);
+ streaks(painter, palette, stage, fx);
+
+ let (scale, rotation) = fx_pose(fx);
+ let globe = Affine::translate(vec2(0.0, -look.lift / K)).after(
+ Affine::scale(scale, scale)
+ .after(Affine::rotate(rotation))
+ .about(GLOBE_ORIGIN),
+ );
+
+ // `drop-shadow(0 10px 16px rgba(0,0,0,.4))`, as one blurred disc.
+ let shadow_half = RADIUS * K * scale / theme::SHADOW_DISC;
+ painter.image(
+ brushes.shadow.id(),
+ Rect::from_center_size(
+ to_screen(globe.apply(CENTRE)) + vec2(0.0, 10.0),
+ Vec2::splat(2.0 * shadow_half),
+ ),
+ Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
+ theme::white(0.4),
+ );
+
+ let disc = circle(96);
+ let meridians = motion.meridians();
+ let colours = [palette.red, palette.green, palette.blue];
+ for (i, &(x0, x1)) in STRIPS.iter().enumerate() {
+ let [tx, ty, rot, sx, sy] = motion.slice(i);
+ let spread = match i {
+ 0 => vec2(-7.0 * look.spread, 0.0),
+ 1 => vec2(0.0, 7.0),
+ _ => vec2(7.0 * look.spread, 0.0),
+ };
+ let wave = Affine::translate(vec2(tx, ty))
+ .after(Affine::rotate(rot))
+ .after(Affine::scale(1.0 + sx, 1.0 + sy))
+ .about(CENTRE);
+ let local = globe.after(Affine::translate(spread)).after(wave);
+ let map = |p: Pos2| to_screen(local.apply(p));
+ let grid = Stroke::new(0.7 * K * local.scale_factor(), theme::white(0.5));
+
+ let outline = clip_x(&disc, x0, x1);
+ painter.add(egui::Shape::convex_polygon(
+ outline.iter().map(|p| map(*p)).collect(),
+ colours[i],
+ Stroke::NONE,
+ ));
+
+ for rx in meridians {
+ let rx = RADIUS * rx;
+ if rx < 0.5 {
+ continue;
+ }
+ let ellipse: Vec = (0..=72)
+ .map(|n| {
+ let a = TAU * n as f32 / 72.0;
+ pos2(CENTRE.x + rx * a.cos(), CENTRE.y + RADIUS * a.sin())
+ })
+ .collect();
+ for run in runs_within(&ellipse, x0, x1) {
+ painter.add(egui::Shape::line(
+ run.into_iter().map(map).collect(),
+ Stroke::new(0.7, theme::white(0.5)),
+ ));
+ }
+ }
+ for y in PARALLELS {
+ let half = (RADIUS * RADIUS - (y - CENTRE.y).powi(2)).sqrt();
+ let (a, b) = ((CENTRE.x - half).max(x0), (CENTRE.x + half).min(x1));
+ if a < b {
+ painter.line_segment([map(pos2(a, y)), map(pos2(b, y))], grid);
+ }
+ }
+
+ let band = clip_y(&outline, BAND.0, BAND.1);
+ if band.len() >= 3 {
+ painter.add(egui::Shape::convex_polygon(
+ band.iter().map(|p| map(*p)).collect(),
+ palette.band,
+ Stroke::NONE,
+ ));
+ }
+
+ let mut shade = egui::Mesh::with_texture(brushes.shade.id());
+ let side = 2.0 * RADIUS;
+ let corner = CENTRE - Vec2::splat(RADIUS);
+ for p in &outline {
+ let uv = (*p - corner) / side;
+ shade.vertices.push(egui::epaint::Vertex {
+ pos: map(*p),
+ uv: uv.to_pos2(),
+ color: Color32::WHITE,
+ });
+ }
+ for k in 1..outline.len().saturating_sub(1) as u32 {
+ shade.add_triangle(0, k, k + 1);
+ }
+ painter.add(shade);
+ }
+}
+
+/// The Quality halo: a blurred colour wheel turning once every seven seconds.
+fn halo(painter: &egui::Painter, brushes: &Brushes, centre: Pos2, clock: f32, alpha: f32) {
+ if alpha <= 0.0 {
+ return;
+ }
+ let half = 75.0 / theme::HALO_DISC;
+ let angle = TAU * clock / 7.0;
+ let (s, c) = angle.sin_cos();
+ let corner = |dx: f32, dy: f32| centre + vec2(dx * c - dy * s, dx * s + dy * c) * half;
+ let tint = theme::white(0.55 * alpha);
+ let mut mesh = egui::Mesh::with_texture(brushes.halo.id());
+ for (pos, uv) in [
+ (corner(-1.0, -1.0), pos2(0.0, 0.0)),
+ (corner(1.0, -1.0), pos2(1.0, 0.0)),
+ (corner(1.0, 1.0), pos2(1.0, 1.0)),
+ (corner(-1.0, 1.0), pos2(0.0, 1.0)),
+ ] {
+ mesh.vertices.push(egui::epaint::Vertex {
+ pos,
+ uv,
+ color: tint,
+ });
+ }
+ mesh.add_triangle(0, 1, 2);
+ mesh.add_triangle(0, 2, 3);
+ painter.add(mesh);
+}
+
+/// One ring growing and fading: scale 0.7 to 1.9, opacity 0.75 to 0.
+fn ring(painter: &egui::Painter, centre: Pos2, radius: f32, width: f32, colour: Color32, t: f32) {
+ if !(0.0..1.0).contains(&t) {
+ return;
+ }
+ let e = theme::ease_out(t);
+ let scale = 0.7 + 1.2 * e;
+ let alpha = 0.75 * (1.0 - e);
+ painter.circle_stroke(
+ centre,
+ radius * scale,
+ Stroke::new(width * scale, colour.gamma_multiply(alpha)),
+ );
+}
+
+fn rings(painter: &egui::Painter, palette: &Palette, centre: Pos2, fx: Option<(Fx, f32)>) {
+ match fx {
+ Some((Fx::Start, t)) => ring(painter, centre, 52.75, 1.5, palette.band, t / 0.9),
+ Some((Fx::Cheer, t)) => {
+ for (n, colour) in [palette.red, palette.green, palette.blue]
+ .into_iter()
+ .enumerate()
+ {
+ ring(
+ painter,
+ centre,
+ 53.0,
+ 2.0,
+ colour,
+ (t - 0.16 * n as f32) / 1.2,
+ );
+ }
+ }
+ _ => {}
+ }
+}
+
+/// Three white streaks flying left to right past the globe.
+fn streaks(painter: &egui::Painter, palette: &Palette, stage: Rect, fx: Option<(Fx, f32)>) {
+ let Some((Fx::Fast, elapsed)) = fx else {
+ return;
+ };
+ for (n, top) in [30.0, 54.0, 78.0].into_iter().enumerate() {
+ let t = (elapsed - 0.08 * n as f32) / 0.5;
+ if !(0.0..1.0).contains(&t) {
+ continue;
+ }
+ let e = theme::ease_in(t);
+ let x = stage.left() - 60.0 - 40.0 + 270.0 * e;
+ let alpha = 0.9 * (1.0 - e);
+ let rect = Rect::from_min_size(pos2(x, stage.top() + top), vec2(70.0, 2.0));
+ let tip = palette.band.gamma_multiply(alpha);
+ let mut mesh = egui::Mesh::default();
+ mesh.colored_vertex(rect.left_top(), Color32::TRANSPARENT);
+ mesh.colored_vertex(rect.right_top(), tip);
+ mesh.colored_vertex(rect.right_bottom(), tip);
+ mesh.colored_vertex(rect.left_bottom(), Color32::TRANSPARENT);
+ mesh.add_triangle(0, 1, 2);
+ mesh.add_triangle(0, 2, 3);
+ painter.add(mesh);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn moving() -> Motion {
+ let mut motion = Motion::default();
+ let drive = Drive {
+ variant: Some(4),
+ speed: 1.0,
+ spin: Some(4.5),
+ };
+ for _ in 0..200 {
+ motion.step(1.0 / 60.0, 0.0, &drive);
+ }
+ motion
+ }
+
+ /// At rest every slice sits exactly where the static design puts it.
+ #[test]
+ fn at_rest_every_slice_transform_is_identity() {
+ let motion = Motion::default();
+ for v in 0..5 {
+ let mut m = motion.clone();
+ m.from = v;
+ m.to = v;
+ for i in 0..3 {
+ assert_eq!(m.slice(i), [0.0; 5], "variant {v}, slice {i}");
+ }
+ }
+ }
+
+ /// Easing out of a separation ends at exactly zero, so the window can stop
+ /// repainting.
+ #[test]
+ fn the_motion_settles_to_exactly_zero_and_goes_quiet() {
+ let mut motion = moving();
+ assert!(motion.amp > 0.9, "{}", motion.amp);
+ let idle = Drive {
+ variant: None,
+ speed: 1.0,
+ spin: None,
+ };
+ let mut live = true;
+ for _ in 0..600 {
+ live = motion.step(1.0 / 60.0, 100.0, &idle);
+ }
+ assert_eq!(motion.amp, 0.0);
+ assert!(!live, "still asking for frames at rest");
+ for i in 0..3 {
+ assert_eq!(motion.slice(i), [0.0; 5]);
+ }
+ }
+
+ #[test]
+ fn slice_polygons_stay_inside_the_circle() {
+ let disc = circle(96);
+ for (x0, x1) in STRIPS {
+ let outline = clip_x(&disc, x0, x1);
+ assert!(outline.len() >= 3);
+ for p in &outline {
+ assert!(p.distance(CENTRE) <= RADIUS + 1e-3, "{p:?} is outside");
+ assert!(
+ p.x >= x0 - 1e-3 && p.x <= x1 + 1e-3,
+ "{p:?} is outside its strip"
+ );
+ }
+ let band = clip_y(&outline, BAND.0, BAND.1);
+ for p in &band {
+ assert!(p.y >= BAND.0 - 1e-3 && p.y <= BAND.1 + 1e-3);
+ }
+ }
+ }
+
+ /// The right strip excludes the centre, so its outline must come from clipping.
+ #[test]
+ fn the_right_slice_has_its_true_corners() {
+ let outline = clip_x(&circle(360), 77.0, 120.0);
+ let top = outline.iter().map(|p| p.y).fold(f32::MAX, f32::min);
+ let expected = CENTRE.y - (RADIUS * RADIUS - 17.0 * 17.0).sqrt();
+ assert!(
+ (top - expected).abs() < 0.5,
+ "top {top}, expected {expected}"
+ );
+ }
+
+ #[test]
+ fn meridian_runs_stay_within_their_strip() {
+ let ellipse: Vec = (0..=72)
+ .map(|n| {
+ let a = TAU * n as f32 / 72.0;
+ pos2(60.0 + 40.0 * a.cos(), 60.0 + 52.0 * a.sin())
+ })
+ .collect();
+ let runs = runs_within(&ellipse, 77.0, 120.0);
+ assert_eq!(
+ runs.len(),
+ 1,
+ "one arc of the ellipse crosses the right strip"
+ );
+ for p in runs.iter().flatten() {
+ assert!(p.x >= 77.0 - 1e-3, "{p:?}");
+ }
+ }
+
+ #[test]
+ fn affine_composition_runs_inner_first() {
+ let map = Affine::translate(vec2(10.0, 0.0)).after(Affine::scale(2.0, 2.0));
+ assert_eq!(map.apply(pos2(1.0, 1.0)), pos2(12.0, 2.0));
+ let turn = Affine::rotate(90.0).about(pos2(1.0, 1.0));
+ let p = turn.apply(pos2(2.0, 1.0));
+ assert!(
+ (p.x - 1.0).abs() < 1e-5 && (p.y - 2.0).abs() < 1e-5,
+ "{p:?}"
+ );
+ assert_eq!(Affine::IDENTITY.apply(pos2(3.0, 4.0)), pos2(3.0, 4.0));
+ }
+
+ #[test]
+ fn a_one_shot_ends_where_it_started() {
+ for fx in [Fx::Start, Fx::Cheer, Fx::Quality, Fx::Balanced] {
+ let (scale, rotation) = fx_pose(Some((fx, fx.duration())));
+ assert!(
+ (scale - 1.0).abs() < 1e-4 && rotation.abs() < 1e-4,
+ "{fx:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn a_job_keeps_its_motion() {
+ assert_eq!(variant_of("job-1"), variant_of("job-1"));
+ assert!(
+ (0..50)
+ .map(|n| variant_of(&format!("job-{n}")))
+ .all(|v| v < 5)
+ );
+ }
+}
diff --git a/crates/stemd-server/src/ui/header.rs b/crates/stemd-server/src/ui/header.rs
index 25c49fa..113e404 100644
--- a/crates/stemd-server/src/ui/header.rs
+++ b/crates/stemd-server/src/ui/header.rs
@@ -1,441 +1,494 @@
-//! The top row: which model is loaded, and what the stems come out as.
+//! The top row: a segmented control for the model, and a capsule with the parts,
+//! format and rate menus.
//!
-//! Both are saved as they change, see [`crate::settings`], so this row is also
-//! what the next launch will do, not just this one.
+//! Everything here is saved as it changes, see [`crate::settings`], so this row is
+//! also what the next launch will do.
use std::sync::Arc;
-use eframe::egui;
+use eframe::egui::{self, Color32, Rect, Sense, Stroke, pos2, vec2};
use stemd_core::{OutputRate, PART_NAMES, StemFormat};
use crate::api::AppState;
use crate::models::Preset;
-use crate::settings::DropParts;
+use crate::settings::{DropParts, SettingsStore};
use crate::switch::{SwitchState, Switcher};
-use super::theme::Palette;
-
-const MODEL_WIDTH: f32 = 112.0;
-const FORMAT_WIDTH: f32 = 88.0;
-const RATE_WIDTH: f32 = 82.0;
-const PARTS_WIDTH: f32 = 72.0;
-pub(super) const DIALOG_WIDTH: f32 = 300.0;
-
-/// Text size in the header row.
-const TEXT: f32 = 12.0;
-
-/// Cap on a dropdown's list height.
-///
-/// `ComboBox` puts its contents in a `ScrollArea` capped at
-/// `spacing.combo_height`, 200 points by default, and a list that reaches the cap
-/// grows a scrollbar. This is deliberately far above what any list here needs
-/// rather than computed to fit: a computed height a few points short clips the
-/// list and puts the scrollbar back. The popup sizes itself to its contents; this
-/// only says where scrolling starts.
-///
-/// Not enough on its own: see [`borderless_rows`].
-pub const MENU_CAP: f32 = 600.0;
-
-/// Take the border off the rows inside a dropdown.
-///
-/// A `selectable_label` in a menu picks up the window's one-point button outline,
-/// which is drawn outside the row's rect, so a list of them measures a fraction of
-/// a point taller than the box holding it and the `ScrollArea` grows a scroll bar.
-/// Raising [`MENU_CAP`] cannot fix it: the overflow is against the list's own
-/// height.
-///
-/// Pinned by [`no_dropdown_grows_a_scroll_bar`](tests::no_dropdown_grows_a_scroll_bar).
-pub fn borderless_rows(ui: &mut egui::Ui) {
- let widgets = &mut ui.style_mut().visuals.widgets;
- for widget in [
- &mut widgets.inactive,
- &mut widgets.hovered,
- &mut widgets.active,
- &mut widgets.open,
- &mut widgets.noninteractive,
- ] {
- widget.bg_stroke = egui::Stroke::NONE;
- }
-}
+use super::icons;
+use super::theme::{self, Brushes, Glass, Palette, Tween, Weight, white};
+use super::widgets::{self, menu_row, pick_menu};
+
+/// Height of both header controls.
+pub const HEIGHT: f32 = 32.0;
+
+/// Segment widths in the segmented control, Fast, Balanced and Quality.
+const SEGMENTS: [f32; 3] = [54.0, 86.0, 88.0];
+const SEGMENT_HEIGHT: f32 = 24.0;
+/// Space between the control's border and its segments.
+const INSET: f32 = 3.0;
-/// Marks a preset the machine does not have yet.
-///
-/// U+2B07 rather than a typographically nicer arrow: egui bundles only
-/// Ubuntu-Light, NotoEmoji and emoji-icon-font, and most arrow glyphs are in
-/// none of the three. A missing glyph draws nothing at all, so anything used
-/// here has to be checked against those fonts rather than assumed.
-const DOWNLOAD_MARK: &str = " ⬇";
+/// Seconds the thumb takes to reach each preset: quick to Fast, slow to Quality.
+const THUMB_TIME: [f32; 3] = [0.12, 0.24, 0.5];
-/// Marks a part that costs an extra model on the loaded preset.
-const SLOW_MARK: &str = " ⚠";
+const TEXT: f32 = 13.0;
+const CAP_HEIGHT: f32 = 28.0;
+const CHEVRON: f32 = 8.0;
/// Shown on an output control a command-line flag fixed for this run.
const PINNED_HINT: &str = "Set on the command line for this run. Restart without \
the flag to change it here.";
-/// No wordmark here: the title bar two points above already says "stemd", and
-/// saying it twice in forty pixels is the kind of thing that reads as unfinished.
-/// The row is the controls.
-pub fn row(ui: &mut egui::Ui, state: &AppState, palette: &Palette) {
- ui.horizontal(|ui| {
- model(ui, state, palette);
- ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- output(ui, state, palette);
- });
- });
+/// Least room between the model control and the capsule.
+const ROW_GAP: f32 = 12.0;
+
+/// Draw the row. Returns the width it needs without the two controls touching.
+pub fn row(
+ ui: &mut egui::Ui,
+ state: &AppState,
+ palette: &Palette,
+ brushes: &Brushes,
+ clock: f32,
+) -> f32 {
+ // The glass's drop shadow reaches past the panel; let it.
+ ui.set_clip_rect(ui.ctx().content_rect());
+ let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), HEIGHT), Sense::hover());
+ let left = models(ui, rect, state, palette, brushes, clock);
+ let right = capsule(ui, rect, state, palette, brushes);
+ left + ROW_GAP + right
+}
+
+/// The preset the segmented control shows: the one being loaded once its
+/// download and check are done, else the loaded one.
+pub fn shown_preset(switcher: &Switcher) -> Option {
+ match switcher.state() {
+ SwitchState::Loading(preset) => Some(preset),
+ _ => switcher.current(),
+ }
}
-/// The model dropdown, plus whatever the switch is currently doing.
-///
-/// Presets are named for the trade rather than the checkpoint. Two things are
-/// marked because both cost you something you would otherwise find out the hard
-/// way: an artefact that is not on disk yet, and one that cannot meet the
-/// latency target.
-fn model(ui: &mut egui::Ui, state: &AppState, palette: &Palette) {
- let switcher = Arc::clone(&state.switcher);
- let switch = switcher.state();
- let current = switcher.current();
+/// The preset a switch in flight is fetching or checking.
+fn pending_preset(switcher: &Switcher) -> Option {
+ match switcher.state() {
+ SwitchState::Downloading { preset, .. } | SwitchState::Verifying(preset) => Some(preset),
+ _ => None,
+ }
+}
- // "Speed" on its own says nothing about what it is the speed of. The presets
- // are named for a trade, and the label is what tells you which trade.
- ui.label(egui::RichText::new("Model").size(TEXT).color(palette.muted));
+fn index_of(preset: Preset) -> usize {
+ Preset::ALL.iter().position(|p| *p == preset).unwrap_or(0)
+}
- ui.add_enabled_ui(!switch.busy(), |ui| {
- let selected = current.map_or_else(
- || "custom".to_owned(),
- |preset| entry_for(&switcher, preset),
+/// Fast, Balanced and Quality as one control with a sliding thumb. Returns its
+/// width.
+fn models(
+ ui: &mut egui::Ui,
+ row: Rect,
+ state: &AppState,
+ palette: &Palette,
+ brushes: &Brushes,
+ clock: f32,
+) -> f32 {
+ let switcher = Arc::clone(&state.switcher);
+ let busy = switcher.state().busy();
+ let shown = shown_preset(&switcher);
+ let pending = pending_preset(&switcher);
+ let width = 2.0 * (1.0 + INSET) + SEGMENTS.iter().sum::();
+ let rect = Rect::from_min_size(row.min, vec2(width, HEIGHT));
+ let painter = ui.painter();
+ brushes.glass(painter, rect, HEIGHT / 2.0, Glass::Panel);
+
+ let lefts: [f32; 3] = std::array::from_fn(|i| SEGMENTS[..i].iter().sum::());
+ let top = rect.top() + 1.0 + INSET;
+ let segment = |left: f32, width: f32| {
+ Rect::from_min_size(
+ pos2(rect.left() + 1.0 + INSET + left, top),
+ vec2(width, SEGMENT_HEIGHT),
+ )
+ };
+
+ if let Some(preset) = shown {
+ let i = index_of(preset);
+ let id = ui.id().with("model-thumb");
+ let x = Tween::follow(
+ ui.ctx(),
+ id.with("x"),
+ lefts[i],
+ THUMB_TIME[i],
+ theme::ease_spring,
);
- egui::ComboBox::from_id_salt("model-preset")
- .selected_text(egui::RichText::new(selected).size(TEXT))
- .width(MODEL_WIDTH)
- .height(MENU_CAP)
- .show_ui(ui, |ui| {
- borderless_rows(ui);
- for preset in Preset::ALL {
- if ui
- .selectable_label(current == Some(preset), entry_for(&switcher, preset))
- .on_hover_text(hint_for(&switcher, preset))
- .clicked()
- {
- switcher.request(preset);
- }
- }
- })
- .response
- .on_hover_text(match current {
- Some(preset) => preset.detail().to_owned(),
- None => "a hand-traced artefact, not one of the presets".to_owned(),
- });
- });
-
- // After the menu, not before it: the row reads left to right now, so a
- // marker belongs beside the thing it is marking rather than ahead of it.
- if switch.busy() {
- ui.spinner();
- } else if matches!(switch, SwitchState::Failed { .. }) {
- ui.label(
- egui::RichText::new("switch failed")
- .size(11.0)
- .color(palette.bad),
+ let w = Tween::follow(
+ ui.ctx(),
+ id.with("w"),
+ SEGMENTS[i],
+ THUMB_TIME[i],
+ theme::ease_spring,
);
+ let thumb = segment(x, w);
+ if preset == Preset::Quality {
+ brushes.sheen(painter, thumb, SEGMENT_HEIGHT / 2.0, 0.4 * clock);
+ } else {
+ brushes.pill(painter, thumb, SEGMENT_HEIGHT / 2.0, 1.0);
+ }
}
- let parts = state.settings.get().drop_parts;
- if current == Some(Preset::Balanced) && (parts.has("bass") || parts.has("other")) {
- ui.label(egui::RichText::new("slow").size(11.0).color(palette.warn))
- .on_hover_text(SPLIT_COST);
+
+ for (i, preset) in Preset::ALL.into_iter().enumerate() {
+ let seg = segment(lefts[i], SEGMENTS[i]);
+ let response = ui
+ .interact(seg, ui.id().with(("model", i)), Sense::click())
+ .on_hover_text(hint_for(&switcher, preset));
+ let selected = shown == Some(preset);
+ let hover = ui.ctx().animate_bool_with_time(
+ response.id.with("hover"),
+ response.hovered() && !busy,
+ 0.15,
+ );
+ let colour = if selected {
+ palette.ink
+ } else {
+ theme::mix(palette.soft, Color32::WHITE, hover)
+ };
+ let weight = if selected {
+ Weight::Semibold
+ } else {
+ Weight::Medium
+ };
+ let remote = !selected && !switcher.is_local(preset);
+ let galley = ui.painter().layout_no_wrap(
+ preset.label().to_owned(),
+ theme::font(TEXT, weight),
+ colour,
+ );
+ let icon = if remote { 10.0 + 5.0 } else { 0.0 };
+ let left = seg.center().x - (galley.size().x + icon) / 2.0;
+ let text_pos = pos2(left, seg.center().y - galley.size().y / 2.0);
+ let text_width = galley.size().x;
+ ui.painter().galley(text_pos, galley, colour);
+ let icon_box = icons::square(pos2(left + text_width + 5.0 + 5.0, seg.center().y), 10.0);
+ if pending == Some(preset) {
+ icons::spinner(ui.painter(), icon_box, colour, 1.3, clock.rem_euclid(1.0));
+ } else if remote {
+ icons::download(ui.painter(), icon_box, colour, 1.3);
+ }
+ if !busy && response.clicked() && !selected {
+ switcher.request(preset);
+ }
+ if !busy && !selected {
+ response.on_hover_cursor(egui::CursorIcon::PointingHand);
+ }
}
+ width
+}
+
+/// Which capsule menu.
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum Menu {
+ Parts,
+ Format,
+ Rate,
}
-/// What a client gets when it does not ask for a format or a rate, and what a
-/// drop is written as.
-fn output(ui: &mut egui::Ui, state: &AppState, palette: &Palette) {
+/// Parts, format and rate, in one glass capsule on the right. Returns its width.
+fn capsule(
+ ui: &mut egui::Ui,
+ row: Rect,
+ state: &AppState,
+ palette: &Palette,
+ brushes: &Brushes,
+) -> f32 {
let settings = Arc::clone(&state.settings);
let current = settings.get();
let pinned = settings.pinned();
-
- ui.add_enabled_ui(!pinned.rate, |ui| {
- egui::ComboBox::from_id_salt("output-rate")
- .selected_text(egui::RichText::new(rate_label(current.rate)).size(TEXT))
- .width(RATE_WIDTH)
- .height(MENU_CAP)
- .show_ui(ui, |ui| {
- borderless_rows(ui);
- // Only the rates the chosen format can carry. Offering 96 kHz
- // beside MP3 would be offering something that does not exist:
- // picking it used to cost eighteen seconds a stem and produce a
- // 48 kHz file anyway.
- for rate in current.format.rates() {
- if ui
- .selectable_label(current.rate == rate, rate_label(rate))
- .on_hover_text(rate_hint(rate))
- .clicked()
- {
- settings.set_rate(rate);
- }
- }
- });
- })
- .response
- .on_disabled_hover_text(PINNED_HINT);
-
- ui.add_enabled_ui(!pinned.format, |ui| {
- egui::ComboBox::from_id_salt("output-format")
- .selected_text(egui::RichText::new(current.format.label()).size(TEXT))
- .width(FORMAT_WIDTH)
- .height(MENU_CAP)
- .show_ui(ui, |ui| {
- borderless_rows(ui);
- for format in StemFormat::ALL {
- if ui
- .selectable_label(current.format == format, format.label())
- .on_hover_text(format_hint(format))
- .clicked()
- {
- settings.set_format(format);
- }
- }
- });
- })
- .response
- .on_disabled_hover_text(PINNED_HINT);
-
- let split_costs = state.switcher.current() == Some(Preset::Balanced);
- drop_parts(ui, &settings, current.drop_parts, split_costs, palette);
+ let count = current.drop_parts.names().count();
+ let items = [
+ (
+ Menu::Parts,
+ if count == 1 {
+ "1 part".to_owned()
+ } else {
+ format!("{count} parts")
+ },
+ (13.0, 10.0),
+ false,
+ ),
+ (
+ Menu::Format,
+ match current.format {
+ // The menu says "float"; the capsule has no room for it.
+ StemFormat::Pcm32 => "PCM 32-bit".to_owned(),
+ format => format.label().to_owned(),
+ },
+ (12.0, 10.0),
+ pinned.format,
+ ),
+ (
+ Menu::Rate,
+ rate_label(current.rate),
+ (12.0, 11.0),
+ pinned.rate,
+ ),
+ ];
+
+ let font = theme::font(TEXT, Weight::Regular);
+ let widths: Vec = items
+ .iter()
+ .map(|(_, label, (left, right), _)| {
+ let text = ui
+ .painter()
+ .layout_no_wrap(label.clone(), font.clone(), palette.text)
+ .size()
+ .x;
+ left + text + 6.0 + CHEVRON + right
+ })
+ .collect();
+ // Two hairline dividers and a point of padding at each end.
+ let width = widths.iter().sum::() + 2.0 + 2.0;
+ let rect = Rect::from_min_size(pos2(row.right() - width, row.top()), vec2(width, HEIGHT));
+ brushes.glass(ui.painter(), rect, HEIGHT / 2.0, Glass::Panel);
+
+ let mut x = rect.left() + 1.0;
+ for (n, ((menu, label, (pad_left, _), disabled), item_width)) in
+ items.into_iter().zip(widths).enumerate()
+ {
+ if n > 0 {
+ ui.painter().line_segment(
+ [
+ pos2(x + 0.5, rect.center().y - 7.0),
+ pos2(x + 0.5, rect.center().y + 7.0),
+ ],
+ Stroke::new(1.0, palette.divider),
+ );
+ x += 1.0;
+ }
+ let button = Rect::from_min_size(
+ pos2(x, rect.center().y - CAP_HEIGHT / 2.0),
+ vec2(item_width, CAP_HEIGHT),
+ );
+ x += item_width;
+ let id = ui.id().with(("capsule", n));
+ let response = ui.interact(
+ button,
+ id,
+ if disabled {
+ Sense::hover()
+ } else {
+ Sense::click()
+ },
+ );
+ let open = widgets::menu(
+ &response,
+ if menu == Menu::Rate {
+ egui::RectAlign::BOTTOM_END
+ } else {
+ egui::RectAlign::BOTTOM_START
+ },
+ if menu == Menu::Parts {
+ egui::PopupCloseBehavior::CloseOnClickOutside
+ } else {
+ egui::PopupCloseBehavior::CloseOnClick
+ },
+ palette,
+ |ui| match menu {
+ Menu::Parts => parts_menu(ui, &settings, current.drop_parts, state, palette),
+ Menu::Format => format_menu(ui, &settings, current.format, palette),
+ Menu::Rate => rate_menu(ui, &settings, current.format, current.rate, palette),
+ },
+ );
+ cap_button(
+ ui, button, &response, &label, pad_left, open, disabled, palette, brushes,
+ );
+ if disabled {
+ response.on_hover_text(PINNED_HINT);
+ } else if !open {
+ response
+ .on_hover_cursor(egui::CursorIcon::PointingHand)
+ .on_hover_text(match menu {
+ Menu::Parts => format!(
+ "What a dropped track writes: {}.",
+ current.drop_parts.names().collect::>().join(", ")
+ ),
+ Menu::Format => format_hint(current.format).to_owned(),
+ Menu::Rate => rate_hint(current.rate).to_owned(),
+ });
+ }
+ }
+ width
}
-/// Shown on `bass` and `other` while Balanced is loaded.
-const SPLIT_COST: &str = "On Balanced, bass and other need one more model: about 1.5x the time.";
-
-/// The same, short enough for one line in the parts menu.
-const SPLIT_NOTE: &str = "bass/other: about 1.5x the time";
+/// One capsule button: label and chevron, a faint hover, the light pill while
+/// its menu is open.
+#[allow(clippy::too_many_arguments)]
+fn cap_button(
+ ui: &egui::Ui,
+ rect: Rect,
+ response: &egui::Response,
+ label: &str,
+ pad_left: f32,
+ open: bool,
+ disabled: bool,
+ palette: &Palette,
+ brushes: &Brushes,
+) {
+ let painter = ui.painter();
+ let hover = ui.ctx().animate_bool_with_time(
+ response.id.with("hover"),
+ response.hovered() && !disabled,
+ 0.15,
+ );
+ let lit = ui
+ .ctx()
+ .animate_bool_with_time(response.id.with("open"), open, 0.15);
+ if hover > 0.0 && lit < 1.0 {
+ painter.rect_filled(rect, CAP_HEIGHT / 2.0, white(0.09 * hover * (1.0 - lit)));
+ }
+ if lit > 0.0 {
+ brushes.pill(painter, rect, CAP_HEIGHT / 2.0, lit);
+ }
+ let alpha = if disabled { 0.45 } else { 1.0 };
+ let text_colour = theme::mix(palette.text, palette.ink, lit).gamma_multiply(alpha);
+ let weight = if open {
+ Weight::Semibold
+ } else {
+ Weight::Regular
+ };
+ let galley = painter.layout_no_wrap(label.to_owned(), theme::font(TEXT, weight), text_colour);
+ let text_width = galley.size().x;
+ let y = rect.center().y - galley.size().y / 2.0;
+ painter.galley(pos2(rect.left() + pad_left, y), galley, text_colour);
+ let chevron_colour = theme::mix(palette.muted, palette.ink, lit).gamma_multiply(alpha);
+ icons::chevron(
+ painter,
+ icons::square(
+ pos2(
+ rect.left() + pad_left + text_width + 6.0 + CHEVRON / 2.0,
+ rect.center().y,
+ ),
+ CHEVRON,
+ ),
+ chevron_colour,
+ 1.6,
+ std::f32::consts::PI * lit,
+ );
+}
/// Which parts a dropped track writes, as ticks. Requests over HTTP name their
/// own parts and are not affected.
-fn drop_parts(
+fn parts_menu(
ui: &mut egui::Ui,
- settings: &crate::settings::SettingsStore,
+ settings: &SettingsStore,
current: DropParts,
- split_costs: bool,
+ state: &AppState,
palette: &Palette,
) {
- let count = current.names().count();
- let costly = split_costs && (current.has("bass") || current.has("other"));
- let mark = if costly { SLOW_MARK } else { "" };
- egui::ComboBox::from_id_salt("drop-parts")
- .selected_text(egui::RichText::new(format!("{count} parts{mark}")).size(TEXT))
- .width(PARTS_WIDTH)
- .height(MENU_CAP)
- .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
- .show_ui(ui, |ui| {
- borderless_rows(ui);
- for part in PART_NAMES {
- let slow = split_costs && matches!(part, "bass" | "other");
- let label = if slow {
- format!("{part}{SLOW_MARK}")
- } else {
- part.to_owned()
- };
- let mut on = current.has(part);
- if ui
- .checkbox(&mut on, label)
- .on_hover_text(if slow { SPLIT_COST } else { part_hint(part) })
- .clicked()
- {
- settings.set_drop_parts(current.toggle(part));
- }
- }
- if costly {
- ui.separator();
- // One line, no wrap: a wrapped label inside the menu's scroll
- // area measures short and gets clipped.
- ui.add(
- egui::Label::new(
- egui::RichText::new(format!("{} {SPLIT_NOTE}", SLOW_MARK.trim()))
- .size(10.5)
- .color(palette.warn),
- )
- .extend(),
- );
- }
- })
- .response
- .on_hover_text(format!(
- "What a dropped track writes: {}.{}",
- current.names().collect::>().join(", "),
- if costly {
- format!(" {SPLIT_COST}")
- } else {
- String::new()
- }
- ));
-}
-
-fn part_hint(part: &str) -> &'static str {
- match part {
- "harmonics" => "bass and other together; clears bass and other",
- "bass" | "other" => "split out of harmonics; clears harmonics",
- "drums" => "rebuilt as the mix minus the other parts",
- _ => "",
- }
-}
-
-/// The download / load dialog. Only drawn while a switch is in flight or has
-/// failed, so there is nothing to dismiss in the common case.
-pub fn switch_dialog(ctx: &egui::Context, state: &AppState, palette: &Palette) {
- let switcher = Arc::clone(&state.switcher);
- let switch = switcher.state();
- if matches!(switch, SwitchState::Idle) {
- return;
+ ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
+ let split_costs = state.switcher.current() == Some(Preset::Balanced);
+ let width = widgets::row_width(236.0);
+ for part in PART_NAMES {
+ let (rect, response) = menu_row(ui, width, 34.0, ui.id().with(("part", part)));
+ let on = current.has(part);
+ let painter = ui.painter();
+ let tick = Rect::from_min_size(
+ pos2(rect.left() + 8.0, rect.center().y - 7.0),
+ vec2(14.0, 14.0),
+ );
+ if on {
+ painter.rect_filled(tick, 4.0, palette.band);
+ icons::check(painter, tick.shrink(2.5), palette.ink, 2.0);
+ } else {
+ painter.rect_stroke(
+ tick,
+ 4.0,
+ Stroke::new(1.0, white(0.3)),
+ egui::StrokeKind::Inside,
+ );
+ }
+ painter.text(
+ pos2(tick.right() + 10.0, rect.center().y),
+ egui::Align2::LEFT_CENTER,
+ part,
+ theme::font(TEXT, Weight::Regular),
+ palette.text,
+ );
+ let slow = split_costs && matches!(part, "bass" | "other");
+ let hint = match part {
+ "harmonics" => "bass + other",
+ _ if slow => "1.5x time",
+ _ => "",
+ };
+ painter.text(
+ pos2(rect.right() - 8.0, rect.center().y),
+ egui::Align2::RIGHT_CENTER,
+ hint,
+ theme::font(11.5, Weight::Regular),
+ palette.muted,
+ );
+ if response
+ .on_hover_text(if slow { SPLIT_COST } else { part_hint(part) })
+ .clicked()
+ {
+ settings.set_drop_parts(current.toggle(part));
+ }
}
-
- // `Loading` covers two situations that look nothing alike to whoever is
- // waiting: the worker building the model, which takes seconds, and the
- // worker still separating a track, which takes as long as the track. The
- // state cannot tell them apart, because from the switch's side both are the
- // same blocked call. The queue can.
- let separating = state.queue.running().is_some();
-
- egui::Window::new("Switching model")
- .collapsible(false)
- .resizable(false)
- .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
- .show(ctx, |ui| {
- ui.add_space(4.0);
- dialog_body(ui, &switch, &switcher, palette, separating);
- ui.add_space(4.0);
- });
}
-/// The download dialog: a bar, and underneath it what is being fetched and how far
-/// along.
-///
-/// The counts sit below the bar rather than inside it: egui draws a progress bar's
-/// text over a six-point bar and clips it, and a bar tall enough to hold text
-/// would not be the hairline the rest of this window uses.
-pub(super) fn downloading(
+fn format_menu(
ui: &mut egui::Ui,
- preset: Preset,
- file: &str,
- done: u64,
- total: u64,
+ settings: &SettingsStore,
+ current: StemFormat,
palette: &Palette,
) {
- ui.label(format!("Downloading the {} model", preset.label()));
- ui.label(
- egui::RichText::new(preset.detail())
- .size(11.0)
- .color(palette.muted),
- );
- ui.add_space(8.0);
- progress(ui, file, done, total, palette);
-}
-
-/// A download's bar, with the file name and the byte count underneath it.
-pub(super) fn progress(ui: &mut egui::Ui, file: &str, done: u64, total: u64, palette: &Palette) {
- let fraction = if total > 0 {
- done as f32 / total as f32
- } else {
- 0.0
- };
- ui.add(
- egui::ProgressBar::new(fraction)
- .desired_width(DIALOG_WIDTH)
- .desired_height(6.0)
- .corner_radius(3)
- .fill(palette.accent),
- );
- ui.add_space(4.0);
- ui.horizontal(|ui| {
- let small = |text: String| egui::RichText::new(text).size(10.5).color(palette.muted);
- ui.label(small(file.to_owned()));
- ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- ui.label(small(format!(
- "{} · {:.0} / {:.0} MB",
- super::status::percent(fraction),
- done as f64 / 1e6,
- total as f64 / 1e6
- )));
- });
- });
+ let labels: Vec<_> = StemFormat::ALL
+ .iter()
+ .map(|f| (f.label().to_owned(), *f == current, format_hint(*f)))
+ .collect();
+ if let Some(n) = pick_menu(ui, 170.0, &labels, palette) {
+ settings.set_format(StemFormat::ALL[n]);
+ }
}
-fn dialog_body(
+/// Only the rates the chosen format can carry: 96 kHz beside MP3 would be a
+/// choice that produces a 48 kHz file anyway.
+fn rate_menu(
ui: &mut egui::Ui,
- state: &SwitchState,
- switcher: &Switcher,
+ settings: &SettingsStore,
+ format: StemFormat,
+ current: OutputRate,
palette: &Palette,
- separating: bool,
) {
- match state {
- SwitchState::Downloading {
- preset,
- file,
- done,
- total,
- } => downloading(ui, *preset, file, *done, *total, palette),
- SwitchState::Verifying(preset) => {
- ui.label(format!("Checking the {} model", preset.label()));
- ui.add_space(8.0);
- ui.spinner();
- }
- // Waiting for the track in flight, not loading anything. Saying
- // "Loading onto the GPU" through a six-minute separation is a dialog
- // that looks wedged, next to a progress bar that is still moving, and
- // invites someone to force-quit a working program.
- SwitchState::Loading(preset) if separating => {
- ui.label(format!(
- "{} will load when the track in flight finishes",
- preset.label()
- ));
- ui.add_space(8.0);
- ui.spinner();
- }
- SwitchState::Loading(preset) => {
- ui.label(format!("Loading {} onto the GPU", preset.label()));
- ui.add_space(8.0);
- ui.spinner();
- }
- SwitchState::Failed { preset, message } => {
- ui.label(
- egui::RichText::new(format!("Could not switch to {}", preset.label()))
- .color(palette.bad),
- );
- ui.add_space(6.0);
- ui.label(egui::RichText::new(message).size(11.0));
- ui.add_space(8.0);
- // The old model is untouched, so this is dismissable rather than
- // fatal.
- if ui.button("Keep current model").clicked() {
- switcher.dismiss_error();
- }
- }
- SwitchState::Idle => {}
+ let rates: Vec = format.rates().collect();
+ let labels: Vec<_> = rates
+ .iter()
+ .map(|r| (rate_label(*r), *r == current, rate_hint(*r)))
+ .collect();
+ if let Some(n) = pick_menu(ui, 140.0, &labels, palette) {
+ settings.set_rate(rates[n]);
}
}
-/// One dropdown row: the preset name plus any marks.
-fn entry_for(switcher: &Switcher, preset: Preset) -> String {
- let mut entry = preset.label().to_owned();
- if !switcher.is_local(preset) {
- entry.push_str(DOWNLOAD_MARK);
+/// Shown on `bass` and `other` while Balanced is loaded.
+const SPLIT_COST: &str = "On Balanced, bass and other need one more model: about 1.5x the time.";
+
+fn part_hint(part: &str) -> &'static str {
+ match part {
+ "harmonics" => "bass and other together; clears bass and other",
+ "bass" | "other" => "split out of harmonics; clears harmonics",
+ "drums" => "rebuilt as the mix minus the other parts",
+ _ => "",
}
- entry
}
-/// Hover text for a dropdown row, warning about a download before it starts.
+/// Hover text for a segment, warning about a download before it starts.
fn hint_for(switcher: &Switcher, preset: Preset) -> String {
let mut hint = preset.detail().to_owned();
if !switcher.is_local(preset) {
hint.push_str(&format!(
- "\n\nnot installed — choosing this downloads {:.0} MB",
+ "\n\nNot installed. Choosing this downloads {:.0} MB.",
preset.total_bytes() as f64 / 1e6
));
}
hint
}
-/// A rate as a person reads it. `Display` gives the number the API takes, which
-/// is the right answer in a URL and the wrong one in a menu.
+/// A rate as a person reads it. `Display` gives the number the API takes.
fn rate_label(rate: OutputRate) -> String {
match rate.hz() {
44_100 => "44.1 kHz".to_owned(),
@@ -448,19 +501,19 @@ fn rate_hint(rate: OutputRate) -> &'static str {
OutputRate::Hz24000 => "Half the bandwidth, half the size.",
OutputRate::Hz44100 => "Model's native rate, no conversion.",
OutputRate::Hz48000 => "Converted from 44.1 kHz.",
- OutputRate::Hz96000 => "Converted from 44.1 kHz. Twice the size without quality improvment",
+ OutputRate::Hz96000 => "Converted from 44.1 kHz. Twice the size with no quality gain.",
}
}
fn format_hint(format: StemFormat) -> &'static str {
match format {
- StemFormat::Flac => "Lossless, about half size of a WAV, and openable everywhere.",
+ StemFormat::Flac => "Lossless, about half the size of a WAV, and opens everywhere.",
StemFormat::Wav => "The same 16-bit samples as FLAC, uncompressed. For anything fussy.",
- StemFormat::Mp3 => "320 kbps lossy, The outputs cannot be summed back",
+ StemFormat::Mp3 => "320 kbps lossy. The outputs cannot be summed back.",
StemFormat::Pcm16 => "Raw 16-bit samples: no container, no decode step.",
StemFormat::Pcm32 => {
"Raw floats. Twice the bytes, no ceiling at full scale, and the \
- only route to an exact null."
+ only route to an exact null."
}
}
}
@@ -469,27 +522,6 @@ fn format_hint(format: StemFormat) -> &'static str {
mod tests {
use super::*;
- /// Characters checked by hand against the cmap and glyf tables of egui's
- /// bundled fonts. Extend this only after checking a new one the same way.
- const COVERED_BY_BUNDLED_FONTS: [char; 3] = ['⬇', '⚠', '·'];
-
- /// Every non-ASCII mark in the window must exist in one of egui's three
- /// bundled fonts. A missing glyph draws nothing at all rather than a
- /// placeholder, so it fails silently: U+25CF and U+2913 both did.
- #[test]
- fn the_menu_marks_are_glyphs_the_bundled_fonts_have() {
- for mark in [DOWNLOAD_MARK, SLOW_MARK] {
- for c in mark.chars().filter(|c| !c.is_whitespace()) {
- assert!(
- COVERED_BY_BUNDLED_FONTS.contains(&c),
- "{c:?} (U+{:04X}) is not known to render; check it against \
- Ubuntu-Light, NotoEmoji and emoji-icon-font before using it",
- c as u32
- );
- }
- }
- }
-
#[test]
fn a_rate_reads_as_kilohertz_including_the_awkward_one() {
assert_eq!(rate_label(OutputRate::Hz44100), "44.1 kHz");
diff --git a/crates/stemd-server/src/ui/icons.rs b/crates/stemd-server/src/ui/icons.rs
new file mode 100644
index 0000000..e2ccb6a
--- /dev/null
+++ b/crates/stemd-server/src/ui/icons.rs
@@ -0,0 +1,131 @@
+//! Stroke icons, painted from SVG paths in a 10 by 10 box.
+//!
+//! Painted because a glyph missing from the bundled fonts draws nothing at all.
+
+use eframe::egui::{self, Color32, Pos2, Rect, Stroke, vec2};
+
+/// Draw `paths`, given in a `viewbox`-unit square, into `rect`.
+fn paths(
+ painter: &egui::Painter,
+ rect: Rect,
+ viewbox: f32,
+ paths: &[&[(f32, f32)]],
+ stroke: Stroke,
+) {
+ let k = rect.width().min(rect.height()) / viewbox;
+ let origin = rect.center() - vec2(viewbox, viewbox) * k / 2.0;
+ for path in paths {
+ let points: Vec = path
+ .iter()
+ .map(|(x, y)| origin + vec2(*x, *y) * k)
+ .collect();
+ painter.add(egui::Shape::line(points.clone(), stroke));
+ // Round caps, as `stroke-linecap: round`.
+ for end in [points[0], points[points.len() - 1]] {
+ painter.circle_filled(end, stroke.width / 2.0, stroke.color);
+ }
+ }
+}
+
+/// `M2.5 6.4 5 8.8l4.5-5.4` in a 12-unit box.
+pub fn check(painter: &egui::Painter, rect: Rect, colour: Color32, width: f32) {
+ paths(
+ painter,
+ rect,
+ 12.0,
+ &[&[(2.5, 6.4), (5.0, 8.8), (9.5, 3.4)]],
+ Stroke::new(width, colour),
+ );
+}
+
+/// An arrow into a tray, in a 10-unit box.
+pub fn download(painter: &egui::Painter, rect: Rect, colour: Color32, width: f32) {
+ paths(
+ painter,
+ rect,
+ 10.0,
+ &[
+ &[(5.0, 1.0), (5.0, 6.5)],
+ &[(2.8, 4.5), (5.0, 6.7), (7.2, 4.5)],
+ &[(1.5, 9.0), (8.5, 9.0)],
+ ],
+ Stroke::new(width, colour),
+ );
+}
+
+/// A chevron pointing down, turned by `turn` radians clockwise.
+pub fn chevron(painter: &egui::Painter, rect: Rect, colour: Color32, width: f32, turn: f32) {
+ let (s, c) = turn.sin_cos();
+ let rotate = |x: f32, y: f32| {
+ let (dx, dy) = (x - 5.0, y - 5.0);
+ (5.0 + dx * c - dy * s, 5.0 + dx * s + dy * c)
+ };
+ paths(
+ painter,
+ rect,
+ 10.0,
+ &[&[rotate(2.0, 3.5), rotate(5.0, 6.5), rotate(8.0, 3.5)]],
+ Stroke::new(width, colour),
+ );
+}
+
+/// A cross, in a 10-unit box.
+pub fn cross(painter: &egui::Painter, rect: Rect, colour: Color32, width: f32) {
+ paths(
+ painter,
+ rect,
+ 10.0,
+ &[&[(2.0, 2.0), (8.0, 8.0)], &[(8.0, 2.0), (2.0, 8.0)]],
+ Stroke::new(width, colour),
+ );
+}
+
+/// Three lines, the last short: the log.
+pub fn lines(painter: &egui::Painter, rect: Rect, colour: Color32, width: f32) {
+ paths(
+ painter,
+ rect,
+ 14.0,
+ &[
+ &[(2.5, 3.8), (11.5, 3.8)],
+ &[(2.5, 7.0), (11.5, 7.0)],
+ &[(2.5, 10.2), (8.0, 10.2)],
+ ],
+ Stroke::new(width, colour),
+ );
+}
+
+/// A clock face: waiting.
+pub fn clock(painter: &egui::Painter, rect: Rect, colour: Color32, width: f32) {
+ let k = rect.width().min(rect.height()) / 12.0;
+ painter.circle_stroke(rect.center(), 4.6 * k, Stroke::new(width, colour));
+ let origin = rect.center() - vec2(6.0, 6.0) * k;
+ paths(
+ painter,
+ Rect::from_min_size(origin, vec2(12.0, 12.0) * k),
+ 12.0,
+ &[&[(6.0, 3.6), (6.0, 6.0), (7.6, 7.2)]],
+ Stroke::new(width, colour),
+ );
+}
+
+/// A faint ring with a quarter arc running round it, `turn` in revolutions.
+pub fn spinner(painter: &egui::Painter, rect: Rect, colour: Color32, width: f32, turn: f32) {
+ let k = rect.width().min(rect.height()) / 12.0;
+ let r = 4.6 * k;
+ let centre = rect.center();
+ painter.circle_stroke(centre, r, Stroke::new(width, super::theme::white(0.16)));
+ let start = -std::f32::consts::FRAC_PI_2 + turn * std::f32::consts::TAU;
+ let points: Vec = (0..=12)
+ .map(|i| {
+ let a = start + std::f32::consts::FRAC_PI_2 * i as f32 / 12.0;
+ centre + r * vec2(a.cos(), a.sin())
+ })
+ .collect();
+ painter.add(egui::Shape::line(points, Stroke::new(width, colour)));
+}
+
+/// A square of `size` centred on `centre`.
+pub fn square(centre: Pos2, size: f32) -> Rect {
+ Rect::from_center_size(centre, vec2(size, size))
+}
diff --git a/crates/stemd-server/src/ui/logs.rs b/crates/stemd-server/src/ui/logs.rs
index 752686b..4e6e39a 100644
--- a/crates/stemd-server/src/ui/logs.rs
+++ b/crates/stemd-server/src/ui/logs.rs
@@ -5,22 +5,24 @@
//! path. Collapsed by default: it is where you go when something looks wrong,
//! not what the window is for.
-use eframe::egui;
+use eframe::egui::{self, Color32, Sense, Stroke, pos2, vec2};
use crate::logbuf::LogLine;
-use super::header::MENU_CAP;
-use super::theme::Palette;
+use super::icons;
+use super::theme::{self, Brushes, Palette, Weight, white};
+use super::widgets::{self, Pill};
/// How tall the log area gets before it scrolls.
pub const VISIBLE_HEIGHT: f32 = 168.0;
-/// Log rows are set much tighter than the rest of the window on purpose. This is
-/// a tail you scan rather than read, and the useful thing is how many lines fit
-/// at once; the window's ordinary spacing turns twenty lines into six.
+/// Log rows are set tight: this is a tail you scan, and the useful thing is how
+/// many lines fit at once.
const ROW: f32 = 8.0;
-const ROW_SPACING: f32 = 0.0;
-const CONTROL_TEXT: f32 = 11.0;
+const CONTROL_TEXT: f32 = 12.0;
+const CONTROL_HEIGHT: f32 = 26.0;
+/// Most recent matching lines drawn. Older ones stay in the buffer and the API.
+const SHOWN: usize = 400;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LevelFilter {
@@ -51,17 +53,18 @@ impl LevelFilter {
/// What the window remembers about how the log is being read.
pub struct View {
- pub follow: bool,
pub min_level: LevelFilter,
pub filter: String,
+ /// Layout pass the pane was last drawn in, to tell when it has just opened.
+ drawn_at: u64,
}
impl Default for View {
fn default() -> Self {
Self {
- follow: true,
min_level: LevelFilter::Info,
filter: String::new(),
+ drawn_at: 0,
}
}
}
@@ -69,90 +72,138 @@ impl Default for View {
/// Draw the log section. Returns true if the buffer should be emptied: the
/// caller owns it, so the button reports rather than acts.
#[must_use]
-pub fn show(ui: &mut egui::Ui, palette: &Palette, view: &mut View, lines: &[LogLine]) -> bool {
- let mut clear = false;
+pub fn show(
+ ui: &mut egui::Ui,
+ palette: &Palette,
+ brushes: &Brushes,
+ view: &mut View,
+ lines: &[LogLine],
+) -> bool {
+ let pass = ui.ctx().cumulative_pass_nr();
+ let opened = view.drawn_at + 1 != pass;
+ view.drawn_at = pass;
+ let mut clear = false;
ui.horizontal(|ui| {
- egui::ComboBox::from_id_salt("log-level")
- .selected_text(egui::RichText::new(view.min_level.label()).size(CONTROL_TEXT))
- .width(104.0)
- // Far above what three items need, so the list cannot scroll.
- .height(MENU_CAP)
- .show_ui(ui, |ui| {
- super::header::borderless_rows(ui);
- for level in LevelFilter::ALL {
- ui.selectable_value(&mut view.min_level, level, level.label());
- }
- });
-
+ level_menu(ui, palette, view);
ui.add(
egui::TextEdit::singleline(&mut view.filter)
.hint_text("filter")
- .font(egui::FontId::proportional(CONTROL_TEXT))
- .desired_width(ui.available_width() - 96.0),
+ .font(theme::font(CONTROL_TEXT, Weight::Regular))
+ .margin(vec2(10.0, 5.0))
+ .desired_width(ui.available_width() - 70.0),
);
-
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- clear = ui
- .add_enabled(
- !lines.is_empty(),
- egui::Button::new(egui::RichText::new("Clear").size(CONTROL_TEXT)),
- )
- .on_hover_text("Clears the logs")
- .clicked();
- ui.checkbox(
- &mut view.follow,
- egui::RichText::new("follow").size(CONTROL_TEXT),
- );
+ let (rect, _) = ui.allocate_exact_size(vec2(56.0, CONTROL_HEIGHT), Sense::hover());
+ clear = widgets::pill(
+ ui,
+ rect,
+ ui.id().with("clear-logs"),
+ "Clear",
+ Pill::Ghost,
+ !lines.is_empty(),
+ palette,
+ brushes,
+ )
+ .on_hover_text("Clears the logs")
+ .clicked();
});
});
ui.add_space(4.0);
let visible = visible_lines(view, lines);
- // Measured from the font, not assumed from the point size. `show_rows` uses
- // this both to decide which rows are on screen and to tell the scroll area
- // how tall its contents are, so a guess that is a point or two under leaves
- // the area sized for less than it draws, which is the gap that appears
- // under a short log.
let font = egui::FontId::monospace(ROW);
- let row_height = ui.ctx().fonts_mut(|fonts| fonts.row_height(&font));
- // Scoped, because the row spacing has to be set on the ui `show_rows` is called
- // on, not on the one it hands back. `show_rows` adds `item_spacing.y` to the row
- // height given here and from that decides how tall the contents are and which
- // rows are on screen, so setting it inside the closure is too late.
- ui.scope(|ui| {
- ui.spacing_mut().item_spacing = egui::vec2(6.0, ROW_SPACING);
- // Without this every line is at least `interact_size.y` tall: 24
- // points, set for buttons, and a 9.5-point line sits in the middle of
- // it. That is where the density went.
- ui.spacing_mut().interact_size.y = 0.0;
- egui::ScrollArea::vertical()
- .stick_to_bottom(view.follow)
- .max_height(VISIBLE_HEIGHT)
- // Shrinks to the lines it has, up to the cap. A fixed height leaves
- // an empty box under a short log, which reads as broken.
- .auto_shrink([false, true])
- .show_rows(ui, row_height, visible.len(), |ui, range| {
- for line in &visible[range] {
- ui.horizontal(|ui| {
- ui.label(
- egui::RichText::new(format!("{:<5}", line.level))
- .font(font.clone())
- .color(level_colour(palette, &line.level)),
- );
- ui.label(
+ egui::ScrollArea::vertical()
+ // Follows new lines while scrolled to the bottom, and stops while
+ // scrolled up.
+ .stick_to_bottom(true)
+ .max_height(VISIBLE_HEIGHT)
+ // Shrinks to the lines it has, up to the cap.
+ .auto_shrink([false, true])
+ .show(ui, |ui| {
+ ui.spacing_mut().item_spacing = vec2(6.0, 0.0);
+ ui.spacing_mut().interact_size.y = 0.0;
+ let skip = visible.len().saturating_sub(SHOWN);
+ for line in &visible[skip..] {
+ ui.horizontal_top(|ui| {
+ ui.label(
+ egui::RichText::new(format!("{:<5}", line.level))
+ .font(font.clone())
+ .color(level_colour(palette, &line.level)),
+ );
+ ui.add(
+ egui::Label::new(
egui::RichText::new(&line.message)
.font(font.clone())
.color(palette.text),
- );
- });
- }
- });
- });
+ )
+ .wrap(),
+ );
+ });
+ }
+ if opened {
+ ui.scroll_to_cursor(Some(egui::Align::BOTTOM));
+ }
+ });
clear
}
+/// The level floor as a pill button with a frosted menu.
+fn level_menu(ui: &mut egui::Ui, palette: &Palette, view: &mut View) {
+ let font = theme::font(CONTROL_TEXT, Weight::Medium);
+ let label = view.min_level.label();
+ let text = ui
+ .painter()
+ .layout_no_wrap(label.to_owned(), font.clone(), palette.text)
+ .size()
+ .x;
+ let (rect, response) = ui.allocate_exact_size(
+ vec2(12.0 + text + 6.0 + 8.0 + 10.0, CONTROL_HEIGHT),
+ Sense::click(),
+ );
+ let open = widgets::menu(
+ &response,
+ egui::RectAlign::BOTTOM_START,
+ egui::PopupCloseBehavior::CloseOnClick,
+ palette,
+ |ui| {
+ let labels: Vec<_> = LevelFilter::ALL
+ .iter()
+ .map(|l| (l.label().to_owned(), *l == view.min_level, ""))
+ .collect();
+ if let Some(n) = widgets::pick_menu(ui, 150.0, &labels, palette) {
+ view.min_level = LevelFilter::ALL[n];
+ }
+ },
+ );
+ let hover = widgets::fade(ui, response.id, response.hovered() || open);
+ let painter = ui.painter();
+ let r = CONTROL_HEIGHT / 2.0;
+ painter.rect_filled(rect, r, white(0.06 + 0.06 * hover));
+ painter.rect_stroke(
+ rect,
+ r,
+ Stroke::new(1.0, white(0.12)),
+ egui::StrokeKind::Inside,
+ );
+ painter.text(
+ pos2(rect.left() + 12.0, rect.center().y),
+ egui::Align2::LEFT_CENTER,
+ label,
+ font,
+ palette.text,
+ );
+ icons::chevron(
+ painter,
+ icons::square(pos2(rect.right() - 10.0 - 4.0, rect.center().y), 8.0),
+ palette.muted,
+ 1.6,
+ if open { std::f32::consts::PI } else { 0.0 },
+ );
+ response.on_hover_cursor(egui::CursorIcon::PointingHand);
+}
+
/// Buffered lines passing the level and text filters.
fn visible_lines(view: &View, lines: &[LogLine]) -> Vec {
let needle = view.filter.to_lowercase();
@@ -168,11 +219,11 @@ fn visible_lines(view: &View, lines: &[LogLine]) -> Vec {
.collect()
}
-fn level_colour(palette: &Palette, level: &str) -> egui::Color32 {
+fn level_colour(palette: &Palette, level: &str) -> Color32 {
match level {
- "ERROR" => palette.bad,
- "WARN" => palette.warn,
- "INFO" => palette.accent,
+ "ERROR" => Color32::from_rgb(0xFF, 0x45, 0x3A),
+ "WARN" => Color32::from_rgb(0xFF, 0x9F, 0x0A),
+ "INFO" => Color32::from_rgb(0x0A, 0x84, 0xFF),
_ => palette.muted,
}
}
@@ -226,7 +277,7 @@ mod tests {
let view = View {
min_level: LevelFilter::Warn,
filter: "queued".to_owned(),
- follow: true,
+ ..Default::default()
};
assert_eq!(visible_lines(&view, &lines).len(), 1);
}
diff --git a/crates/stemd-server/src/ui/mod.rs b/crates/stemd-server/src/ui/mod.rs
index e43c41b..06d1846 100644
--- a/crates/stemd-server/src/ui/mod.rs
+++ b/crates/stemd-server/src/ui/mod.rs
@@ -1,18 +1,21 @@
//! The window: drop a track on it, and watch the server behind it.
//!
//! ```text
-//! header which model, and what the stems come out as
-//! dropzone the main region: drop target, and progress while it works
+//! header the model, and what the stems come out as
+//! dropzone the globe, the drop target, and progress while it works
//! recents what has been separated, and where the stems went
//! logs the same lines GET /v1/logs serves
-//! status server state, along the bottom
+//! status server state and the cache, along the bottom
//! ```
//!
//! A drop and a `POST /v1/jobs` become the same job in the same queue; see
//! [`crate::drops`]. The server runs headless without any of this.
+mod blur;
mod dropzone;
+mod globe;
mod header;
+mod icons;
mod logs;
#[cfg(test)]
mod probe;
@@ -22,6 +25,8 @@ mod section;
mod starting;
mod status;
mod theme;
+mod titlebar;
+mod widgets;
use std::net::SocketAddr;
use std::sync::Arc;
@@ -29,20 +34,48 @@ use std::sync::Arc;
use eframe::egui;
use crate::api::AppState;
-use crate::drops::{AUDIO_EXTENSIONS, Dropped, looks_like_audio};
+use crate::drops::{AUDIO_EXTENSIONS, Dropped, State, looks_like_audio};
use crate::launch::Launch;
+use crate::models::Preset;
+use crate::switch::SwitchState;
+
+use theme::{Brushes, Glass, Palette};
+
+const WINDOW_SIZE: [f32; 2] = [560.0, 600.0 + titlebar::HEIGHT];
+const MIN_WINDOW_SIZE: [f32; 2] = [480.0, 520.0 + titlebar::HEIGHT];
+
+/// Height the drop zone keeps; the Separated list and the log take the rest.
+const ZONE_HEIGHT: f32 = 320.0;
+
+/// The zone's height in a window `content` points tall: [`ZONE_HEIGHT`], less
+/// when the window is too short to also show one row of the list.
+fn zone_height(content: f32) -> f32 {
+ // Header, status bar and their margins; the zone's and the sections' gaps;
+ // the "Separated" heading and one row.
+ let around = f32::from(TOP)
+ + header::HEIGHT
+ + status::HEIGHT
+ + f32::from(PAD)
+ + 3.0 * f32::from(GAP)
+ + section::HEIGHT
+ + 6.0
+ + recents::ROW_HEIGHT;
+ (content - around).clamp(dropzone::MIN_HEIGHT, ZONE_HEIGHT)
+}
-use theme::{Palette, Sheen};
+/// Space around the window's contents, and between its regions.
+const PAD: i8 = 14;
-/// Small on purpose: this is a drop target that reports, not a console. It grows
-/// to fit if both sections are opened, and the minimum still shows a whole drop
-/// zone.
-const WINDOW_SIZE: [f32; 2] = [520.0, 470.0];
-const MIN_WINDOW_SIZE: [f32; 2] = [480.0, 360.0];
-const PAD: f32 = 12.0;
+/// Space above the header, clearing the title bar where the window draws its own.
+const TOP: i8 = if titlebar::HEIGHT > 0.0 {
+ titlebar::HEIGHT as i8 + 6
+} else {
+ PAD
+};
+const GAP: i8 = 10;
-/// Nothing here is event-driven, the server writes from other threads, so the
-/// window polls at a rate that looks live without spinning a core.
+/// Poll interval at rest, for state written by other threads. While anything
+/// moves, the window repaints every frame.
const REPAINT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(250);
/// The window's own icon, compiled in.
@@ -50,9 +83,6 @@ const REPAINT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(2
/// Two of them: macOS icons are a rounded body inset in a transparent square,
/// Windows icons are the square. A bundled `.app` takes its icon from
/// `Contents/Resources/stemd.icns`, but the app is not always bundled.
-///
-/// A PNG rather than raw pixels: eframe already depends on `image`, and 256 is the
-/// largest size anything asks of a window icon.
#[cfg(target_os = "macos")]
const WINDOW_ICON: &[u8] = include_bytes!("../../../../resources/stemd-icon-mac-256.png");
#[cfg(not(target_os = "macos"))]
@@ -62,7 +92,21 @@ pub fn run(launch: Arc, bind: SocketAddr) -> Result<(), eframe::Error> {
let mut viewport = egui::ViewportBuilder::default()
.with_inner_size(WINDOW_SIZE)
.with_min_inner_size(MIN_WINDOW_SIZE)
- .with_title("stemd");
+ .with_title("Stemd");
+ // The backdrop runs under the title bar, which keeps only its buttons; see
+ // `titlebar`.
+ #[cfg(target_os = "macos")]
+ {
+ viewport = viewport
+ .with_fullsize_content_view(true)
+ .with_titlebar_shown(false)
+ .with_title_shown(false);
+ }
+ // No system frame: `titlebar` draws the bar, its buttons and the resize grips.
+ #[cfg(windows)]
+ {
+ viewport = viewport.with_decorations(false);
+ }
// A window that will not decode its own icon is a window without one, not
// a reason to refuse to open.
@@ -71,9 +115,17 @@ pub fn run(launch: Arc, bind: SocketAddr) -> Result<(), eframe::Error> {
Err(err) => tracing::warn!("the window icon will not decode ({err}); running without one"),
}
+ // eframe saves the window's position and size here, and puts it back on a
+ // monitor if that one is gone. Centred until there is a saved position.
+ let saved_in = crate::models::support_dir()
+ .ok()
+ .map(|dir| dir.join("window.ron"));
+ let first_launch = saved_in.as_ref().is_none_or(|file| !file.exists());
#[allow(unused_mut)]
let mut options = eframe::NativeOptions {
viewport,
+ centered: first_launch,
+ persistence_path: saved_in,
..Default::default()
};
@@ -91,13 +143,15 @@ pub fn run(launch: Arc, bind: SocketAddr) -> Result<(), eframe::Error> {
}
eframe::run_native(
- "stemd",
+ "Stemd",
options,
Box::new(move |cc| {
theme::install(&cc.egui_ctx);
Ok(Box::new(App {
launch,
bind,
+ brushes: Brushes::new(&cc.egui_ctx),
+ first_run: starting::View::default(),
window: None,
}))
}),
@@ -108,6 +162,8 @@ pub fn run(launch: Arc, bind: SocketAddr) -> Result<(), eframe::Error> {
struct App {
launch: Arc,
bind: SocketAddr,
+ brushes: Brushes,
+ first_run: starting::View,
window: Option,
}
@@ -116,12 +172,38 @@ impl eframe::App for App {
if self.window.is_none()
&& let Some(state) = self.launch.ready()
{
- self.window = Some(Window::new(Arc::clone(state), self.bind, ui.ctx()));
+ let motion = self.first_run.take_motion();
+ let globe = self.first_run.globe;
+ self.window = Some(Window::new(Arc::clone(state), self.bind, motion, globe));
+ self.first_run.globe_handed_off = true;
}
match &mut self.window {
- Some(window) => window.ui(ui),
- None => starting::show(ui, &self.launch),
+ Some(window) => {
+ window.ui(ui, &self.brushes);
+ // The startup screen fades out over the window as it fades in.
+ if let (Some(t), Some(stage)) = (window.reveal(), self.first_run.last.clone()) {
+ let ctx = ui.ctx().clone();
+ let mut fading =
+ ui.new_child(egui::UiBuilder::new().max_rect(ctx.content_rect()));
+ fading.multiply_opacity(1.0 - t);
+ let palette = Palette::of(&ctx);
+ let _ = starting::body(
+ &mut fading,
+ &stage,
+ &palette,
+ &self.brushes,
+ &mut self.first_run,
+ );
+ }
+ }
+ None => starting::show(ui, &self.launch, &mut self.first_run, &self.brushes),
}
+ titlebar::show(ui, &Palette::of(ui.ctx()));
+ }
+
+ /// eframe saves only the window's position and size.
+ fn persist_egui_memory(&self) -> bool {
+ false
}
/// The only shutdown the window ever gets.
@@ -137,144 +219,367 @@ impl eframe::App for App {
struct Window {
state: Arc,
bind: SocketAddr,
- sheen: Sheen,
logs: logs::View,
- /// Which sections are open. Held here rather than in egui's memory so the
- /// defaults are stated in one place.
- show_recents: bool,
+ /// Whether the log pane is open.
show_logs: bool,
- /// Height the sections panel had last frame, so a change can be handed to
- /// the window instead of taken out of the drop zone.
- sections_height: Option,
+ /// Height the log pane took last frame, 0 when closed. Opening it grows
+ /// the window by that much instead of squeezing the list.
+ logs_height: Option,
/// The drop the zone is currently showing. Held so the zone keeps showing
/// one track while a second is queued behind it.
current: Option>,
+ /// Drops since the window was last idle, for the per-track bars and the
+ /// count when they finish.
+ batch: Vec>,
+ /// Whether any drop in the batch was running last frame.
+ batch_running: bool,
+ /// Text shown under the title in place of the hint, and when it expires.
+ note: Option<(String, f64)>,
+ motion: globe::Motion,
+ /// The preset the header showed last frame, to fire its effect on a change.
+ shown: Option,
+ /// Least window width the header fits in, as last sent to the viewport.
+ min_width: f32,
/// Whether a close is being held for a question. See [`quit`].
quit: quit::Guard,
+ /// The hand-over from the startup screen, while it plays.
+ intro: Option,
+ /// Its eased progress this frame.
+ reveal: Option,
+}
+
+/// Where the startup screen's globe was, and when the hand-over began.
+struct Intro {
+ from: egui::Rect,
+ spread: f32,
+ start: Option,
}
+/// Seconds the hand-over from the startup screen takes.
+const INTRO_SECS: f32 = 0.8;
+
impl Window {
- fn new(state: Arc, bind: SocketAddr, ctx: &egui::Context) -> Self {
+ fn new(
+ state: Arc,
+ bind: SocketAddr,
+ motion: globe::Motion,
+ globe: Option<(egui::Rect, f32)>,
+ ) -> Self {
Self {
state,
bind,
- sheen: Sheen::new(ctx),
logs: logs::View::default(),
- show_recents: true,
show_logs: false,
- sections_height: None,
+ logs_height: None,
current: None,
+ batch: Vec::new(),
+ batch_running: false,
+ note: None,
+ motion,
+ shown: None,
+ min_width: 0.0,
quit: quit::Guard::default(),
+ intro: globe.map(|(from, spread)| Intro {
+ from,
+ spread,
+ start: None,
+ }),
+ reveal: None,
}
}
}
+/// Phase speed and seconds per meridian turn, by preset: Fast spins quick,
+/// Quality slow.
+const fn pace(preset: Option) -> (f32, f32) {
+ match preset {
+ Some(Preset::Fast) => (1.6, 1.6),
+ Some(Preset::Quality) => (0.6, 7.0),
+ Some(Preset::Balanced) | None => (1.0, 4.5),
+ }
+}
+
+const fn fx_for(preset: Preset) -> globe::Fx {
+ match preset {
+ Preset::Fast => globe::Fx::Fast,
+ Preset::Balanced => globe::Fx::Balanced,
+ Preset::Quality => globe::Fx::Quality,
+ }
+}
+
impl Window {
- fn ui(&mut self, ui: &mut egui::Ui) {
+ fn ui(&mut self, ui: &mut egui::Ui, brushes: &Brushes) {
let ctx = ui.ctx().clone();
- ctx.request_repaint_after(REPAINT_INTERVAL);
- // The appearance can change under a running window, and the style was
- // installed once at startup.
theme::install(&ctx);
let palette = Palette::of(&ctx);
+ let (dt, now) = (globe::frame_dt(&ctx), ctx.input(|i| i.time));
+ brushes.backdrop(ui.painter(), ctx.content_rect());
+
+ let reveal = self.advance_intro(&ctx, now);
self.take_drops(&ctx);
+ self.follow_batch(now);
// Before the rest of the frame: a held close should put its question up
// now rather than a frame later, and the answer can end the process.
- quit::guard(&mut self.quit, &self.state, &ctx, &palette);
+ quit::guard(&mut self.quit, &self.state, &ctx, &palette, brushes);
+
+ let shown = header::shown_preset(&self.state.switcher);
+ if shown != self.shown {
+ // A switch landed, after its download and check. A failed one falls
+ // back to the old model, which is not a landing.
+ let failed = matches!(self.state.switcher.state(), SwitchState::Failed { .. });
+ if let (Some(_), Some(preset), false) = (self.shown, shown, failed) {
+ let fx = fx_for(preset);
+ self.motion.fire(fx, now);
+ self.note = Some((preset.detail().to_owned(), now + f64::from(fx.duration())));
+ }
+ self.shown = shown;
+ }
+ let switch_state = self.state.switcher.state();
+ let running = self.state.queue.running();
+ theme::set_processing(&ctx, running.is_some());
+ let working = self.current.as_ref().is_some_and(|d| d.is_running());
+ let switching = match &switch_state {
+ SwitchState::Downloading { .. } | SwitchState::Verifying(_) => true,
+ SwitchState::Loading(_) => running.is_none(),
+ SwitchState::Idle | SwitchState::Failed { .. } => false,
+ };
+ let variant = running
+ .as_deref()
+ .map(globe::variant_of)
+ .or((working || switching).then_some(0));
+ let (speed, period) = pace(shown);
+ let drive = globe::Drive {
+ variant,
+ speed,
+ spin: variant.is_some().then_some(period),
+ };
+ if self.motion.step(dt, now, &drive) {
+ theme::request_frame(&ctx);
+ } else {
+ ctx.request_repaint_after(REPAINT_INTERVAL);
+ }
- egui::Panel::top("header").show(ui, |ui| {
- ui.add_space(6.0);
- header::row(ui, &self.state, &palette);
- ui.add_space(6.0);
- });
+ let margin = |top: i8, bottom: i8| egui::Margin {
+ left: PAD,
+ right: PAD,
+ top,
+ bottom,
+ };
- egui::Panel::bottom("status").show(ui, |ui| {
- status::bar(ui, &self.state, &palette, self.bind);
- });
+ egui::Panel::top("header")
+ .frame(egui::Frame::NONE.inner_margin(margin(TOP, 0)))
+ .show_separator_line(false)
+ .resizable(false)
+ .show(ui, |ui| {
+ if let Some(t) = reveal {
+ ui.multiply_opacity(t);
+ }
+ let needed = header::row(ui, &self.state, &palette, brushes, self.motion.clock);
+ self.fit_width(&ctx, needed + 2.0 * f32::from(PAD));
+ });
+
+ egui::Panel::bottom("status")
+ .frame(egui::Frame::NONE.inner_margin(margin(0, PAD)))
+ .show_separator_line(false)
+ .resizable(false)
+ .show(ui, |ui| {
+ if let Some(t) = reveal {
+ ui.multiply_opacity(t);
+ }
+ match status::bar(ui, &self.state, &palette, self.bind.port(), self.show_logs) {
+ Some(status::Action::ToggleLogs) => self.show_logs = !self.show_logs,
+ Some(status::Action::ToggleServer) => {
+ let on = !self.state.serving.is_on();
+ self.state.serving.set(on, &self.state.queue.info().model);
+ self.state.settings.set_serving(on);
+ // Clients get 503 from now on, so nobody could collect
+ // or cancel these.
+ if !on {
+ let cancelled = self.state.queue.cancel_api_jobs();
+ if cancelled > 0 {
+ tracing::info!("cancelled {cancelled} API job(s)");
+ let noun = if cancelled == 1 { "job" } else { "jobs" };
+ let until = ui.input(|i| i.time) + 4.0;
+ self.note =
+ Some((format!("Stopped {cancelled} client {noun}"), until));
+ }
+ }
+ }
+ None => {}
+ }
+ });
+
+ // The drop zone keeps a fixed height and the sections take the rest,
+ // so the ratio between them holds whatever the list holds.
+ let zone = zone_height(ctx.content_rect().height());
+ egui::Panel::top("zone")
+ .frame(egui::Frame::NONE.inner_margin(margin(GAP, 0)))
+ .exact_size(zone + f32::from(GAP))
+ .show_separator_line(false)
+ .resizable(false)
+ .show(ui, |ui| {
+ let armed = !ui.ctx().input(|i| i.raw.hovered_files.is_empty());
+ let note = self
+ .note
+ .as_ref()
+ .filter(|(_, until)| now < *until)
+ .map(|(text, _)| text.as_str());
+ let switch = match &switch_state {
+ SwitchState::Downloading {
+ preset,
+ file,
+ done,
+ total,
+ } => Some(dropzone::Switch::Downloading {
+ preset: *preset,
+ file,
+ done: *done,
+ total: *total,
+ }),
+ SwitchState::Verifying(preset) => Some(dropzone::Switch::Verifying(*preset)),
+ SwitchState::Loading(preset) => Some(dropzone::Switch::Loading {
+ preset: *preset,
+ waiting: running.is_some(),
+ }),
+ SwitchState::Failed { preset, message } => Some(dropzone::Switch::Failed {
+ preset: *preset,
+ message,
+ }),
+ SwitchState::Idle => None,
+ };
+ let action = dropzone::Zone {
+ palette: &palette,
+ brushes,
+ motion: &self.motion,
+ armed,
+ current: self.current.as_deref(),
+ parts: self.state.settings.get().drop_parts,
+ batch: dropzone::Batch {
+ finished: self.batch.iter().map(|d| !d.is_running()).collect(),
+ shown: self.current.as_ref().and_then(|current| {
+ self.batch.iter().position(|d| Arc::ptr_eq(d, current))
+ }),
+ },
+ note,
+ maxed: shown == Some(Preset::Quality),
+ switch,
+ intro: self
+ .intro
+ .as_ref()
+ .zip(reveal)
+ .map(|(intro, t)| dropzone::Intro {
+ from: intro.from,
+ spread: intro.spread,
+ t,
+ }),
+ }
+ .show(ui);
+ match action {
+ Some(dropzone::Action::ChooseFile) => self.pick_a_file(),
+ Some(dropzone::Action::KeepModel) => self.state.switcher.dismiss_error(),
+ None => {}
+ }
+ });
- // The sections have their own panel, and the window grows to fit it
- // rather than the drop zone shrinking to make room: opening the log to
- // read it should not shrink the thing you are dropping tracks on.
- let sections = egui::Panel::bottom("sections")
+ let logs_height = egui::CentralPanel::default()
+ .frame(egui::Frame::NONE.inner_margin(margin(GAP, GAP)))
.show(ui, |ui| {
- ui.add_space(2.0);
- self.sections(ui, &palette);
- ui.add_space(2.0);
+ if let Some(t) = reveal {
+ ui.multiply_opacity(t);
+ }
+ self.sections(ui, &palette, brushes)
})
- .response
- .rect
- .height();
- self.match_window_to(&ctx, sections);
-
- egui::CentralPanel::default().show(ui, |ui| {
- ui.add_space(PAD);
- let armed = !ui.ctx().input(|i| i.raw.hovered_files.is_empty());
- let clicked = dropzone::Zone {
- palette: &palette,
- sheen: &mut self.sheen,
- armed,
- current: self.current.as_deref(),
- parts: self.state.settings.get().drop_parts,
- }
- .show(ui);
- if clicked {
- self.pick_a_file();
- }
- ui.add_space(PAD);
- });
+ .inner;
+ self.match_window_to(&ctx, logs_height);
+ }
- header::switch_dialog(&ctx, &self.state, &palette);
+ /// Step the hand-over from the startup screen. Returns its eased progress
+ /// while it plays.
+ fn advance_intro(&mut self, ctx: &egui::Context, now: f64) -> Option {
+ let intro = self.intro.as_mut()?;
+ let start = *intro.start.get_or_insert(now);
+ let t = ((now - start) as f32 / INTRO_SECS).clamp(0.0, 1.0);
+ if t >= 1.0 {
+ self.intro = None;
+ self.reveal = None;
+ return None;
+ }
+ theme::request_frame(ctx);
+ self.reveal = Some(theme::ease_in_out(t));
+ self.reveal
}
-}
-impl Window {
- fn sections(&mut self, ui: &mut egui::Ui, palette: &Palette) {
+ /// Eased progress of the hand-over from the startup screen, while it plays.
+ const fn reveal(&self) -> Option {
+ self.reveal
+ }
+
+ /// Keep the window at least as wide as the header needs.
+ fn fit_width(&mut self, ctx: &egui::Context, needed: f32) {
+ let width = needed.max(MIN_WINDOW_SIZE[0]).ceil();
+ if (width - self.min_width).abs() < 1.0 {
+ return;
+ }
+ self.min_width = width;
+ ctx.send_viewport_cmd(egui::ViewportCommand::MinInnerSize(egui::vec2(
+ width,
+ MIN_WINDOW_SIZE[1],
+ )));
+ }
+
+ /// The Separated list and, when open, the log. Returns the log pane's
+ /// height, 0 when closed.
+ fn sections(&mut self, ui: &mut egui::Ui, palette: &Palette, brushes: &Brushes) -> f32 {
+ // The glass's drop shadow reaches past the panel; let it.
+ ui.set_clip_rect(ui.ctx().content_rect());
+ ui.spacing_mut().item_spacing = egui::vec2(0.0, 6.0);
let recent = self.state.drops.recent();
let count = (!recent.is_empty()).then(|| recent.len().to_string());
- section::show(
- ui,
- palette,
- "Separated",
- count,
- &mut self.show_recents,
- section::UNDER_LABEL,
- |ui| recents::list(ui, palette, &mut self.sheen, &self.state.drops, &recent),
- );
+ section::heading(ui, palette, "Separated", count);
+ // Room left once the log, as tall as it was last frame, is set aside.
+ let logs = if self.show_logs {
+ self.logs_height.unwrap_or(0.0) + 4.0 + 6.0
+ } else {
+ 0.0
+ };
+ let room = ui.available_height() - logs;
+ if let Some(item) = recents::list(ui, palette, brushes, &self.state.drops, &recent, room) {
+ self.state.drops.stop(&self.state, &item);
+ }
+ if !self.show_logs {
+ return 0.0;
+ }
+ ui.add_space(4.0);
+ let behind = ui.painter().add(egui::Shape::Noop);
let mut clear_logs = false;
- section::show(
- ui,
- palette,
- "Logs",
- None,
- &mut self.show_logs,
- section::FLUSH,
- |ui| {
- let lines = self.state.logs.recent(crate::logbuf::CAPACITY);
- clear_logs = logs::show(ui, palette, &mut self.logs, &lines);
- },
+ let frame = egui::Frame::NONE.inner_margin(10).show(ui, |ui| {
+ ui.spacing_mut().item_spacing = egui::vec2(8.0, 4.0);
+ let lines = self.state.logs.recent(crate::logbuf::CAPACITY);
+ clear_logs = logs::show(ui, palette, brushes, &mut self.logs, &lines);
+ });
+ ui.painter().set(
+ behind,
+ brushes.glass_shape(frame.response.rect, 16.0, Glass::List),
);
- // Not logged. Emptying the log and then writing to it is a joke at the
- // reader's expense, and the window has just shown the result.
+ // Not logged: emptying the log and then writing to it would undo the click.
if clear_logs {
self.state.logs.clear();
}
+ frame.response.rect.height()
}
- /// Grow or shrink the window by however much the sections panel changed.
- ///
- /// Driven by the panel's measured height rather than by the toggle, so it follows
- /// a section whose contents grew. The first frame only records the height.
- fn match_window_to(&mut self, ctx: &egui::Context, sections: f32) {
- let Some(previous) = self.sections_height.replace(sections) else {
+ /// Grow or shrink the window by however much the log pane changed, so
+ /// opening it does not squeeze the list. The first frame only records it.
+ fn match_window_to(&mut self, ctx: &egui::Context, logs: f32) {
+ let Some(previous) = self.logs_height.replace(logs) else {
return;
};
- let delta = sections - previous;
- // A threshold, not zero: heights wobble by a fraction of a point as text
- // is laid out, and a window that resizes by half a pixel every frame
- // never settles.
+ let delta = logs - previous;
+ // Heights wobble by a fraction of a point as text is laid out, and a window
+ // that resizes by half a pixel every frame never settles.
if delta.abs() < 1.0 {
return;
}
@@ -306,21 +611,64 @@ impl Window {
tracing::warn!("{} is not audio this can decode", path.display());
continue;
}
- self.current = Some(self.state.drops.accept(&self.state, path));
+ let dropped = self.state.drops.accept(&self.state, path);
+ if !self.current.as_ref().is_some_and(|d| d.is_running()) {
+ self.current = Some(dropped);
+ }
}
- // Once the shown track is finished, let the next running one take the
- // zone; the finished one is still in the list below.
- if self.current.as_ref().is_some_and(|d| !d.is_running()) {
+ // Once the shown track is finished, the next running one takes the zone,
+ // oldest first; the finished one is still in the list below.
+ if !self.current.as_ref().is_some_and(|d| d.is_running()) {
self.current = self
.state
.drops
.recent()
.into_iter()
+ .rev()
.find(|d| d.is_running());
}
}
+ /// Fold new drops into the batch and fire the start and finish effects.
+ ///
+ /// Reads the drop list directly, so a track chosen with the file picker on
+ /// another thread counts too.
+ fn follow_batch(&mut self, now: f64) {
+ let fresh: Vec> = self
+ .state
+ .drops
+ .recent()
+ .into_iter()
+ .rev()
+ .filter(|d| d.is_running() && !self.batch.iter().any(|b| Arc::ptr_eq(b, d)))
+ .collect();
+ if !fresh.is_empty() && !self.batch_running {
+ self.batch.clear();
+ self.note = None;
+ self.motion.fire(globe::Fx::Start, now);
+ }
+ self.batch.extend(fresh);
+
+ let running = self.batch.iter().any(|d| d.is_running());
+ if self.batch_running && !running {
+ let done = self
+ .batch
+ .iter()
+ .filter(|d| matches!(d.state(), State::Done(_)))
+ .count();
+ if done > 0 {
+ self.motion.fire(globe::Fx::Cheer, now);
+ let noun = if done == 1 { "track" } else { "tracks" };
+ self.note = Some((
+ format!("{done} {noun} separated"),
+ now + f64::from(globe::Fx::Cheer.duration()),
+ ));
+ }
+ }
+ self.batch_running = running;
+ }
+
/// The click-to-choose path, for a track that is not convenient to drag.
///
/// An in-process panel would need a crate and, on macOS, the main thread;
@@ -332,32 +680,42 @@ impl Window {
std::thread::Builder::new()
.name("stemd-picker".into())
.spawn(move || {
- let Some(path) = choose_file() else { return };
- // The same gate a drop goes through: the dialog can still be
- // typed into, and the two ways in should refuse the same files.
- if !looks_like_audio(&path) {
- tracing::warn!("{} is not audio this can decode", path.display());
- return;
+ for path in choose_files() {
+ // The same gate a drop goes through: the dialog can still be
+ // typed into, and the two ways in should refuse the same files.
+ if looks_like_audio(&path) {
+ drops.accept(&state, path);
+ } else {
+ tracing::warn!("{} is not audio this can decode", path.display());
+ }
}
- drops.accept(&state, path);
})
.expect("spawning the file picker");
}
}
-/// Ask the system for a file, blocking the thread it is called on.
+/// Ask the system for one or more files, blocking the thread it is called on.
///
/// Every platform's answer is the same shape: hand the job to a program that
-/// already owns a file panel and read a path off its stdout. `None` means
-/// cancelled, which all three report as a non-zero exit or empty output.
-fn choose_file() -> Option {
- let output = picker()?.output().ok()?;
+/// already owns a file panel and read one path per line off its stdout. Empty
+/// means cancelled, which all three report as a non-zero exit or empty output.
+fn choose_files() -> Vec {
+ let Some(output) = picker().and_then(|mut cmd| cmd.output().ok()) else {
+ return Vec::new();
+ };
if !output.status.success() {
- return None;
+ return Vec::new();
}
- let path = String::from_utf8(output.stdout).ok()?;
- let path = path.trim();
- (!path.is_empty()).then(|| std::path::PathBuf::from(path))
+ paths_of(&String::from_utf8_lossy(&output.stdout))
+}
+
+/// One path per non-empty line.
+fn paths_of(text: &str) -> Vec {
+ text.lines()
+ .map(str::trim)
+ .filter(|line| !line.is_empty())
+ .map(std::path::PathBuf::from)
+ .collect()
}
/// The type list is `public.audio` and every extension this accepts.
@@ -371,12 +729,27 @@ fn picker() -> Option {
.map(|t| format!("\"{t}\""))
.collect::>()
.join(", ");
- let script = format!(
- "POSIX path of (choose file with prompt \"Choose a track to separate\" \
- of type {{{types}}})"
+ let choose = format!(
+ "set picked to choose file with prompt \"Choose tracks to separate\" \
+ of type {{{types}}} with multiple selections allowed"
);
let mut cmd = std::process::Command::new("osascript");
- cmd.args(["-e", &script]);
+ cmd.args([
+ "-e",
+ &choose,
+ "-e",
+ "set text item delimiters to linefeed",
+ "-e",
+ "set paths to {}",
+ "-e",
+ "repeat with f in picked",
+ "-e",
+ "set end of paths to POSIX path of f",
+ "-e",
+ "end repeat",
+ "-e",
+ "return paths as text",
+ ]);
Some(cmd)
}
@@ -403,9 +776,10 @@ fn picker() -> Option {
let script = format!(
"Add-Type -AssemblyName System.Windows.Forms; \
$d = New-Object System.Windows.Forms.OpenFileDialog; \
- $d.Title = 'Choose a track to separate'; \
+ $d.Title = 'Choose tracks to separate'; \
+ $d.Multiselect = $true; \
$d.Filter = 'Audio|{patterns}|All files|*.*'; \
- if ($d.ShowDialog() -eq 'OK') {{ [Console]::Out.Write($d.FileName) }} else {{ exit 1 }}"
+ if ($d.ShowDialog() -eq 'OK') {{ [Console]::Out.Write($d.FileNames -join [char]10) }} else {{ exit 1 }}"
);
let mut cmd = std::process::Command::new("powershell");
cmd.args(["-NoProfile", "-STA", "-Command", &script])
@@ -429,7 +803,9 @@ fn picker() -> Option {
let mut cmd = std::process::Command::new("zenity");
cmd.args([
"--file-selection",
- "--title=Choose a track to separate",
+ "--multiple",
+ "--separator=\n",
+ "--title=Choose tracks to separate",
&format!("--file-filter=Audio | {patterns}"),
"--file-filter=All files | *",
]);
@@ -437,7 +813,13 @@ fn picker() -> Option {
}
if which("kdialog") {
let mut cmd = std::process::Command::new("kdialog");
- cmd.args(["--getopenfilename", ".", &format!("{patterns}|Audio")]);
+ cmd.args([
+ "--getopenfilename",
+ ".",
+ &format!("{patterns}|Audio"),
+ "--multiple",
+ "--separate-output",
+ ]);
return Some(cmd);
}
tracing::warn!("no zenity or kdialog to open a file panel with; drag a track in instead");
@@ -451,3 +833,23 @@ fn which(program: &str) -> bool {
.map(|path| std::env::split_paths(&path).any(|dir| dir.join(program).is_file()))
.unwrap_or(false)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_picker_answer_is_one_path_per_line() {
+ assert_eq!(
+ paths_of("/a/one.wav\n/b/two words.mp3\n\n"),
+ vec![
+ std::path::PathBuf::from("/a/one.wav"),
+ std::path::PathBuf::from("/b/two words.mp3"),
+ ]
+ );
+ assert!(
+ paths_of("").is_empty(),
+ "a cancelled panel picked something"
+ );
+ }
+}
diff --git a/crates/stemd-server/src/ui/probe.rs b/crates/stemd-server/src/ui/probe.rs
index c46ecee..fe56677 100644
--- a/crates/stemd-server/src/ui/probe.rs
+++ b/crates/stemd-server/src/ui/probe.rs
@@ -6,9 +6,21 @@
use eframe::egui;
-/// A 520x470 window, matching [`super::WINDOW_SIZE`].
+/// A 560x600 window, matching [`super::WINDOW_SIZE`].
fn screen() -> egui::Rect {
- egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(520.0, 470.0))
+ egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(560.0, 600.0))
+}
+
+/// A context with the window's fonts and style in place.
+///
+/// Fonts are bound at the start of the frame after they are set, and the
+/// weighted families do not exist before that, so this runs one empty frame.
+fn themed() -> egui::Context {
+ let ctx = egui::Context::default();
+ frame(&ctx, None, false, |_| {});
+ super::theme::install(&ctx);
+ frame(&ctx, None, false, |_| {});
+ ctx
}
/// Run one frame with the pointer at `pointer`, optionally clicking, and return
@@ -39,96 +51,6 @@ fn frame(
ctx.run_ui(input, |ui| build(ui)).shapes
}
-/// Rects narrow enough to be a scroll bar and tall enough to be worth drawing.
-///
-/// egui's floating bars are about a point wide and animate in and out, so this
-/// deliberately catches a partly-faded one: a bar on its way in is still a bar
-/// that appeared.
-fn scroll_bars(shapes: &[egui::epaint::ClippedShape]) -> Vec {
- fn walk(shape: &egui::epaint::Shape, out: &mut Vec) {
- match shape {
- egui::epaint::Shape::Rect(r) if r.rect.width() <= 12.0 && r.rect.height() >= 20.0 => {
- out.push(r.rect);
- }
- egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
- _ => {}
- }
- }
- let mut out = Vec::new();
- for clipped in shapes {
- walk(&clipped.shape, &mut out);
- }
- out
-}
-
-/// A dropdown built the way [`super::header`] builds its three.
-fn dropdown(ui: &mut egui::Ui, borderless: bool) {
- egui::ComboBox::from_id_salt("probe")
- .selected_text("MP3 320")
- .width(88.0)
- .height(super::header::MENU_CAP)
- .show_ui(ui, |ui| {
- if borderless {
- super::header::borderless_rows(ui);
- }
- for label in ["FLAC", "WAV", "MP3 320", "PCM 16", "PCM 32"] {
- ui.selectable_label(false, label)
- .on_hover_text("320 kbps, and the only lossy option.");
- }
- });
-}
-
-/// Open the dropdown, then walk the pointer down the open list, counting the
-/// positions at which a scroll bar is drawn.
-///
-/// A sweep rather than a hand-picked coordinate: row heights come out of the
-/// theme, so a guessed position lands between rows the moment the theme changes
-/// and the test silently stops testing anything.
-fn positions_growing_a_bar(borderless: bool) -> usize {
- let ctx = egui::Context::default();
- let build = |ui: &mut egui::Ui| dropdown(ui, borderless);
-
- // A frame before `install`, which reads the context's current theme.
- frame(&ctx, None, false, build);
- super::theme::install(&ctx);
- frame(&ctx, None, false, build);
-
- let button = egui::pos2(40.0, 10.0);
- frame(&ctx, Some(button), true, build);
- frame(&ctx, Some(button), false, build);
-
- (0..60u8)
- .filter(|step| {
- let at = egui::pos2(40.0, 4.0 * f32::from(*step));
- // Twice: egui settles a frame behind its own input.
- frame(&ctx, Some(at), false, build);
- !scroll_bars(&frame(&ctx, Some(at), false, build)).is_empty()
- })
- .count()
-}
-
-/// A list of five items in a box that caps at 600 points has nothing to scroll,
-/// and must not say otherwise.
-#[test]
-fn no_dropdown_grows_a_scroll_bar() {
- assert_eq!(
- positions_growing_a_bar(true),
- 0,
- "a dropdown grew a scroll bar with nothing to scroll to"
- );
-}
-
-/// The above passing means nothing unless it can fail. This is the same sweep
-/// over the same list with the row borders left on: the bug as it shipped.
-#[test]
-fn the_sweep_catches_the_bar_it_is_meant_to_catch() {
- assert!(
- positions_growing_a_bar(false) > 0,
- "the sweep found no bar even with the borders that caused it, so it is \
- not testing anything"
- );
-}
-
/// The vertical extent of every log line drawn, given `total` buffered lines.
fn log_text_extent(total: usize) -> egui::Rect {
use crate::logbuf::LogLine;
@@ -140,13 +62,12 @@ fn log_text_extent(total: usize) -> egui::Rect {
message: format!("line number {i} of the log"),
})
.collect();
- let ctx = egui::Context::default();
- frame(&ctx, None, false, |_| {});
- super::theme::install(&ctx);
+ let ctx = themed();
let palette = super::theme::Palette::of(&ctx);
+ let brushes = super::theme::Brushes::new(&ctx);
let mut view = super::logs::View::default();
let mut build = |ui: &mut egui::Ui| {
- let _ = super::logs::show(ui, &palette, &mut view, &lines);
+ let _ = super::logs::show(ui, &palette, &brushes, &mut view, &lines);
};
frame(&ctx, None, false, &mut build);
let shapes = frame(&ctx, None, false, &mut build);
@@ -163,11 +84,7 @@ fn log_text_extent(total: usize) -> egui::Rect {
}
/// A log with far more lines than fit must draw enough of them to fill the box.
-///
-/// `show_rows` decides how many rows to hand out by adding `item_spacing.y` to the
-/// row height it is given, so spacing set inside its closure comes too late and the
-/// rows draw short of the box. The tolerance is one row, not zero: the last row may
-/// be partly clipped.
+/// The tolerance is one row: the last row may be partly clipped.
#[test]
fn a_full_log_fills_the_box_it_is_given() {
let drawn = log_text_extent(200).height();
@@ -219,50 +136,51 @@ fn clipped_text(shapes: &[egui::epaint::ClippedShape]) -> Vec<(String, f32)> {
out
}
-/// The model-download dialog draws its byte count somewhere it fits.
-///
-/// It did not: the count was the progress bar's own text, and the bar is six
-/// points tall to match the hairlines elsewhere in the window, so egui clipped
-/// a fourteen-point string into it. The fix moves the count out of the bar,
-/// and this is what says it stayed out.
+/// A model download shows in the drop zone, with its byte count and figure,
+/// and offers no file button while it runs.
#[test]
-fn the_download_dialog_does_not_clip_its_own_text() {
- let ctx = egui::Context::default();
+fn a_model_download_shows_in_the_drop_zone() {
+ let ctx = themed();
let palette = super::theme::Palette::of(&ctx);
+ let brushes = super::theme::Brushes::new(&ctx);
+ let motion = super::globe::Motion::default();
- let shapes = frame(&ctx, None, false, |ui| {
- super::header::downloading(
- ui,
- crate::models::Preset::Quality,
- "bs_roformer_viperx.safetensors",
- 6_000_000,
- 639_109_056,
- &palette,
- );
- });
+ let mut build = |ui: &mut egui::Ui| {
+ let _ = super::dropzone::Zone {
+ palette: &palette,
+ brushes: &brushes,
+ motion: &motion,
+ armed: false,
+ current: None,
+ parts: crate::settings::DropParts::DEFAULT,
+ batch: super::dropzone::Batch::default(),
+ note: None,
+ maxed: false,
+ switch: Some(super::dropzone::Switch::Downloading {
+ preset: crate::models::Preset::Quality,
+ file: "stemd_roformer.safetensors",
+ done: 6_000_000,
+ total: 639_109_056,
+ }),
+ intro: None,
+ }
+ .show(ui);
+ };
+ frame(&ctx, None, false, &mut build);
+ let shapes = frame(&ctx, None, false, &mut build);
let clipped = clipped_text(&shapes);
+ assert!(clipped.is_empty(), "clipped text: {clipped:?}");
+ let drawn = drawn_text(&shapes);
+ for want in ["Downloading the Quality model", "639 MB", "0%"] {
+ assert!(
+ drawn.iter().any(|t| t.contains(want)),
+ "{want:?} is not drawn: {drawn:?}"
+ );
+ }
assert!(
- clipped.is_empty(),
- "text is drawn taller than the region it is clipped to: {clipped:?}"
- );
-
- // And the count is actually on screen, rather than absent and therefore
- // trivially unclipped.
- let drawn: Vec = shapes
- .iter()
- .filter_map(|c| match &c.shape {
- egui::epaint::Shape::Text(t) => Some(t.galley.text().to_owned()),
- _ => None,
- })
- .collect();
- assert!(
- drawn.iter().any(|t| t.contains("639 MB")),
- "the byte count is not drawn at all: {drawn:?}"
- );
- assert!(
- drawn.iter().any(|t| t.contains("0%")),
- "the percentage is not drawn at all: {drawn:?}"
+ !drawn.iter().any(|t| t == "Choose a file"),
+ "the file button shows during a download"
);
}
@@ -280,8 +198,10 @@ fn drawn_text(shapes: &[egui::epaint::ClippedShape]) -> Vec {
/// The first-run download shows its model, its progress, and fits.
#[test]
fn the_startup_download_shows_its_progress() {
- let ctx = egui::Context::default();
+ let ctx = themed();
let palette = super::theme::Palette::of(&ctx);
+ let brushes = super::theme::Brushes::new(&ctx);
+ let mut view = super::starting::View::default();
let stage = crate::launch::Stage::Downloading {
preset: crate::models::Preset::Fast,
file: "htdemucs.safetensors".into(),
@@ -290,7 +210,7 @@ fn the_startup_download_shows_its_progress() {
};
let shapes = frame(&ctx, None, false, |ui| {
- let _ = super::starting::body(ui, &stage, &palette);
+ let _ = super::starting::body(ui, &stage, &palette, &brushes, &mut view);
});
let clipped = clipped_text(&shapes);
@@ -304,16 +224,48 @@ fn the_startup_download_shows_its_progress() {
}
}
+/// Without the model rows, the globe and the text under it sit in the middle.
+#[test]
+fn checking_the_model_at_startup_is_centred() {
+ let ctx = themed();
+ let palette = super::theme::Palette::of(&ctx);
+ let brushes = super::theme::Brushes::new(&ctx);
+ let mut view = super::starting::View::default();
+ let stage = crate::launch::Stage::Verifying(crate::models::Preset::Quality);
+
+ let shapes = frame(&ctx, None, false, |ui| {
+ let _ = super::starting::body(ui, &stage, &palette, &brushes, &mut view);
+ });
+ let title = shapes
+ .iter()
+ .find_map(|c| match &c.shape {
+ egui::epaint::Shape::Text(t)
+ if t.galley.text() == "Checking downloaded Quality model" =>
+ {
+ Some(t.pos.y)
+ }
+ _ => None,
+ })
+ .expect("the title");
+ let height = screen().height();
+ assert!(
+ title > height * 0.45 && title < height * 0.65,
+ "the title sits at {title:.0} of {height:.0} points"
+ );
+}
+
/// A first run offers every preset with its download size, and a click picks one.
#[test]
fn a_first_run_offers_every_model_and_picks_the_clicked_one() {
- let ctx = egui::Context::default();
+ let ctx = themed();
let palette = super::theme::Palette::of(&ctx);
+ let brushes = super::theme::Brushes::new(&ctx);
+ let mut view = super::starting::View::default();
let stage = crate::launch::Stage::Choosing(vec![crate::models::Preset::Balanced]);
let mut picked = None;
let shapes = frame(&ctx, None, false, |ui| {
- picked = super::starting::body(ui, &stage, &palette);
+ picked = super::starting::body(ui, &stage, &palette, &brushes, &mut view);
});
assert_eq!(picked, None, "nothing was clicked");
let drawn = drawn_text(&shapes);
@@ -324,10 +276,7 @@ fn a_first_run_offers_every_model_and_picks_the_clicked_one() {
);
}
assert!(drawn.iter().any(|t| t == "installed"), "{drawn:?}");
- assert!(
- drawn.iter().any(|t| t.ends_with("MB download")),
- "{drawn:?}"
- );
+ assert!(drawn.iter().any(|t| t.ends_with(" MB")), "{drawn:?}");
// Click the middle of the first button, found by its label.
let at = shapes
@@ -341,10 +290,10 @@ fn a_first_run_offers_every_model_and_picks_the_clicked_one() {
.expect("the Fast button");
// A press and release need a frame with the pointer already there.
frame(&ctx, Some(at), false, |ui| {
- let _ = super::starting::body(ui, &stage, &palette);
+ let _ = super::starting::body(ui, &stage, &palette, &brushes, &mut view);
});
frame(&ctx, Some(at), true, |ui| {
- picked = super::starting::body(ui, &stage, &palette);
+ picked = super::starting::body(ui, &stage, &palette, &brushes, &mut view);
});
assert_eq!(picked, Some(crate::models::Preset::Fast));
}
@@ -352,16 +301,18 @@ fn a_first_run_offers_every_model_and_picks_the_clicked_one() {
/// A failed startup says why and offers a way out.
#[test]
fn a_failed_startup_shows_the_error_and_a_quit_button() {
- let ctx = egui::Context::default();
+ let ctx = themed();
let palette = super::theme::Palette::of(&ctx);
+ let brushes = super::theme::Brushes::new(&ctx);
+ let mut view = super::starting::View::default();
let stage = crate::launch::Stage::Failed("fetching the model: GET https://x: timed out".into());
let shapes = frame(&ctx, None, false, |ui| {
- let _ = super::starting::body(ui, &stage, &palette);
+ let _ = super::starting::body(ui, &stage, &palette, &brushes, &mut view);
});
let drawn = drawn_text(&shapes);
- for want in ["stemd could not start", "timed out", "Quit"] {
+ for want in ["Stemd could not start", "timed out", "Quit"] {
assert!(
drawn.iter().any(|t| t.contains(want)),
"{want:?} is not drawn: {drawn:?}"
@@ -398,70 +349,86 @@ fn the_percentage_beside_a_bar_is_honest_and_fits() {
}
}
-/// The horizontal extent of everything drawn, ignoring the backdrop egui paints
-/// across the whole panel.
-fn drawn_extent(shapes: &[egui::epaint::ClippedShape], within: egui::Rect) -> Option<(f32, f32)> {
- fn walk(shape: &egui::epaint::Shape, within: egui::Rect, out: &mut Vec) {
+/// Rectangles drawn, at any depth.
+fn rects(shapes: &[egui::epaint::ClippedShape]) -> Vec {
+ fn walk(shape: &egui::epaint::Shape, out: &mut Vec) {
match shape {
- egui::epaint::Shape::Rect(r) => {
- // Anything spanning the full width is the background, not
- // content, and would make every layout look centred.
- if r.rect.width() < within.width() - 1.0 && r.rect.width() > 0.0 {
- out.push(r.rect);
- }
- }
- egui::epaint::Shape::Text(t) => out.push(t.galley.rect.translate(t.pos.to_vec2())),
- egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| walk(s, within, out)),
+ egui::epaint::Shape::Rect(r) => out.push(r.rect),
+ egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
- let mut rects = Vec::new();
+ let mut out = Vec::new();
for clipped in shapes {
- walk(&clipped.shape, within, &mut rects);
+ walk(&clipped.shape, &mut out);
}
- let left = rects.iter().map(|r| r.left()).fold(f32::MAX, f32::min);
- let right = rects.iter().map(|r| r.right()).fold(f32::MIN, f32::max);
- (left <= right).then_some((left, right))
+ out
}
-/// The drop zone's bar and its figure sit centred, as one row.
+/// A working drop shows its title, its stage and a bar centred under them.
///
-/// Wrapping the pair in a plain `ui.horizontal` gives it the whole available width,
-/// so its contents start hard against the left edge while the title and caption
-/// stay centred. A test that only asks whether the text fits cannot see that.
+/// The bar and its figure are one row: centring the bar alone would push the row
+/// right by half the figure's width.
#[test]
-fn the_drop_zone_progress_row_is_centred() {
- let ctx = egui::Context::default();
+fn the_drop_zone_progress_bar_is_centred() {
+ let ctx = themed();
let palette = super::theme::Palette::of(&ctx);
+ let brushes = super::theme::Brushes::new(&ctx);
+ let motion = super::globe::Motion::default();
+ let item = crate::drops::Dropped::working(
+ "Bells Of Eternity",
+ stemd_core::Progress {
+ stage: stemd_core::Stage::Separating,
+ completed: 7,
+ total: 24,
+ fraction: 0.31,
+ detail: None,
+ },
+ );
- let shapes = frame(&ctx, None, false, |ui| {
- let width = ui.available_width();
- let mut centred = ui.new_child(
- egui::UiBuilder::new()
- .max_rect(egui::Rect::from_min_size(
- ui.min_rect().min,
- egui::vec2(width, 60.0),
- ))
- .layout(egui::Layout::top_down(egui::Align::Center)),
+ let mut zone_rect = egui::Rect::NOTHING;
+ let mut build = |ui: &mut egui::Ui| {
+ zone_rect = ui.available_rect_before_wrap();
+ let _ = super::dropzone::Zone {
+ palette: &palette,
+ brushes: &brushes,
+ motion: &motion,
+ armed: false,
+ current: Some(&item),
+ parts: crate::settings::DropParts::DEFAULT,
+ batch: super::dropzone::Batch::default(),
+ note: None,
+ maxed: false,
+ switch: None,
+ intro: None,
+ }
+ .show(ui);
+ };
+ frame(&ctx, None, false, &mut build);
+ let shapes = frame(&ctx, None, false, &mut build);
+
+ let drawn = drawn_text(&shapes);
+ for want in [
+ "Bells Of Eternity",
+ "separating · 7/24",
+ "31%",
+ "Queue another track",
+ ] {
+ assert!(
+ drawn.iter().any(|t| t == want),
+ "{want:?} is not drawn: {drawn:?}"
);
- super::dropzone::progress_row(&mut centred, Some(0.31), &palette);
- });
+ }
- let bounds = screen();
- let (left, right) = drawn_extent(&shapes, bounds).expect("the row drew something");
- let before = left - bounds.left();
- let after = bounds.right() - right;
-
- // The row is allocated at exactly its own width and centred by the enclosing
- // layout, so what is left is ink: the figure keeps a fixed 34-point box so the bar
- // does not twitch as the number goes from one digit to three.
- //
- // Measured: 79 and 85 with the row allocated, 0 and 164 without.
- let slack = super::dropzone::PERCENT_WIDTH / 2.0;
+ let track = rects(&shapes)
+ .into_iter()
+ .find(|r| (r.width() - 260.0).abs() < 0.5 && (r.height() - 8.0).abs() < 0.5)
+ .expect("the progress track");
+ let before = track.left() - zone_rect.left();
+ let after = zone_rect.right() - track.right();
assert!(
- (before - after).abs() <= slack,
- "the row is not centred: {before:.0} points of space on the left, \
- {after:.0} on the right"
+ (before - after).abs() <= 1.0,
+ "the bar is not centred: {before:.1} points on the left, {after:.1} on the right"
);
}
@@ -473,12 +440,12 @@ fn the_drop_zone_progress_row_is_centred() {
/// cannot be closed at all except by quitting the process another way.
#[test]
fn the_quit_confirmation_offers_both_answers() {
- let ctx = egui::Context::default();
- super::theme::install(&ctx);
+ let ctx = themed();
let palette = super::theme::Palette::of(&ctx);
+ let brushes = super::theme::Brushes::new(&ctx);
let shapes = frame(&ctx, None, false, |ui| {
- super::quit::dialog(ui, &palette, "Bells Of Eternity.flac");
+ super::quit::dialog(ui, &palette, &brushes, "Bells Of Eternity.flac");
});
let drawn: Vec = shapes
@@ -516,11 +483,9 @@ fn the_quit_confirmation_offers_both_answers() {
/// list that reports twelve rows tall grows the window whether or not it paints
/// them.
fn recents_height(rows: usize) -> f32 {
- let ctx = egui::Context::default();
- frame(&ctx, None, false, |_| {});
- super::theme::install(&ctx);
+ let ctx = themed();
let palette = super::theme::Palette::of(&ctx);
- let mut sheen = super::theme::Sheen::new(&ctx);
+ let brushes = super::theme::Brushes::new(&ctx);
let drops = crate::drops::Drops::default();
let dir = std::path::Path::new("/Music/track-stems");
@@ -531,7 +496,9 @@ fn recents_height(rows: usize) -> f32 {
let mut height = 0.0;
let mut build = |ui: &mut egui::Ui| {
height = ui
- .scope(|ui| super::recents::list(ui, &palette, &mut sheen, &drops, &items))
+ .scope(|ui| {
+ super::recents::list(ui, &palette, &brushes, &drops, &items, visible_cap());
+ })
.response
.rect
.height();
@@ -542,12 +509,7 @@ fn recents_height(rows: usize) -> f32 {
height
}
-/// A full list must stop growing at the cap.
-///
-/// It had no cap at all: every separated track added forty points to the
-/// sections panel, `match_window_to` handed each one to the window, and twelve
-/// drops, the most the list remembers, took a 470-point window past 1000 and
-/// off the bottom of the screen.
+/// A full list stops at the room it is given.
#[test]
fn a_long_separated_list_stops_at_the_cap() {
let drawn = recents_height(crate::drops::REMEMBERED);
@@ -558,7 +520,7 @@ fn a_long_separated_list_stops_at_the_cap() {
);
}
-/// And the other direction: two rows must not be stretched to fill the cap.
+/// And two rows are not stretched to fill it.
#[test]
fn a_short_separated_list_stays_short() {
let drawn = recents_height(2);
@@ -569,15 +531,105 @@ fn a_short_separated_list_stays_short() {
);
}
-/// The cap [`super::recents`] applies, read through the same theme the tests
-/// draw under.
+/// The room the list is given in these probes: three and a half rows.
fn visible_cap() -> f32 {
- let ctx = egui::Context::default();
- frame(&ctx, None, false, |_| {});
- super::theme::install(&ctx);
- let mut cap = 0.0;
- frame(&ctx, None, false, |ui| {
- cap = super::recents::visible_height(ui)
- });
- cap
+ super::recents::ROW_HEIGHT * 3.5
+}
+
+/// Every non-ASCII character the window draws as text has a glyph in the fonts
+/// it binds. A missing glyph draws nothing at all, so this fails silently on
+/// screen.
+#[test]
+fn the_marks_drawn_as_text_have_glyphs() {
+ use super::theme::{Weight, font};
+ let ctx = themed();
+ for weight in [Weight::Regular, Weight::Medium, Weight::Semibold] {
+ for c in ['·', '…'] {
+ let id = font(13.0, weight);
+ assert!(
+ ctx.fonts_mut(|f| f.has_glyph(&id, c)),
+ "{c:?} (U+{:04X}) has no glyph at {weight:?}",
+ c as u32
+ );
+ }
+ }
+}
+
+/// Milliseconds of CPU per frame for the drop zone, building and tessellating.
+///
+/// `cargo test --release -p stemd-server frame_cost -- --ignored --nocapture`
+#[test]
+#[ignore = "a measurement, run by hand"]
+fn frame_cost() {
+ let ctx = themed();
+ let palette = super::theme::Palette::of(&ctx);
+ let brushes = super::theme::Brushes::new(&ctx);
+ let item = crate::drops::Dropped::working(
+ "Bells Of Eternity",
+ stemd_core::Progress {
+ stage: stemd_core::Stage::Separating,
+ completed: 7,
+ total: 24,
+ fraction: 0.31,
+ detail: None,
+ },
+ );
+ for (name, working) in [("idle", false), ("working", true)] {
+ let mut motion = super::globe::Motion::default();
+ let drive = super::globe::Drive {
+ variant: working.then_some(2),
+ speed: 1.0,
+ spin: working.then_some(4.5),
+ };
+ let frames = 600;
+ let mut spent = std::time::Duration::ZERO;
+ for n in 0..frames {
+ let now = f64::from(n) / 60.0;
+ motion.step(1.0 / 60.0, now, &drive);
+ let input = egui::RawInput {
+ screen_rect: Some(screen()),
+ time: Some(now),
+ ..Default::default()
+ };
+ let start = std::time::Instant::now();
+ let output = ctx.run_ui(input, |ui| {
+ brushes.backdrop(ui.painter(), ui.max_rect());
+ let _ = super::dropzone::Zone {
+ palette: &palette,
+ brushes: &brushes,
+ motion: &motion,
+ armed: false,
+ current: working.then_some(&*item),
+ parts: crate::settings::DropParts::DEFAULT,
+ batch: super::dropzone::Batch::default(),
+ note: None,
+ maxed: true,
+ switch: None,
+ intro: None,
+ }
+ .show(ui);
+ });
+ let primitives = ctx.tessellate(output.shapes, output.pixels_per_point);
+ if n >= 60 {
+ spent += start.elapsed();
+ }
+ if n == frames - 1 {
+ let vertices: usize = primitives
+ .iter()
+ .map(|p| match &p.primitive {
+ egui::epaint::Primitive::Mesh(m) => m.vertices.len(),
+ egui::epaint::Primitive::Callback(_) => 0,
+ })
+ .sum();
+ println!(
+ "{name}: {} primitives, {vertices} vertices",
+ primitives.len()
+ );
+ }
+ }
+ println!(
+ "{name}: {:.3} ms per frame",
+ spent.as_secs_f64() * 1000.0 / f64::from(frames - 60)
+ );
+ }
}
diff --git a/crates/stemd-server/src/ui/quit.rs b/crates/stemd-server/src/ui/quit.rs
index 75939aa..01bbd85 100644
--- a/crates/stemd-server/src/ui/quit.rs
+++ b/crates/stemd-server/src/ui/quit.rs
@@ -17,7 +17,8 @@ use std::sync::Arc;
use eframe::egui;
use crate::api::AppState;
-use crate::ui::theme::Palette;
+use crate::ui::theme::{self, Brushes, Palette, Weight};
+use crate::ui::widgets::{self, Pill};
/// What the person chose, once they choose.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -80,31 +81,43 @@ impl Guard {
/// The question itself.
///
/// Takes the track's name rather than any state, so a probe can draw it.
-pub fn dialog(ui: &mut egui::Ui, palette: &Palette, track: &str) -> Option {
+pub fn dialog(
+ ui: &mut egui::Ui,
+ palette: &Palette,
+ brushes: &Brushes,
+ track: &str,
+) -> Option {
ui.set_max_width(320.0);
- ui.heading("Still separating");
+ ui.label(
+ egui::RichText::new("Still separating")
+ .font(theme::font(17.0, Weight::Semibold))
+ .color(palette.text),
+ );
ui.add_space(6.0);
ui.label(
egui::RichText::new(format!(
"{track} is not finished. Quitting now will cancel the current separation."
))
+ .font(theme::font(13.0, Weight::Regular))
.color(palette.muted),
);
- ui.add_space(14.0);
+ ui.add_space(16.0);
let mut choice = None;
ui.horizontal(|ui| {
- // The safe option first and focused, because this dialog appears when
- // someone is already leaving and the destructive answer is the one
- // muscle memory will hit.
- if ui.button("Keep separating").clicked() {
- choice = Some(Choice::Stay);
- }
- if ui
- .button(egui::RichText::new("Quit anyway").color(palette.bad))
- .clicked()
- {
- choice = Some(Choice::Quit);
+ // The safe option first, because this dialog appears when someone is
+ // already leaving and the destructive answer is the one muscle memory
+ // will hit.
+ for (label, kind, answer) in [
+ ("Keep separating", Pill::Light, Choice::Stay),
+ ("Quit anyway", Pill::Danger, Choice::Quit),
+ ] {
+ let width = widgets::pill_width(ui, label, 30.0);
+ let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 30.0), egui::Sense::hover());
+ let id = ui.id().with(("quit-answer", label));
+ if widgets::pill(ui, rect, id, label, kind, true, palette, brushes).clicked() {
+ choice = Some(answer);
+ }
}
});
choice
@@ -113,7 +126,13 @@ pub fn dialog(ui: &mut egui::Ui, palette: &Palette, track: &str) -> Option, ctx: &egui::Context, palette: &Palette) {
+pub fn guard(
+ guard: &mut Guard,
+ state: &Arc,
+ ctx: &egui::Context,
+ palette: &Palette,
+ brushes: &Brushes,
+) {
let closing = ctx.input(|i| i.viewport().close_requested());
if closing && !guard.intercept(state.queue.running(), true) {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
@@ -123,9 +142,13 @@ pub fn guard(guard: &mut Guard, state: &Arc, ctx: &egui::Context, pale
return;
};
let mut answered = None;
- egui::Modal::new(egui::Id::new("stemd-quit")).show(ctx, |ui| {
- answered = dialog(ui, palette, &track);
- });
+ egui::Modal::new(egui::Id::new("stemd-quit"))
+ .frame(egui::Frame::NONE)
+ .show(ctx, |ui| {
+ super::blur::frosted(ui, &widgets::dialog_look(palette), |ui| {
+ answered = dialog(ui, palette, brushes, &track);
+ });
+ });
if let Some(choice) = answered {
guard.answer(choice);
if choice == Choice::Quit {
diff --git a/crates/stemd-server/src/ui/recents.rs b/crates/stemd-server/src/ui/recents.rs
index dde3a5d..a24c600 100644
--- a/crates/stemd-server/src/ui/recents.rs
+++ b/crates/stemd-server/src/ui/recents.rs
@@ -1,197 +1,268 @@
//! The list of tracks this window has separated.
//!
-//! A finished row's only job is to get you to the stems, so the whole row opens the
-//! folder. Failures stay in the list with their reason rather than disappearing.
-//!
-//! Rows leave two ways: the cross removes one and deletes its stems, and a row
-//! whose folder has been deleted behind the window's back removes itself when
-//! anyone asks it to open that folder.
+//! One glass panel, a hairline between rows. A finished row opens its folder;
+//! hovering it swaps the detail for "Open folder" and a cross that removes the row
+//! and deletes its stems. On a running row the cross stops the drop. A row whose
+//! folder has been deleted removes itself when asked to open it.
use std::sync::Arc;
-use eframe::egui::{self, Color32, Stroke};
+use eframe::egui::{self, Rect, Sense, Stroke, pos2, vec2};
use crate::drops::{Dropped, Drops, State};
-use super::theme::{CARD_RADIUS, Palette, Sheen};
-
-const ROW_HEIGHT: f32 = 40.0;
-const DOT_RADIUS: f32 = 3.5;
+use super::icons;
+use super::theme::{self, Brushes, Glass, Palette, Weight, white};
-/// How many rows the list shows before it scrolls, in rows rather than points
-/// so it stays four of them if [`ROW_HEIGHT`] ever moves. The half is the point
-/// of it: a whole number of rows ends the list on a clean edge and looks like
-/// the whole list, while half a row showing under the fourth says there is more
-/// below. Without a cap the window grew by a row per track and twelve of them
-/// took it past the bottom of the screen.
-const VISIBLE_ROWS: f32 = 4.5;
+pub const ROW_HEIGHT: f32 = 40.0;
-/// How tall the list gets before it scrolls.
-///
-/// Read off the ui rather than stated as a constant because the gap between
-/// rows comes from the theme, and a cap that ignored it would be a row short of
-/// what it claims the moment the spacing moves.
-pub fn visible_height(ui: &egui::Ui) -> f32 {
- ROW_HEIGHT * VISIBLE_ROWS + ui.spacing().item_spacing.y * (VISIBLE_ROWS - 1.0)
-}
+const RADIUS: f32 = 16.0;
-/// Hit target for the delete control. Bigger than the cross it draws: a
-/// nine-point glyph is not something to ask anyone to hit.
-const CROSS_BOX: f32 = 22.0;
+/// Hit target and drawn box of the remove control.
+const CROSS_BOX: f32 = 26.0;
-/// Arm length of the painted cross, from its centre.
-const CROSS_ARM: f32 = 3.5;
+/// The list's height for `rows` rows in `max_height`, never under one row.
+pub fn height(rows: usize, max_height: f32) -> f32 {
+ (ROW_HEIGHT * rows as f32).min(max_height.max(ROW_HEIGHT))
+}
+/// Draw the list. Returns a running drop whose cross was clicked, for the
+/// caller to stop.
pub fn list(
ui: &mut egui::Ui,
palette: &Palette,
- sheen: &mut Sheen,
+ brushes: &Brushes,
drops: &Drops,
items: &[Arc],
-) {
+ max_height: f32,
+) -> Option> {
if items.is_empty() {
- ui.add_space(2.0);
- ui.label(
- egui::RichText::new("No separated tracks")
- .size(11.5)
- .color(palette.muted),
- );
- return;
+ placeholder(ui, palette, brushes, max_height);
+ return None;
}
- // The window sizes itself to this panel, so the cap here is what stops a
- // long list from resizing the window off the screen. `auto_shrink` on the
- // vertical keeps a short list the height of its own rows: a fixed box would
- // leave empty space under one row, which reads as a list that lost
- // something.
+ let height = height(items.len(), max_height);
+ let (panel, _) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover());
+ brushes.glass(ui.painter(), panel, RADIUS, Glass::List);
+
+ let mut inside = ui.new_child(
+ egui::UiBuilder::new()
+ .max_rect(panel)
+ .layout(egui::Layout::top_down(egui::Align::Min)),
+ );
+ inside.set_clip_rect(panel.intersect(ui.clip_rect()));
+ inside.spacing_mut().item_spacing = vec2(0.0, 0.0);
+ inside.spacing_mut().scroll = egui::style::ScrollStyle {
+ bar_width: 6.0,
+ ..egui::style::ScrollStyle::floating()
+ };
+ let now = ui.input(|i| i.time) as f32;
+ let mut stop = None;
egui::ScrollArea::vertical()
.id_salt("recents")
- .max_height(visible_height(ui))
+ .max_height(height)
.auto_shrink([false, true])
- .show(ui, |ui| {
- for item in items {
- row(ui, palette, sheen, drops, item);
+ .show(&mut inside, |ui| {
+ for (n, item) in items.iter().enumerate() {
+ if row(ui, palette, drops, item, n == 0, n + 1 == items.len(), now) {
+ stop = Some(Arc::clone(item));
+ }
}
});
+ stop
+}
+
+/// The empty list: the panel at three rows, or what fits, faded, with faint
+/// sample rows under one line of text.
+fn placeholder(ui: &mut egui::Ui, palette: &Palette, brushes: &Brushes, max_height: f32) {
+ let height = height(3, max_height);
+ let (panel, _) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover());
+ let mut painter = ui.painter().with_clip_rect(ui.clip_rect());
+ painter.multiply_opacity(0.5);
+ brushes.glass(&painter, panel, RADIUS, Glass::List);
+ painter.set_clip_rect(panel.intersect(ui.clip_rect()));
+
+ // Sample rows: a dot where the icon goes and bars for the title and detail,
+ // each row fainter than the one above. The bars stop short of the centred text.
+ let rows = (height / ROW_HEIGHT).floor().max(1.0) as usize;
+ let titles = [84.0, 64.0, 76.0];
+ for n in 0..rows {
+ let row = Rect::from_min_size(
+ panel.min + vec2(0.0, ROW_HEIGHT * n as f32),
+ vec2(panel.width(), ROW_HEIGHT),
+ );
+ let alpha = 1.0 - 0.3 * n as f32;
+ if n > 0 {
+ painter.line_segment(
+ [
+ row.left_top() + vec2(0.0, 0.5),
+ row.right_top() + vec2(0.0, 0.5),
+ ],
+ Stroke::new(1.0, palette.hairline.gamma_multiply(alpha)),
+ );
+ }
+ let ghost = white(0.05 * alpha);
+ let y = row.center().y;
+ painter.circle_filled(pos2(row.left() + 20.0, y), 6.0, ghost);
+ let title = Rect::from_min_size(
+ pos2(row.left() + 36.0, y - 4.0),
+ vec2(titles[n % titles.len()], 8.0),
+ );
+ painter.rect_filled(title, 4.0, ghost);
+ let detail = Rect::from_min_size(pos2(row.right() - 14.0 - 64.0, y - 3.0), vec2(64.0, 6.0));
+ painter.rect_filled(detail, 3.0, ghost);
+ }
+
+ painter.set_opacity(1.0);
+ let text = painter.layout_no_wrap(
+ "Separated tracks will appear here.".to_owned(),
+ theme::font(12.0, Weight::Regular),
+ palette.muted,
+ );
+ painter.galley(panel.center() - text.size() / 2.0, text, palette.muted);
}
fn row(
ui: &mut egui::Ui,
palette: &Palette,
- sheen: &mut Sheen,
drops: &Drops,
item: &Arc,
-) {
+ first: bool,
+ last: bool,
+ now: f32,
+) -> bool {
let state = item.state();
- let width = ui.available_width();
- let (rect, _) = ui.allocate_exact_size(egui::vec2(width, ROW_HEIGHT), egui::Sense::hover());
- let inner = rect.shrink2(egui::vec2(12.0, 6.0));
+ let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::hover());
- // Interacted by an id derived from the track rather than by allocation
- // order. An automatic id is a counter over the widgets before it, so it
- // shifts whenever the list above changes shape, and an id that changes
- // between frames cannot correlate a press with its release, which is
- // exactly a row that highlights on hover and does nothing when clicked.
- //
- // Keyed on the source path rather than the title: two different files can
- // share a title (different folders, same name), and `title` alone would
- // hand both rows the same id, colliding in exactly the way this comment
- // warns about.
+ // Keyed on the source path: an automatic id is a counter that shifts as the
+ // list changes, and an id that moves between frames cannot pair a press with
+ // its release. Two files can share a title, so not the title either.
let id = ui.id().with(("recent", &item.source));
- let response = ui.interact(rect, id.with("row"), egui::Sense::click());
-
- // Registered after the row and overlapping it, so egui hands it the pointer
- // first: the last widget to claim a spot is the one on top of it.
- let removable = !item.is_running();
- let cross_rect = egui::Rect::from_center_size(
- egui::pos2(inner.right() - CROSS_BOX / 2.0, inner.center().y),
- egui::Vec2::splat(CROSS_BOX),
- );
- let cross = removable.then(|| ui.interact(cross_rect, id.with("remove"), egui::Sense::click()));
+ let response = ui.interact(rect, id.with("row"), Sense::click());
let openable = matches!(state, State::Done(_));
- // The cross sits inside the row, so pointing at it takes the hover off the
- // row underneath. Without this the control would vanish as it was reached.
- let touched = response.hovered() || cross.as_ref().is_some_and(egui::Response::hovered);
- sheen.card(
- ui,
- rect,
- CARD_RADIUS,
- if touched && openable {
- palette.card_hover
- } else {
- palette.card
- },
- Stroke::new(1.0, palette.outline),
+ let running = item.is_running();
+ let cross_rect = Rect::from_center_size(
+ pos2(rect.right() - 8.0 - CROSS_BOX / 2.0, rect.center().y),
+ vec2(CROSS_BOX, CROSS_BOX),
);
+ // Registered after the row and on top of it, so it gets the pointer first.
+ let cross = ui.interact(cross_rect, id.with("remove"), Sense::click());
+ let touched = response.hovered() || cross.hovered();
- // Painted, not laid out as widgets. A `ui.label` inside the row registers its own
- // interaction rect on top of the row's, so egui hands the pointer to the label and
- // the row beneath stops seeing the hover: the whole card clickable except over its
- // own text. The painter takes no part in interaction.
let painter = ui.painter();
- painter.circle_filled(
- egui::pos2(inner.left() + DOT_RADIUS, inner.center().y),
- DOT_RADIUS,
- colour_for(palette, &state),
- );
+ let hover = ui
+ .ctx()
+ .animate_bool_with_time(id.with("hover"), touched, 0.12);
+ if hover > 0.0 {
+ let corners = egui::CornerRadius {
+ nw: if first { RADIUS as u8 - 1 } else { 0 },
+ ne: if first { RADIUS as u8 - 1 } else { 0 },
+ sw: if last { RADIUS as u8 - 1 } else { 0 },
+ se: if last { RADIUS as u8 - 1 } else { 0 },
+ };
+ painter.rect_filled(rect.shrink(1.0), corners, white(0.05 * hover));
+ }
+ if !first {
+ painter.line_segment(
+ [
+ rect.left_top() + vec2(0.0, 0.5),
+ rect.right_top() + vec2(0.0, 0.5),
+ ],
+ Stroke::new(1.0, palette.hairline),
+ );
+ }
- let text_left = inner.left() + DOT_RADIUS * 2.0 + 8.0;
- painter.text(
- egui::pos2(text_left, inner.center().y - 1.0),
- egui::Align2::LEFT_BOTTOM,
- &item.title,
- egui::FontId::proportional(12.5),
- palette.text,
- );
- painter.text(
- egui::pos2(text_left, inner.center().y + 2.0),
- egui::Align2::LEFT_TOP,
- detail(&state),
- egui::FontId::proportional(10.5),
- match &state {
- State::Failed(_) => palette.bad,
- _ => palette.muted,
- },
- );
+ // Painted: a `ui.label` registers its own interaction rect on top of the
+ // row's and takes the pointer from it.
+ let icon = icons::square(pos2(rect.left() + 14.0 + 6.0, rect.center().y), 12.0);
+ let waiting = matches!(&state, State::Working(p) if p.stage == stemd_core::Stage::Queued);
+ match &state {
+ State::Done(_) => icons::check(painter, icon, palette.good, 1.6),
+ State::Failed(_) => icons::cross(painter, icon.shrink(1.0), palette.danger, 1.5),
+ _ if waiting => icons::clock(painter, icon, palette.muted, 1.4),
+ State::Reading | State::Working(_) => {
+ icons::spinner(painter, icon, palette.text, 1.6, now.rem_euclid(1.0));
+ }
+ }
- if touched && openable {
- painter.text(
- egui::pos2(cross_rect.left() - 6.0, inner.center().y),
- egui::Align2::RIGHT_CENTER,
- "Show in Finder",
- egui::FontId::proportional(10.5),
- palette.accent,
+ let swapped = touched && openable;
+ let meta_font = theme::font(12.0, Weight::Regular);
+ let meta_right = if touched {
+ cross_rect.left() - 4.0 - 6.0
+ } else {
+ rect.right() - 8.0 - 6.0
+ };
+ let meta_left = if swapped {
+ let open =
+ painter.layout_no_wrap("Open folder".to_owned(), meta_font.clone(), palette.text);
+ let x = cross_rect.left() - 4.0 - 6.0 - open.size().x;
+ painter.galley(
+ pos2(x, rect.center().y - open.size().y / 2.0),
+ open,
+ palette.text,
);
- }
+ x
+ } else {
+ let colour = if matches!(state, State::Failed(_)) {
+ palette.danger
+ } else {
+ palette.muted
+ };
+ let mut job = egui::text::LayoutJob::single_section(
+ detail(&state),
+ egui::TextFormat::simple(meta_font, colour),
+ );
+ job.wrap = egui::text::TextWrapping::truncate_at_width(rect.width() * 0.45);
+ let meta = painter.layout_job(job);
+ let x = meta_right - meta.size().x;
+ painter.galley(pos2(x, rect.center().y - meta.size().y / 2.0), meta, colour);
+ x
+ };
- // Only under the pointer: twelve permanent crosses down the side of the list
- // would be the loudest thing in the window, and the least often wanted.
- if let Some(cross) = &cross
- && touched
- {
- draw_cross(
- ui,
- cross_rect.center(),
- if cross.hovered() {
- palette.bad
- } else {
- palette.muted
- },
+ let title_left = icon.right() + 10.0;
+ let mut job = egui::text::LayoutJob::single_section(
+ item.title.clone(),
+ egui::TextFormat::simple(theme::font(13.0, Weight::Medium), palette.text),
+ );
+ job.wrap =
+ egui::text::TextWrapping::truncate_at_width((meta_left - 10.0 - title_left).max(20.0));
+ let title = painter.layout_job(job);
+ painter.galley(
+ pos2(title_left, rect.center().y - title.size().y / 2.0),
+ title,
+ palette.text,
+ );
+
+ if touched {
+ let lit = ui
+ .ctx()
+ .animate_bool_with_time(id.with("cross"), cross.hovered(), 0.12);
+ if lit > 0.0 {
+ painter.rect_filled(cross_rect, 8.0, white(0.08 * lit));
+ }
+ icons::cross(
+ painter,
+ icons::square(cross_rect.center(), 9.0),
+ theme::mix(palette.muted, palette.danger, lit),
+ 1.5,
);
}
- if let Some(cross) = cross
- && cross
- .on_hover_cursor(egui::CursorIcon::PointingHand)
- .on_hover_text(match &state {
- State::Done(_) => "Remove this, and delete its stems",
- _ => "Remove this from the list",
- })
- .clicked()
+ if cross
+ .on_hover_cursor(egui::CursorIcon::PointingHand)
+ .on_hover_text(match &state {
+ State::Done(_) => "Remove from the list and delete the stems",
+ _ if running => "Cancel and remove from the list",
+ _ => "Remove from the list",
+ })
+ .clicked()
{
+ if running {
+ return true;
+ }
drops.discard(item);
- return;
+ return false;
}
if openable
@@ -200,9 +271,8 @@ fn row(
.clicked()
&& let State::Done(finished) = &state
{
- // Deleted behind the window's back: in Finder, or by the cross on a
- // second copy of the same track. Opening nothing and saying nothing is
- // the one outcome that reads as broken, so the row goes instead.
+ // Deleted behind the window's back. Opening nothing and saying nothing
+ // reads as broken, so the row goes instead.
if finished.dir.is_dir() {
reveal(&finished.dir);
} else {
@@ -210,57 +280,31 @@ fn row(
drops.forget(item);
}
}
-}
-
-/// A cross, painted rather than typed, for the same reason the drop zone's arrow
-/// is: egui bundles three fonts and most symbol glyphs are in none of them.
-fn draw_cross(ui: &egui::Ui, centre: egui::Pos2, colour: Color32) {
- let painter = ui.painter();
- let stroke = Stroke::new(1.3, colour);
- for dx in [-CROSS_ARM, CROSS_ARM] {
- painter.line_segment(
- [
- egui::pos2(centre.x - dx, centre.y - CROSS_ARM),
- egui::pos2(centre.x + dx, centre.y + CROSS_ARM),
- ],
- stroke,
- );
- }
-}
-
-fn colour_for(palette: &Palette, state: &State) -> Color32 {
- match state {
- State::Reading | State::Working(_) => palette.accent,
- State::Done(_) => palette.good,
- State::Failed(_) => palette.bad,
- }
+ false
}
fn detail(state: &State) -> String {
+ let dot = theme::DOT;
match state {
State::Reading => "reading".to_owned(),
State::Working(progress) => super::dropzone::stage_label(progress.stage).to_owned(),
State::Done(finished) if finished.cached => {
- format!("{} parts · already separated", finished.parts.len())
+ format!("{} parts{dot}already separated", finished.parts.len())
+ }
+ State::Done(finished) => {
+ format!("{} parts{dot}{:.0} s", finished.parts.len(), finished.secs)
}
- State::Done(finished) => format!(
- "{} parts · separated in {:.0}s",
- finished.parts.len(),
- finished.secs
- ),
State::Failed(why) => why.clone(),
}
}
/// Open the stems folder in the system's file manager.
///
-/// A failure here is worth a log line and nothing more: the stems are written
-/// either way, and the row still shows where they are.
+/// A failure is worth a log line and nothing more: the stems are written either
+/// way.
fn reveal(dir: &std::path::Path) {
- // macOS takes the absolute path rather than bare `open`: a bundle launched
- // from Finder inherits a minimal PATH, and "the button does nothing" is a
- // bad way to find that out. The other two are resolved through PATH because
- // that is the only place they are.
+ // The absolute path on macOS: a bundle launched from Finder inherits a minimal
+ // PATH.
#[cfg(target_os = "macos")]
let mut cmd = std::process::Command::new("/usr/bin/open");
#[cfg(windows)]
@@ -272,3 +316,19 @@ fn reveal(dir: &std::path::Path) {
tracing::warn!("could not reveal {}: {err}", dir.display());
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_list_takes_its_rows_up_to_the_room_it_has() {
+ assert_eq!(
+ height(2, 1000.0),
+ 2.0 * ROW_HEIGHT,
+ "two rows grew past themselves"
+ );
+ assert_eq!(height(20, 300.0), 300.0);
+ assert_eq!(height(20, 0.0), ROW_HEIGHT, "no room hid the list entirely");
+ }
+}
diff --git a/crates/stemd-server/src/ui/section.rs b/crates/stemd-server/src/ui/section.rs
index 9f2eb9a..d338095 100644
--- a/crates/stemd-server/src/ui/section.rs
+++ b/crates/stemd-server/src/ui/section.rs
@@ -1,114 +1,26 @@
-//! A collapsible section header.
-//!
-//! egui's `CollapsingHeader` draws a triangle, a body indent and a vertical rule
-//! down the left of its contents, which reads as an inspector panel. This is a
-//! quiet rule with a label on it: a chevron, a name, an optional count, and no
-//! indent.
+//! The "Separated" heading above the list: a muted label and a count.
-use eframe::egui::{self, Color32, Stroke};
+use eframe::egui::{self, Sense, pos2, vec2};
-use super::theme::Palette;
+use super::theme::{self, Palette, Weight};
-const HEADER_HEIGHT: f32 = 26.0;
-const CHEVRON_BOX: f32 = 14.0;
+pub const HEIGHT: f32 = 18.0;
-/// Indent that lines a body up with the label above it rather than the chevron.
-/// The right default for a list of rows; the wrong one for anything that wants
-/// the width more than the alignment, which is what [`FLUSH`] is for.
-pub const UNDER_LABEL: f32 = CHEVRON_BOX + 4.0;
+/// Left of the label, level with the icons in the list's rows.
+const INSET: f32 = 14.0;
-/// No indent. Log lines are long and monospaced, so eighteen points of
-/// alignment costs about three characters off the end of every line.
-pub const FLUSH: f32 = 0.0;
-
-/// Draw the header and, if it is open, its body. Returns nothing: `open` is the
-/// state, and the caller owns it.
-pub fn show(
- ui: &mut egui::Ui,
- palette: &Palette,
- label: &str,
- trailing: Option,
- open: &mut bool,
- indent: f32,
- body: impl FnOnce(&mut egui::Ui),
-) {
- let width = ui.available_width();
- let (rect, response) =
- ui.allocate_exact_size(egui::vec2(width, HEADER_HEIGHT), egui::Sense::click());
- if response.clicked() {
- *open = !*open;
- }
-
- let lit = response.hovered();
- let colour = if lit { palette.text } else { palette.muted };
-
- let chevron_rect = egui::Rect::from_center_size(
- egui::pos2(rect.left() + CHEVRON_BOX / 2.0, rect.center().y),
- egui::vec2(CHEVRON_BOX, CHEVRON_BOX),
- );
- chevron(ui, chevron_rect, *open, colour);
-
- ui.painter().text(
- egui::pos2(rect.left() + CHEVRON_BOX + 4.0, rect.center().y),
- egui::Align2::LEFT_CENTER,
- label,
- egui::FontId::proportional(12.0),
- colour,
- );
-
- if let Some(trailing) = trailing {
- ui.painter().text(
- egui::pos2(rect.right(), rect.center().y),
- egui::Align2::RIGHT_CENTER,
- trailing,
- egui::FontId::proportional(11.0),
- palette.muted,
- );
- }
-
- if response.hovered() {
- ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
- }
-
- if *open {
- ui.add_space(2.0);
- let mut body_ui = ui.new_child(
- egui::UiBuilder::new()
- .max_rect(egui::Rect::from_min_max(
- egui::pos2(ui.min_rect().left() + indent, ui.cursor().top()),
- ui.max_rect().max,
- ))
- .layout(egui::Layout::top_down(egui::Align::Min)),
- );
- body(&mut body_ui);
- ui.advance_cursor_after_rect(body_ui.min_rect());
- ui.add_space(6.0);
- }
-}
-
-/// A chevron pointing right when closed and down when open.
-///
-/// Painted rather than typed for the same reason every other mark here is: egui
-/// bundles three fonts, most arrow glyphs are in none of them, and a missing one
-/// draws nothing at all instead of a placeholder.
-fn chevron(ui: &egui::Ui, rect: egui::Rect, open: bool, colour: Color32) {
+pub fn heading(ui: &mut egui::Ui, palette: &Palette, label: &str, count: Option) {
+ let font = theme::font(12.0, Weight::Regular);
+ let painter = ui.painter();
+ let label = painter.layout_no_wrap(label.to_owned(), font.clone(), palette.muted);
+ let count = count.map(|c| painter.layout_no_wrap(c, font, palette.text));
+ let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), HEIGHT), Sense::hover());
let painter = ui.painter();
- let stroke = Stroke::new(1.4, colour);
- let c = rect.center();
- let arm = 3.2;
- let (a, b, tip) = if open {
- (
- egui::pos2(c.x - arm, c.y - arm * 0.6),
- egui::pos2(c.x + arm, c.y - arm * 0.6),
- egui::pos2(c.x, c.y + arm * 0.7),
- )
- } else {
- (
- egui::pos2(c.x - arm * 0.6, c.y - arm),
- egui::pos2(c.x - arm * 0.6, c.y + arm),
- egui::pos2(c.x + arm * 0.7, c.y),
- )
- };
- painter.line_segment([a, tip], stroke);
- painter.line_segment([b, tip], stroke);
+ let y = rect.center().y;
+ let x = rect.left() + INSET;
+ let count_x = x + label.size().x + 6.0;
+ painter.galley(pos2(x, y - label.size().y / 2.0), label, palette.muted);
+ if let Some(count) = count {
+ painter.galley(pos2(count_x, y - count.size().y / 2.0), count, palette.text);
+ }
}
diff --git a/crates/stemd-server/src/ui/starting.rs b/crates/stemd-server/src/ui/starting.rs
index de4482f..ebb47a9 100644
--- a/crates/stemd-server/src/ui/starting.rs
+++ b/crates/stemd-server/src/ui/starting.rs
@@ -1,109 +1,425 @@
//! What the window shows until the server is ready: on a first run the choice of
-//! model to download and its progress, then the model loading, or why startup
-//! failed.
+//! model and its download, then the model loading, or why startup failed.
-use eframe::egui;
+use eframe::egui::{self, Rect, Sense, Stroke, pos2, vec2};
use crate::launch::{Launch, Stage};
use crate::models::Preset;
-use super::header::{DIALOG_WIDTH, progress};
-use super::theme::Palette;
+use super::globe::{self, Motion};
+use super::icons;
+use super::theme::{self, Brushes, Glass, Palette, Weight, white};
+use super::widgets::{self, Column, Pill};
-pub fn show(ui: &mut egui::Ui, launch: &Launch) {
+const ROW_HEIGHT: f32 = 68.0;
+const ROW_GAP: f32 = 8.0;
+const ROW_RADIUS: f32 = 18.0;
+const QUIT_HEIGHT: f32 = 32.0;
+
+/// State the first-run view keeps between frames.
+#[derive(Default)]
+pub struct View {
+ motion: Motion,
+ /// Set once the choice has been offered, so the rows stay while the picked
+ /// model downloads and loads.
+ offered: bool,
+ picked: Option,
+ /// The last stage drawn, for the main window to fade out over itself.
+ pub(super) last: Option,
+ /// Where the globe sat and how far its slices were spread, for the main
+ /// window's globe to start from.
+ pub(super) globe: Option<(Rect, f32)>,
+ /// The main window draws the globe now; this view draws everything else.
+ pub(super) globe_handed_off: bool,
+}
+
+impl View {
+ /// The globe's motion, so the main window carries on from it.
+ pub(super) fn take_motion(&mut self) -> Motion {
+ std::mem::take(&mut self.motion)
+ }
+}
+
+pub fn show(ui: &mut egui::Ui, launch: &Launch, view: &mut View, brushes: &Brushes) {
let ctx = ui.ctx().clone();
- ctx.request_repaint_after(super::REPAINT_INTERVAL);
super::theme::install(&ctx);
let palette = Palette::of(&ctx);
let stage = launch.stage();
- egui::CentralPanel::default().show(ui, |_| {});
- egui::Window::new("Starting stemd")
- .collapsible(false)
- .resizable(false)
- .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
- .show(&ctx, |ui| {
- ui.set_max_width(DIALOG_WIDTH);
- ui.add_space(4.0);
- if let Some(preset) = body(ui, &stage, &palette) {
- launch.pick(preset);
- }
- ui.add_space(4.0);
- });
+ brushes.backdrop(ui.painter(), ctx.content_rect());
+
+ let busy = matches!(
+ stage,
+ Stage::Downloading { .. } | Stage::Verifying(_) | Stage::Loading(_)
+ );
+ let drive = globe::Drive {
+ variant: busy.then_some(0),
+ speed: 1.0,
+ spin: busy.then_some(3.0),
+ };
+ let (dt, now) = (globe::frame_dt(&ctx), ctx.input(|i| i.time));
+ if view.motion.step(dt, now, &drive) {
+ ctx.request_repaint();
+ } else {
+ ctx.request_repaint_after(super::REPAINT_INTERVAL);
+ }
+
+ if let Some(preset) = body(ui, &stage, &palette, brushes, view) {
+ launch.pick(preset);
+ }
}
-/// Draw `stage`. Returns the preset picked this frame, if any.
-pub(super) fn body(ui: &mut egui::Ui, stage: &Stage, palette: &Palette) -> Option {
+/// The preset `stage` is about, if any.
+fn preset_of(stage: &Stage) -> Option {
match stage {
- Stage::Starting => {
- ui.label("Checking the GPU");
- ui.add_space(8.0);
- ui.spinner();
- }
- Stage::Choosing(installed) => return choose(ui, installed, palette),
+ Stage::Downloading { preset, .. } | Stage::Verifying(preset) => Some(*preset),
+ Stage::Loading(name) => Preset::ALL.into_iter().find(|p| p.label() == name),
+ _ => None,
+ }
+}
+
+const fn tag(preset: Preset) -> &'static str {
+ match preset {
+ Preset::Fast => "speed",
+ Preset::Balanced => "middle ground",
+ Preset::Quality => "best separation",
+ }
+}
+
+fn megabytes(bytes: u64) -> String {
+ format!("{:.0} MB", bytes as f64 / 1e6)
+}
+
+/// Draw `stage` into the whole of `ui`. Returns the preset picked this frame.
+pub(super) fn body(
+ ui: &mut egui::Ui,
+ stage: &Stage,
+ palette: &Palette,
+ brushes: &Brushes,
+ view: &mut View,
+) -> Option {
+ if matches!(stage, Stage::Choosing(_)) {
+ view.offered = true;
+ }
+ if let Some(preset) = preset_of(stage) {
+ view.picked = Some(preset);
+ }
+ view.last = Some(stage.clone());
+ let area = ui.max_rect();
+ let inner = Rect::from_min_max(area.min + vec2(24.0, 26.0), area.max - vec2(24.0, 24.0));
+ let mid = inner.center().x;
+ let ctx = ui.ctx().clone();
+ let now = ctx.input(|i| i.time);
+ let busy = matches!(
+ stage,
+ Stage::Downloading { .. } | Stage::Verifying(_) | Stage::Loading(_)
+ );
+
+ let picked = view.picked;
+ let (title, sub) = match stage {
+ Stage::Starting => ("Starting Stemd".to_owned(), "Checking the GPU".to_owned()),
+ Stage::Choosing(installed) if installed.is_empty() => (
+ "Choose a model".to_owned(),
+ "No model is installed yet. Pick one to download.".to_owned(),
+ ),
+ Stage::Choosing(_) => (
+ "Choose a model".to_owned(),
+ "Pick the model to start with.".to_owned(),
+ ),
Stage::Downloading {
preset,
file,
done,
total,
} => {
- ui.label(format!("Downloading the {} model", preset.label()));
- ui.label(
- egui::RichText::new(preset.detail())
- .size(11.0)
- .color(palette.muted),
- );
- ui.add_space(8.0);
- progress(ui, file, *done, *total, palette);
+ let fraction = if *total > 0 {
+ *done as f32 / *total as f32
+ } else {
+ 0.0
+ };
+ let dot = theme::DOT;
+ (
+ format!("Downloading the {} model", preset.label()),
+ format!(
+ "{file}{dot}{}{dot}{:.0} / {}",
+ super::status::percent(fraction),
+ *done as f64 / 1e6,
+ megabytes(*total)
+ ),
+ )
}
- Stage::Verifying(preset) => {
- ui.label(format!("Checking the {} model", preset.label()));
- ui.add_space(8.0);
- ui.spinner();
+ Stage::Verifying(preset) => (
+ format!("Checking downloaded {} model", preset.label()),
+ "Verifying local data".to_owned(),
+ ),
+ Stage::Loading(name) => (
+ format!("Loading {name} onto the GPU"),
+ "A few seconds".to_owned(),
+ ),
+ Stage::Failed(message) => ("Stemd could not start".to_owned(), message.clone()),
+ };
+
+ let failed = matches!(stage, Stage::Failed(_));
+ let title_colour = if failed { palette.danger } else { palette.text };
+ let painter = ui.painter();
+ let title_galley =
+ painter.layout_no_wrap(title, theme::font(22.0, Weight::Semibold), title_colour);
+ let title_height = title_galley.size().y;
+ let sub_galley = painter.layout(
+ sub,
+ theme::font(12.5, Weight::Regular),
+ palette.muted,
+ inner.width(),
+ );
+ let sub_height = sub_galley.size().y;
+
+ // Each row's height and the gap under it, once. Without the model rows,
+ // the block sits in the middle.
+ let mut column = Column::default();
+ let stage_at = column.add(globe::STAGE.y, 6.0);
+ let title_at = column.add(title_height, 6.0);
+ let sub_at = column.add(sub_height, 22.0);
+ let rows_at = column.next();
+ if failed && !view.offered {
+ column.add(QUIT_HEIGHT, 0.0);
+ }
+ let top = if view.offered {
+ inner.top()
+ } else {
+ (inner.center().y - column.height() / 2.0).max(inner.top())
+ };
+ let stage_rect = Rect::from_min_size(
+ pos2(mid - globe::STAGE.x / 2.0, top + stage_at),
+ globe::STAGE,
+ );
+ let spread = ctx.animate_bool_with_time(ui.id().with("first-run-spread"), busy, 0.5);
+ view.globe = Some((stage_rect, spread));
+ if !view.globe_handed_off {
+ globe::paint(
+ painter,
+ brushes,
+ palette,
+ stage_rect,
+ &view.motion,
+ globe::Look {
+ spread,
+ lift: 0.0,
+ halo: 0.0,
+ },
+ now,
+ );
+ }
+
+ painter.galley(
+ pos2(mid - title_galley.size().x / 2.0, top + title_at),
+ title_galley,
+ title_colour,
+ );
+ painter.galley(
+ pos2(mid - sub_galley.size().x / 2.0, top + sub_at),
+ sub_galley,
+ palette.muted,
+ );
+ let mut y = top + rows_at;
+
+ let mut chosen = None;
+ if view.offered {
+ let installed: &[Preset] = match stage {
+ Stage::Choosing(installed) => installed,
+ _ => &[],
+ };
+ let locked = !matches!(stage, Stage::Choosing(_));
+ for preset in Preset::ALL {
+ let rect = Rect::from_min_size(pos2(inner.left(), y), vec2(inner.width(), ROW_HEIGHT));
+ y += ROW_HEIGHT + ROW_GAP;
+ let mine = picked == Some(preset) && locked;
+ if option_row(
+ ui, rect, preset, stage, mine, locked, picked, installed, palette, brushes, now,
+ ) {
+ chosen = Some(preset);
+ }
}
- Stage::Loading(model) => {
- ui.label(format!("Loading the {model} model"));
- ui.add_space(8.0);
- ui.spinner();
+ }
+
+ let footer = if view.offered {
+ Rect::from_min_max(pos2(inner.left(), y), inner.max)
+ } else {
+ Rect::from_min_size(pos2(inner.left(), y), vec2(inner.width(), QUIT_HEIGHT))
+ };
+ match stage {
+ Stage::Choosing(_) => {
+ ui.painter().text(
+ footer.center(),
+ egui::Align2::CENTER_CENTER,
+ "You can switch later from the model menu.",
+ theme::font(12.0, Weight::Regular),
+ palette.muted,
+ );
}
- Stage::Failed(message) => {
- ui.label(egui::RichText::new("stemd could not start").color(palette.bad));
- ui.add_space(6.0);
- ui.label(egui::RichText::new(message).size(11.0));
- ui.add_space(8.0);
- if ui.button("Quit").clicked() {
- ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
+ Stage::Failed(_) => {
+ let rect = Rect::from_center_size(footer.center(), vec2(88.0, QUIT_HEIGHT));
+ let quit = widgets::pill(
+ ui,
+ rect,
+ ui.id().with("quit"),
+ "Quit",
+ Pill::Light,
+ true,
+ palette,
+ brushes,
+ );
+ if quit.clicked() {
+ ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
+ _ => {}
+ }
+
+ if let Some(preset) = chosen {
+ view.picked = Some(preset);
}
- None
+ chosen
}
-/// One row per preset: a button to pick it, its speed, and its download size.
-fn choose(ui: &mut egui::Ui, installed: &[Preset], palette: &Palette) -> Option {
- let small = |text: String| egui::RichText::new(text).size(10.5).color(palette.muted);
- ui.label("Choose a model");
- ui.label(small("You can switch later from the model menu.".into()));
- ui.add_space(8.0);
- let mut picked = None;
- for preset in Preset::ALL {
- ui.horizontal(|ui| {
- if ui
- .add_sized([84.0, 28.0], egui::Button::new(preset.label()))
- .clicked()
- {
- picked = Some(preset);
+/// One preset as a glass row. Returns whether it was clicked.
+#[allow(clippy::too_many_arguments)]
+fn option_row(
+ ui: &egui::Ui,
+ rect: Rect,
+ preset: Preset,
+ stage: &Stage,
+ mine: bool,
+ locked: bool,
+ picked: Option,
+ installed: &[Preset],
+ palette: &Palette,
+ brushes: &Brushes,
+ now: f64,
+) -> bool {
+ let ctx = ui.ctx();
+ let id = ui.id().with(("first-run", preset.label()));
+ let response = ui.interact(
+ rect,
+ id,
+ if locked {
+ Sense::hover()
+ } else {
+ Sense::click()
+ },
+ );
+ let dim = ctx.animate_bool_with_time(id.with("dim"), locked && !mine && picked.is_some(), 0.3);
+ let alpha = 1.0 - 0.6 * dim;
+ let hover = ctx.animate_bool_with_time(id.with("hover"), response.hovered() && !locked, 0.15);
+ let pressed = ctx.animate_bool_with_time(
+ id.with("press"),
+ response.is_pointer_button_down_on() && !locked,
+ 0.1,
+ );
+ let rect = Rect::from_center_size(rect.center(), rect.size() * (1.0 - 0.01 * pressed));
+
+ let mut painter = ui.painter().clone();
+ painter.multiply_opacity(alpha);
+ brushes.glass(&painter, rect, ROW_RADIUS, Glass::Panel);
+ if hover > 0.0 {
+ painter.rect_filled(rect, ROW_RADIUS, white(0.05 * hover));
+ }
+ if mine {
+ painter.rect_stroke(
+ rect,
+ ROW_RADIUS,
+ Stroke::new(1.0, white(0.34)),
+ egui::StrokeKind::Inside,
+ );
+ }
+
+ let fraction = match stage {
+ Stage::Downloading { done, total, .. } if mine && *total > 0 => {
+ *done as f32 / *total as f32
+ }
+ Stage::Verifying(_) | Stage::Loading(_) if mine => 1.0,
+ _ => 0.0,
+ };
+ let shown = ctx.animate_value_with_time(id.with("fill"), fraction, 0.16);
+ if shown > 0.0 {
+ let fill = Rect::from_min_size(rect.min, vec2(rect.width() * shown, rect.height()));
+ brushes.spectrum(&painter, rect, fill, ROW_RADIUS, white(0.30));
+ }
+
+ let left = rect.left() + 18.0;
+ let label = painter.layout_no_wrap(
+ preset.label().to_owned(),
+ theme::font(15.0, Weight::Semibold),
+ palette.text,
+ );
+ let label_size = label.size();
+ let top = rect.center().y - (label_size.y + 3.0 + 16.0) / 2.0;
+ painter.galley(pos2(left, top), label, palette.text);
+ painter.text(
+ pos2(left + label_size.x + 8.0, top + label_size.y - 2.0),
+ egui::Align2::LEFT_BOTTOM,
+ tag(preset),
+ theme::font(12.0, Weight::Regular),
+ palette.soft,
+ );
+ painter.text(
+ pos2(left, top + label_size.y + 3.0),
+ egui::Align2::LEFT_TOP,
+ preset.detail(),
+ theme::font(12.5, Weight::Regular),
+ palette.muted,
+ );
+
+ let right = rect.right() - 18.0;
+ let y = rect.center().y;
+ let small = theme::font(12.5, Weight::Regular);
+ if !mine {
+ let is_installed = installed.contains(&preset);
+ let (text, icon) = if is_installed {
+ ("installed".to_owned(), false)
+ } else {
+ (megabytes(preset.total_bytes()), picked.is_none())
+ };
+ let x = if icon {
+ icons::download(
+ &painter,
+ icons::square(pos2(right - 6.5, y), 13.0),
+ palette.soft,
+ 1.2,
+ );
+ right - 13.0 - 8.0
+ } else {
+ right
+ };
+ painter.text(
+ pos2(x, y),
+ egui::Align2::RIGHT_CENTER,
+ text,
+ small,
+ palette.soft,
+ );
+ } else {
+ match stage {
+ Stage::Downloading { .. } => {
+ painter.text(
+ pos2(right, y),
+ egui::Align2::RIGHT_CENTER,
+ super::status::percent(fraction),
+ small,
+ palette.soft,
+ );
}
- ui.vertical(|ui| {
- ui.label(small(preset.detail().into()));
- ui.label(small(if installed.contains(&preset) {
- "installed".into()
- } else {
- format!("{:.0} MB download", preset.total_bytes() as f64 / 1e6)
- }));
- });
- });
- ui.add_space(4.0);
- }
- picked
+ _ => icons::spinner(
+ &painter,
+ icons::square(pos2(right - 6.5, y), 13.0),
+ palette.text,
+ 1.6,
+ (now as f32).rem_euclid(1.0),
+ ),
+ }
+ }
+
+ !locked
+ && response
+ .on_hover_cursor(egui::CursorIcon::PointingHand)
+ .clicked()
}
diff --git a/crates/stemd-server/src/ui/status.rs b/crates/stemd-server/src/ui/status.rs
index 1b114b8..b62b6c5 100644
--- a/crates/stemd-server/src/ui/status.rs
+++ b/crates/stemd-server/src/ui/status.rs
@@ -1,140 +1,249 @@
-//! The bottom bar: is the server up, what is the worker doing, what is stored.
+//! The bottom bar: the server switch and port, what is stored, and the way to
+//! the log.
//!
-//! One line, and the same [`LogBuffer`]-backed state `/v1/health` reports: a second
-//! view, never a second source.
-//!
-//! [`LogBuffer`]: crate::logbuf::LogBuffer
+//! The same state `/v1/health` reports: a second view, never a second source.
-use eframe::egui::{self, Color32};
+use eframe::egui::{self, Sense, pos2, vec2};
use crate::api::AppState;
-use super::theme::Palette;
+use super::icons;
+use super::theme::{self, Palette, Weight, white};
+
+/// Height of the bar.
+pub const HEIGHT: f32 = 20.0;
-/// Text size along the bar. Everything here is secondary to the drop zone, but
-/// the previous 11.0 was small enough to read as disabled rather than quiet.
-const TEXT: f32 = 12.5;
+const TEXT: f32 = 12.0;
+const GAP: f32 = 14.0;
/// A fraction as a whole number of per cent.
///
-/// Rounded down rather than to nearest, so a bar that has not finished never
-/// reads 100%. Watching it sit on "100%" for the last stretch of a four-minute
-/// separation is worse than watching it sit on 99%.
+/// Rounded down, so a bar that has not finished never reads 100%.
pub fn percent(fraction: f32) -> String {
format!("{}%", (fraction.clamp(0.0, 1.0) * 100.0).floor())
}
-/// Height of the bar. Fixed, and every item is centred in it: laying the row out
-/// by its natural height let the tallest widget, the button, set the baseline,
-/// so the label beside it sat visibly high.
-const BAR_HEIGHT: f32 = 30.0;
-
-/// Draw the bar. Returns nothing: everything here is read-only except the cache
-/// reset, which acts on the state it was given.
-pub fn bar(ui: &mut egui::Ui, state: &AppState, palette: &Palette, bind: std::net::SocketAddr) {
- let (rect, _) = ui.allocate_exact_size(
- egui::vec2(ui.available_width(), BAR_HEIGHT),
- egui::Sense::hover(),
- );
- let mut ui = ui.new_child(
- egui::UiBuilder::new()
- .max_rect(rect)
- .layout(egui::Layout::left_to_right(egui::Align::Center)),
- );
- let ui = &mut ui;
-
+/// Free bytes on the cache's volume, read at most every two seconds.
+fn free_space(ctx: &egui::Context, state: &AppState) -> Option {
+ let id = egui::Id::new("stemd-free-space");
+ let now = ctx.input(|i| i.time);
+ if let Some((at, free)) = ctx.data(|d| d.get_temp::<(f64, Option)>(id))
+ && now - at < 2.0
{
- let running = state.queue.running();
- // Green is up and free, amber is busy. Neither is a fault: the colours
- // read as a traffic light, which is what "can I give it a track right
- // now" wants. Grey would be the one to use if the server were down, and
- // there is no such state here: the window is the server.
- dot(
- ui,
- if running.is_some() {
- palette.warn
- } else {
- palette.good
- },
- );
+ return free;
+ }
+ let free = crate::disk::free_bytes(state.cache.root());
+ ctx.data_mut(|d| d.insert_temp(id, (now, free)));
+ free
+}
+
+/// What a click in the bar asked for. Emptying the cache is done in place.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum Action {
+ ToggleLogs,
+ ToggleServer,
+}
+
+const SWITCH: egui::Vec2 = vec2(26.0, 14.0);
- match &running {
- Some(id) => {
- ui.label(
- egui::RichText::new("separating")
- .size(TEXT)
- .color(palette.text),
- );
- if let Some(job) = state.store.get(id) {
- let progress = job.progress.lock().clone();
- ui.add(
- egui::ProgressBar::new(progress.fraction)
- .desired_width(96.0)
- .desired_height(5.0)
- .corner_radius(3)
- .fill(palette.accent),
- );
- ui.label(
- egui::RichText::new(percent(progress.fraction))
- .size(TEXT)
- .color(palette.muted),
- );
- }
- }
- None => {
- ui.label(
- egui::RichText::new(format!("serving on {bind}"))
- .size(TEXT)
- .color(palette.muted),
- );
- }
+/// The text beside the switch: the server's state, with the port while it is on,
+/// or the disk warning, which takes precedence.
+fn status_text(port: u16, on: bool, separating: Option, full: Option) -> String {
+ let dot = theme::DOT;
+ if let Some(free) = full {
+ return format!("disk nearly full{dot}{:.1} GB free", free as f64 / 1e9);
+ }
+ match (on, separating) {
+ (false, _) => "Server off".to_owned(),
+ (true, None) => format!("Server{dot}port {port}"),
+ (true, Some(0)) => format!("Server{dot}port {port}{dot}separating"),
+ (true, Some(queued)) => {
+ format!("Server{dot}port {port}{dot}separating{dot}{queued} queued")
}
+ }
+}
+
+/// Draw the bar. Returns what was clicked.
+#[must_use]
+pub fn bar(
+ ui: &mut egui::Ui,
+ state: &AppState,
+ palette: &Palette,
+ port: u16,
+ logs_open: bool,
+) -> Option {
+ let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), HEIGHT), Sense::hover());
+ let rect = rect.shrink2(vec2(6.0, 0.0));
+ let font = theme::font(TEXT, Weight::Regular);
+ let y = rect.center().y;
+ let mut action = None;
+
+ let on = state.serving.is_on();
+ let separating = state.queue.running().map(|_| state.queue.depth());
+ let free = free_space(ui.ctx(), state);
+ let full = free.filter(|free| *free < crate::disk::FLOOR);
+ let status = status_text(port, on, separating, full);
+ let text = ui
+ .painter()
+ .layout_no_wrap(status, font.clone(), egui::Color32::PLACEHOLDER);
+
+ // The switch and its label are one control.
+ let track = egui::Rect::from_center_size(pos2(rect.left() + SWITCH.x / 2.0, y), SWITCH);
+ let hit = egui::Rect::from_min_max(
+ track.left_top() - vec2(4.0, 3.0),
+ pos2(
+ track.right() + 8.0 + text.size().x + 4.0,
+ track.bottom() + 3.0,
+ ),
+ );
+ let response = ui
+ .interact(hit, ui.id().with("server-switch"), Sense::click())
+ .on_hover_text(if on {
+ format!("Answering players on port {port}. Click to stop.")
+ } else {
+ format!(
+ "Not answering. Click to serve on port {port}. \
+ Drops in this window work either way."
+ )
+ });
+ let lit = ui
+ .ctx()
+ .animate_bool_with_time(response.id.with("on"), on, 0.18);
+ let painter = ui.painter();
+ let r = SWITCH.y / 2.0;
+ painter.rect_filled(track, r, theme::mix(palette.danger, palette.good, lit));
+ let knob = pos2(
+ track.left() + r + (SWITCH.x - SWITCH.y) * lit,
+ track.center().y,
+ );
+ painter.circle_filled(knob, r - 2.0, palette.text);
+ let colour = if full.is_some() {
+ palette.danger
+ } else {
+ palette.muted
+ };
+ painter.galley(
+ pos2(track.right() + 8.0, y - text.size().y / 2.0),
+ text,
+ colour,
+ );
+ if response
+ .on_hover_cursor(egui::CursorIcon::PointingHand)
+ .clicked()
+ {
+ action = Some(Action::ToggleServer);
+ }
- ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- cache_controls(ui, state, palette);
+ // Right to left: the log button, "Empty cache", what is stored.
+ let logs = icons::square(pos2(rect.right() + 5.0 - 12.0, y), 24.0);
+ let logs_response = ui
+ .interact(logs, ui.id().with("logs"), Sense::click())
+ .on_hover_text(if logs_open {
+ "Hide the log"
+ } else {
+ "Show the log"
});
+ let lit = ui.ctx().animate_bool_with_time(
+ logs_response.id.with("hover"),
+ logs_response.hovered() || logs_open,
+ 0.15,
+ );
+ if lit > 0.0 {
+ ui.painter().rect_filled(logs, 7.0, white(0.1 * lit));
}
-}
+ icons::lines(
+ ui.painter(),
+ icons::square(logs.center(), 14.0),
+ theme::mix(palette.muted, egui::Color32::WHITE, lit),
+ 1.4,
+ );
-/// What is stored, and a button to drop it.
-///
-/// Disabled when there is nothing to drop, so the button doubles as the answer
-/// to whether anything is stored. No confirmation: every byte is re-computable
-/// from audio that still exists, so a misclick costs one separation.
-fn cache_controls(ui: &mut egui::Ui, state: &AppState, palette: &Palette) {
let stats = state.cache.stats();
- // Not "Clear": the log section has one of those, and two buttons reading
- // "Clear" in one window is a coin toss about which one you are about to
- // press. This one names what it empties.
- if ui
- .add_enabled(
- stats.tracks > 0,
- egui::Button::new(egui::RichText::new("Empty cache").size(TEXT)),
- )
+ let empty = ui.painter().layout_no_wrap(
+ "Empty cache".to_owned(),
+ font.clone(),
+ egui::Color32::PLACEHOLDER,
+ );
+ let empty_rect = egui::Rect::from_min_size(
+ pos2(
+ logs.left() + 6.0 - GAP - empty.size().x,
+ y - empty.size().y / 2.0,
+ ),
+ empty.size(),
+ );
+ let has_any = stats.tracks > 0;
+ let empty_response = ui.interact(
+ empty_rect,
+ ui.id().with("empty-cache"),
+ if has_any {
+ Sense::click()
+ } else {
+ Sense::hover()
+ },
+ );
+ let danger = ui.ctx().animate_bool_with_time(
+ empty_response.id.with("hover"),
+ empty_response.hovered() && has_any,
+ 0.15,
+ );
+ let colour = if has_any {
+ theme::mix(palette.text, palette.danger, danger)
+ } else {
+ palette.muted.gamma_multiply(0.6)
+ };
+ ui.painter().galley(empty_rect.min, empty, colour);
+ // No confirmation: every byte is re-computable from audio that still exists,
+ // so a misclick costs one separation.
+ if empty_response
.on_hover_text("Delete all separated tracks")
+ .on_hover_cursor(if has_any {
+ egui::CursorIcon::PointingHand
+ } else {
+ egui::CursorIcon::Default
+ })
.clicked()
{
let (tracks, freed) = state.cache.clear();
tracing::info!("cache reset: {tracks} tracks, {:.0} MB", freed as f64 / 1e6);
}
- ui.label(
- egui::RichText::new(format!(
- "{} cached · {:.0} MB",
+
+ ui.painter().text(
+ pos2(empty_rect.left() - GAP, y),
+ egui::Align2::RIGHT_CENTER,
+ format!(
+ "{} cached{}{:.0} MB",
stats.tracks,
+ theme::DOT,
stats.bytes as f64 / 1e6
- ))
- .size(TEXT)
- .color(palette.muted),
+ ),
+ font,
+ palette.muted,
);
+
+ if logs_response
+ .on_hover_cursor(egui::CursorIcon::PointingHand)
+ .clicked()
+ {
+ action = Some(Action::ToggleLogs);
+ }
+ action
}
-/// The status dot, painted rather than typed.
-///
-/// The obvious characters (U+25CF and U+25CB) are not both in egui's bundled fonts:
-/// U+25CF resolves to `.notdef` and draws nothing. Always filled, now that colour
-/// carries the state.
-fn dot(ui: &mut egui::Ui, colour: Color32) {
- let height = ui.text_style_height(&egui::TextStyle::Body);
- let (rect, _) = ui.allocate_exact_size(egui::vec2(height * 0.6, height), egui::Sense::hover());
- ui.painter().circle_filled(rect.center(), 3.5, colour);
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_status_reads_port_then_state() {
+ assert_eq!(status_text(8420, true, None, None), "Server · port 8420");
+ assert_eq!(status_text(8420, false, Some(2), None), "Server off");
+ assert_eq!(
+ status_text(8420, true, Some(2), None),
+ "Server · port 8420 · separating · 2 queued"
+ );
+ assert_eq!(
+ status_text(8420, true, None, Some(1_200_000_000)),
+ "disk nearly full · 1.2 GB free"
+ );
+ }
}
diff --git a/crates/stemd-server/src/ui/theme.rs b/crates/stemd-server/src/ui/theme.rs
index 365b292..d44bbf5 100644
--- a/crates/stemd-server/src/ui/theme.rs
+++ b/crates/stemd-server/src/ui/theme.rs
@@ -1,191 +1,625 @@
-//! Colour, shape and the two painting helpers everything else is built from.
+//! Colour, type, the window backdrop and the glass surfaces everything sits on.
//!
-//! The palette is Apple's system colours rather than invented ones, so the
-//! window sits next to native apps instead of near them, and it comes in both
-//! appearances because a Mac that switches to dark at sunset expects this to
-//! follow.
+//! One appearance: the navy backdrop is the design, so the window does not follow
+//! the system's light mode.
+
+use std::sync::Arc;
use eframe::egui::{
- self, Color32, CornerRadius, Rect, Stroke, StrokeKind, TextureHandle, TextureOptions,
+ self, Color32, CornerRadius, FontFamily, FontId, Pos2, Rect, Stroke, StrokeKind, TextureHandle,
+ TextureOptions, pos2, vec2,
};
+use egui::epaint::text::{FontTweak, VariationCoords};
+use egui::epaint::{RectShape, Shadow};
+
+/// Instrument Sans, variable on `wght` and `wdth`. SIL Open Font License, see
+/// `resources/fonts/InstrumentSans-OFL.txt`.
+const INSTRUMENT_SANS: &[u8] = include_bytes!("../../../../resources/fonts/InstrumentSans.ttf");
+
+/// Separator inside a line of text.
+pub const DOT: &str = " · ";
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Weight {
+ Regular,
+ Medium,
+ Semibold,
+}
+
+impl Weight {
+ const ALL: [Self; 3] = [Self::Regular, Self::Medium, Self::Semibold];
-/// Corner radii, in the two sizes this window uses.
-pub const CARD_RADIUS: u8 = 10;
-pub const ZONE_RADIUS: u8 = 16;
+ const fn font_name(self) -> &'static str {
+ match self {
+ Self::Regular => "instrument-400",
+ Self::Medium => "instrument-500",
+ Self::Semibold => "instrument-600",
+ }
+ }
+
+ const fn wght(self) -> f32 {
+ match self {
+ Self::Regular => 400.0,
+ Self::Medium => 500.0,
+ Self::Semibold => 600.0,
+ }
+ }
+
+ fn family(self) -> FontFamily {
+ match self {
+ Self::Regular => FontFamily::Proportional,
+ Self::Medium => FontFamily::Name("medium".into()),
+ Self::Semibold => FontFamily::Name("semibold".into()),
+ }
+ }
+}
+
+pub fn font(size: f32, weight: Weight) -> FontId {
+ FontId::new(size, weight.family())
+}
pub struct Palette {
- /// Behind everything.
- pub window: Color32,
- /// Raised surfaces: the drop zone, a row in the list.
- pub card: Color32,
- /// The same, one step lighter, for a hover.
- pub card_hover: Color32,
- /// Hairline around a surface. Low alpha on purpose: an outline you notice
- /// is an outline that is too strong.
- pub outline: Color32,
pub text: Color32,
pub muted: Color32,
- pub accent: Color32,
+ /// Unselected segment labels and secondary tags.
+ pub soft: Color32,
+ /// Text on a light pill.
+ pub ink: Color32,
+ /// The equator band, check boxes and rings.
+ pub band: Color32,
+ pub red: Color32,
+ pub green: Color32,
+ pub blue: Color32,
pub good: Color32,
- pub warn: Color32,
- pub bad: Color32,
- /// Top and bottom of the gradient on a raised surface.
- pub sheen: (Color32, Color32),
+ pub danger: Color32,
+ /// Tooltip fill.
+ pub menu: Color32,
+ /// Fill over the blurred backdrop of a menu or dialog.
+ pub frost: Color32,
+ pub menu_outline: Color32,
+ pub hairline: Color32,
+ pub divider: Color32,
}
impl Palette {
- pub fn dark() -> Self {
+ pub fn navy() -> Self {
Self {
- window: Color32::from_rgb(0x1C, 0x1C, 0x1E),
- card: Color32::from_rgb(0x2A, 0x2A, 0x2E),
- card_hover: Color32::from_rgb(0x33, 0x33, 0x38),
- outline: Color32::from_rgba_unmultiplied(0xFF, 0xFF, 0xFF, 0x14),
- text: Color32::from_rgb(0xEC, 0xEC, 0xEE),
- muted: Color32::from_rgb(0x98, 0x98, 0x9F),
- accent: Color32::from_rgb(0x0A, 0x84, 0xFF),
- good: Color32::from_rgb(0x30, 0xD1, 0x58),
- warn: Color32::from_rgb(0xFF, 0x9F, 0x0A),
- bad: Color32::from_rgb(0xFF, 0x45, 0x3A),
- sheen: (
- Color32::from_rgba_unmultiplied(0xFF, 0xFF, 0xFF, 0x0C),
- Color32::TRANSPARENT,
- ),
+ text: Color32::from_rgb(0xEE, 0xF0, 0xF6),
+ muted: Color32::from_rgb(0xB4, 0xBB, 0xCC),
+ soft: Color32::from_rgb(0xC9, 0xCE, 0xDB),
+ ink: Color32::from_rgb(0x12, 0x1A, 0x33),
+ band: Color32::from_rgb(0xE9, 0xEB, 0xF1),
+ red: Color32::from_rgb(0xD4, 0x68, 0x5F),
+ green: Color32::from_rgb(0x6F, 0xB4, 0x7C),
+ blue: Color32::from_rgb(0x5B, 0x84, 0xD8),
+ good: Color32::from_rgb(0x8F, 0xD1, 0x9B),
+ danger: Color32::from_rgb(0xF0, 0xA4, 0x9C),
+ menu: Color32::from_rgba_unmultiplied(30, 38, 70, 235),
+ frost: Color32::from_rgba_unmultiplied(24, 31, 58, 150),
+ menu_outline: white(0.14),
+ hairline: white(0.08),
+ divider: white(0.14),
}
}
- pub fn light() -> Self {
- Self {
- window: Color32::from_rgb(0xF2, 0xF2, 0xF7),
- card: Color32::from_rgb(0xFF, 0xFF, 0xFF),
- card_hover: Color32::from_rgb(0xFA, 0xFA, 0xFC),
- outline: Color32::from_rgba_unmultiplied(0x00, 0x00, 0x00, 0x1A),
- text: Color32::from_rgb(0x1C, 0x1C, 0x1E),
- muted: Color32::from_rgb(0x6E, 0x6E, 0x73),
- accent: Color32::from_rgb(0x00, 0x7A, 0xFF),
- good: Color32::from_rgb(0x34, 0xC7, 0x59),
- warn: Color32::from_rgb(0xFF, 0x95, 0x00),
- bad: Color32::from_rgb(0xFF, 0x3B, 0x30),
- // Light from above means the *bottom* darkens here, so the top is
- // nothing at all rather than white-at-zero, which is additive white
- // however transparent it claims to be.
- sheen: (
- Color32::TRANSPARENT,
- Color32::from_rgba_unmultiplied(0x00, 0x00, 0x00, 0x0A),
- ),
- }
+ pub fn of(_ctx: &egui::Context) -> Self {
+ Self::navy()
}
- pub fn of(ctx: &egui::Context) -> Self {
- if is_dark(ctx) {
- Self::dark()
- } else {
- Self::light()
- }
+ /// The colours drawn with less than full alpha.
+ #[cfg(test)]
+ fn translucent(&self) -> [Color32; 5] {
+ [
+ self.menu,
+ self.frost,
+ self.menu_outline,
+ self.hairline,
+ self.divider,
+ ]
}
+}
+
+/// White at `alpha` (0 to 1), premultiplied for normal blending.
+pub fn white(alpha: f32) -> Color32 {
+ Color32::from_white_alpha(unit(alpha))
+}
+
+pub fn black(alpha: f32) -> Color32 {
+ Color32::from_black_alpha(unit(alpha))
+}
+
+fn unit(alpha: f32) -> u8 {
+ (alpha.clamp(0.0, 1.0) * 255.0).round() as u8
+}
+
+/// `from` to `to` by `t`, in gamma space like CSS.
+pub fn mix(from: Color32, to: Color32, t: f32) -> Color32 {
+ from.lerp_to_gamma(to, t.clamp(0.0, 1.0))
+}
+
+/// A glass surface: vertical white gradient, hairline, top highlight, soft shadow.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum Glass {
+ /// Header controls and first-run rows.
+ Panel,
+ /// The separated list and the log.
+ List,
+}
- /// The same colour at a lower alpha, for a tint behind text of that colour.
- pub fn wash(colour: Color32, alpha: u8) -> Color32 {
- Color32::from_rgba_unmultiplied(colour.r(), colour.g(), colour.b(), alpha)
+/// Alphas of a glass surface's white gradient, outline and highlight, and of
+/// its black shadow.
+struct GlassSpec {
+ top: f32,
+ bottom: f32,
+ outline: f32,
+ highlight: f32,
+ shadow: f32,
+}
+
+impl Glass {
+ const fn spec(self) -> GlassSpec {
+ match self {
+ Self::Panel => GlassSpec {
+ top: 0.10,
+ bottom: 0.04,
+ outline: 0.12,
+ highlight: 0.18,
+ shadow: 0.22,
+ },
+ Self::List => GlassSpec {
+ top: 0.08,
+ bottom: 0.03,
+ outline: 0.10,
+ highlight: 0.14,
+ shadow: 0.20,
+ },
+ }
}
}
-/// A two-pixel texture sampled across a shape, which is how a rounded rectangle
-/// gets a gradient.
+/// Texture brushes and baked images, built once per context.
///
-/// `RectShape` fills with one flat colour, and a gradient painted as a mesh behind
-/// it would square off the corners the rounding just made. A brush is tessellated
-/// with the shape, so the gradient is clipped to the rounded outline exactly.
-pub struct Sheen {
- texture: TextureHandle,
- /// The appearance it was built for. Rebuilt when the system switches.
- dark: bool,
+/// `RectShape` fills with one colour; a texture sampled across it is how a rounded
+/// rectangle gets a gradient that follows its corners.
+pub struct Brushes {
+ backdrop: TextureHandle,
+ panel: TextureHandle,
+ list: TextureHandle,
+ pill: TextureHandle,
+ sheen: TextureHandle,
+ spectrum: TextureHandle,
+ gloss: TextureHandle,
+ pub shade: TextureHandle,
+ pub shadow: TextureHandle,
+ pub halo: TextureHandle,
}
-impl Sheen {
+/// Samples a 1x2 texture from the first texel centre to the second, so the
+/// gradient spans the whole shape.
+const VERTICAL_UV: Rect = Rect {
+ min: Pos2 { x: 0.5, y: 0.25 },
+ max: Pos2 { x: 0.5, y: 0.75 },
+};
+
+impl Brushes {
pub fn new(ctx: &egui::Context) -> Self {
- let dark = is_dark(ctx);
+ let linear = TextureOptions::LINEAR;
+ let repeat = TextureOptions {
+ wrap_mode: egui::TextureWrapMode::Repeat,
+ ..TextureOptions::LINEAR
+ };
+ let column = |name: &str, top: Color32, bottom: Color32| {
+ ctx.load_texture(
+ name,
+ egui::ColorImage::new([1, 2], vec![top, bottom]),
+ linear,
+ )
+ };
Self {
- texture: build(ctx, dark),
- dark,
+ backdrop: ctx.load_texture("stemd-backdrop", backdrop(256), linear),
+ panel: column(
+ "stemd-glass-panel",
+ white(Glass::Panel.spec().top),
+ white(Glass::Panel.spec().bottom),
+ ),
+ list: column(
+ "stemd-glass-list",
+ white(Glass::List.spec().top),
+ white(Glass::List.spec().bottom),
+ ),
+ pill: column(
+ "stemd-pill",
+ Color32::from_rgb(0xEE, 0xF0, 0xF6),
+ Color32::from_rgb(0xC2, 0xC7, 0xD4),
+ ),
+ sheen: ctx.load_texture("stemd-sheen", sheen(64), repeat),
+ spectrum: ctx.load_texture("stemd-spectrum", spectrum(), linear),
+ gloss: ctx.load_texture("stemd-gloss", gloss(16), linear),
+ shade: ctx.load_texture("stemd-shade", shade(128), linear),
+ shadow: ctx.load_texture("stemd-shadow", shadow_disc(64), linear),
+ halo: ctx.load_texture("stemd-halo", halo(128), linear),
}
}
- /// The texture for the current appearance, rebuilding it if it changed.
- fn texture(&mut self, ctx: &egui::Context) -> &TextureHandle {
- let dark = is_dark(ctx);
- if dark != self.dark {
- self.texture = build(ctx, dark);
- self.dark = dark;
+ /// The navy backdrop, stretched over `rect`.
+ pub fn backdrop(&self, painter: &egui::Painter, rect: Rect) {
+ painter.image(
+ self.backdrop.id(),
+ rect,
+ Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
+ Color32::WHITE,
+ );
+ }
+
+ /// A glass surface.
+ pub fn glass(&self, painter: &egui::Painter, rect: Rect, radius: f32, glass: Glass) {
+ painter.add(self.glass_shape(rect, radius, glass));
+ }
+
+ /// A glass surface as one shape, for putting behind content drawn first.
+ pub fn glass_shape(&self, rect: Rect, radius: f32, glass: Glass) -> egui::Shape {
+ let texture = match glass {
+ Glass::Panel => &self.panel,
+ Glass::List => &self.list,
+ };
+ let spec = glass.spec();
+ egui::Shape::Vec(vec![
+ drop_shadow(rect, radius, 6.0, 16.0, black(spec.shadow)),
+ RectShape::filled(rect, radius, Color32::WHITE)
+ .with_texture(texture.id(), VERTICAL_UV)
+ .into(),
+ RectShape::stroke(
+ rect,
+ radius,
+ Stroke::new(1.0, white(spec.outline)),
+ StrokeKind::Inside,
+ )
+ .into(),
+ top_highlight(rect, radius, white(spec.highlight)),
+ ])
+ }
+
+ /// The light pill: a selected segment, an open menu button, a primary button.
+ pub fn pill(&self, painter: &egui::Painter, rect: Rect, radius: f32, alpha: f32) {
+ painter.add(drop_shadow(rect, radius, 1.0, 3.0, black(0.3 * alpha)));
+ painter.add(
+ RectShape::filled(rect, radius, white(alpha)).with_texture(self.pill.id(), VERTICAL_UV),
+ );
+ }
+
+ /// The Quality thumb: a pastel red, green and blue gradient sliding left.
+ /// `offset` is in widths of the pill, and wraps.
+ pub fn sheen(&self, painter: &egui::Painter, rect: Rect, radius: f32, offset: f32) {
+ painter.add(drop_shadow(
+ rect,
+ radius,
+ 0.0,
+ 14.0,
+ Color32::from_rgba_unmultiplied(140, 170, 240, 140),
+ ));
+ painter.add(drop_shadow(rect, radius, 1.0, 3.0, black(0.3)));
+ // The gradient is 2.2 pill widths long.
+ let span = 1.0 / 2.2;
+ let u = offset.rem_euclid(1.0);
+ painter.add(
+ RectShape::filled(rect, radius, Color32::WHITE).with_texture(
+ self.sheen.id(),
+ Rect::from_min_max(pos2(u, 0.5), pos2(u + span, 0.5)),
+ ),
+ );
+ }
+
+ /// Red, green and blue left to right across `track`, drawn only over `fill`.
+ /// The gradient stays anchored to the track as `fill` grows.
+ pub fn spectrum(
+ &self,
+ painter: &egui::Painter,
+ track: Rect,
+ fill: Rect,
+ radius: f32,
+ tint: Color32,
+ ) {
+ if fill.width() <= 0.0 {
+ return;
}
- &self.texture
+ let u = |x: f32| 1.0 / 6.0 + (x - track.left()) / track.width() * (2.0 / 3.0);
+ painter.add(RectShape::filled(fill, radius, tint).with_texture(
+ self.spectrum.id(),
+ Rect::from_min_max(pos2(u(fill.left()), 0.5), pos2(u(fill.right()), 0.5)),
+ ));
}
- /// A raised surface: flat fill, gradient over it, hairline around it.
- pub fn card(&mut self, ui: &egui::Ui, rect: Rect, radius: u8, fill: Color32, stroke: Stroke) {
- let painter = ui.painter();
- painter.rect_filled(rect, radius, fill);
+ /// White fading to nothing 60% of the way down: the gloss on a progress fill.
+ pub fn gloss(&self, painter: &egui::Painter, rect: Rect, radius: f32) {
painter.add(
- egui::epaint::RectShape::filled(rect, radius, Color32::WHITE).with_texture(
- self.texture(ui.ctx()).id(),
- Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
+ RectShape::filled(rect, radius, Color32::WHITE).with_texture(
+ self.gloss.id(),
+ Rect::from_min_max(pos2(0.5, 0.0), pos2(0.5, 1.0)),
),
);
- painter.rect_stroke(rect, radius, stroke, StrokeKind::Inside);
}
}
-/// Which appearance the system is asking for.
-fn is_dark(ctx: &egui::Context) -> bool {
- ctx.theme() == egui::Theme::Dark
+/// `box-shadow: 0 `.
+pub fn drop_shadow(rect: Rect, radius: f32, dy: f32, blur: f32, colour: Color32) -> egui::Shape {
+ let shadow = Shadow {
+ offset: [0, dy.round() as i8],
+ blur: blur.round() as u8,
+ spread: 0,
+ color: colour,
+ };
+ shadow.as_shape(rect, CornerRadius::from(radius)).into()
}
-/// Top row transparent-to-white, bottom row transparent: light from above.
-fn build(ctx: &egui::Context, dark: bool) -> TextureHandle {
- let palette = if dark {
- Palette::dark()
- } else {
- Palette::light()
+/// `box-shadow: inset 0 1px 0 `: a one-point line under the top edge,
+/// following the corners partway round.
+pub fn top_highlight(rect: Rect, radius: f32, colour: Color32) -> egui::Shape {
+ let inset = 1.5;
+ let r = (radius - inset).max(0.0);
+ let left = pos2(rect.left() + radius, rect.top() + radius);
+ let right = pos2(rect.right() - radius, rect.top() + radius);
+ let mut points = Vec::with_capacity(20);
+ let steps = 8;
+ for i in 0..=steps {
+ let a = std::f32::consts::PI * (1.25 + 0.25 * i as f32 / steps as f32);
+ points.push(left + r * vec2(a.cos(), a.sin()));
+ }
+ for i in 0..=steps {
+ let a = std::f32::consts::PI * (1.5 + 0.25 * i as f32 / steps as f32);
+ points.push(right + r * vec2(a.cos(), a.sin()));
+ }
+ egui::Shape::line(points, Stroke::new(1.0, colour))
+}
+
+/// The outline of a rounded rectangle, clockwise from the top-left corner.
+pub fn rounded_outline(rect: Rect, radius: f32, steps: usize) -> Vec {
+ use std::f32::consts::{FRAC_PI_2, PI};
+ let r = radius.min(rect.width() / 2.0).min(rect.height() / 2.0);
+ let corners = [
+ (pos2(rect.left() + r, rect.top() + r), PI),
+ (pos2(rect.right() - r, rect.top() + r), PI + FRAC_PI_2),
+ (pos2(rect.right() - r, rect.bottom() - r), 0.0),
+ (pos2(rect.left() + r, rect.bottom() - r), FRAC_PI_2),
+ ];
+ let mut points = Vec::with_capacity(4 * (steps + 1) + 1);
+ for (centre, start) in corners {
+ for i in 0..=steps {
+ let a = start + FRAC_PI_2 * i as f32 / steps as f32;
+ points.push(centre + r * vec2(a.cos(), a.sin()));
+ }
+ }
+ points.push(points[0]);
+ points
+}
+
+/// `box-shadow: inset 0 0 `: a glow fading inward from the edge.
+pub fn inner_glow(painter: &egui::Painter, rect: Rect, radius: f32, depth: f32, colour: Color32) {
+ if colour.a() == 0 {
+ return;
+ }
+ let steps = 10;
+ let outer = rounded_outline(rect, radius, steps);
+ let inner_rect = rect.shrink(depth);
+ let inner = rounded_outline(inner_rect, (radius - depth).max(0.0), steps);
+ // Both outlines have the same number of points when the radius is not
+ // clamped differently, which holds for the drop zone's sizes.
+ if outer.len() != inner.len() {
+ return;
+ }
+ let mut mesh = egui::Mesh::default();
+ for (o, i) in outer.iter().zip(&inner) {
+ mesh.colored_vertex(*o, colour);
+ mesh.colored_vertex(*i, Color32::TRANSPARENT);
+ }
+ for k in 0..(outer.len() as u32 - 1) {
+ let a = 2 * k;
+ mesh.add_triangle(a, a + 1, a + 2);
+ mesh.add_triangle(a + 1, a + 3, a + 2);
+ }
+ painter.add(mesh);
+}
+
+/// A dashed rounded rectangle, as `border: dashed`.
+pub fn dashed_outline(painter: &egui::Painter, rect: Rect, radius: f32, stroke: Stroke) {
+ let points = rounded_outline(rect.shrink(stroke.width / 2.0), radius, 12);
+ painter.extend(egui::Shape::dashed_line(&points, stroke, 5.0, 4.0));
+}
+
+/// CSS `cubic-bezier(x1, y1, x2, y2)` at `t`.
+pub fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32, t: f32) -> f32 {
+ let t = t.clamp(0.0, 1.0);
+ let curve = |a: f32, b: f32, s: f32| {
+ let u = 1.0 - s;
+ 3.0 * u * u * s * a + 3.0 * u * s * s * b + s * s * s
};
- let (top, bottom) = palette.sheen;
- ctx.load_texture(
- "stemd-sheen",
- egui::ColorImage::new([1, 2], vec![top, bottom]),
- TextureOptions::LINEAR,
- )
+ // Solve x(s) = t by bisection: monotone in s for valid CSS curves.
+ let (mut lo, mut hi) = (0.0_f32, 1.0_f32);
+ for _ in 0..24 {
+ let mid = (lo + hi) / 2.0;
+ if curve(x1, x2, mid) < t {
+ lo = mid;
+ } else {
+ hi = mid;
+ }
+ }
+ curve(y1, y2, (lo + hi) / 2.0)
+}
+
+/// A springy ease, `cubic-bezier(.3, .8, .3, 1)`.
+pub fn ease_spring(t: f32) -> f32 {
+ cubic_bezier(0.3, 0.8, 0.3, 1.0, t)
+}
+
+/// `ease-out`.
+pub fn ease_out(t: f32) -> f32 {
+ cubic_bezier(0.0, 0.0, 0.58, 1.0, t)
+}
+
+/// `ease-in`.
+pub fn ease_in(t: f32) -> f32 {
+ cubic_bezier(0.42, 0.0, 1.0, 1.0, t)
+}
+
+/// `ease-in-out`.
+pub fn ease_in_out(t: f32) -> f32 {
+ cubic_bezier(0.42, 0.0, 0.58, 1.0, t)
+}
+
+/// Frame interval for animation while a track separates: 60 fps.
+const PROCESSING_FRAME: std::time::Duration = std::time::Duration::from_micros(16_667);
+
+fn processing_id() -> egui::Id {
+ egui::Id::new("stemd-processing")
+}
+
+/// Record whether a separation is running, which caps animation at 60 fps.
+pub fn set_processing(ctx: &egui::Context, on: bool) {
+ ctx.data_mut(|d| d.insert_temp(processing_id(), on));
+}
+
+/// Ask for the next animation frame: at the display's rate, or 60 fps while a
+/// track separates.
+pub fn request_frame(ctx: &egui::Context) {
+ if ctx
+ .data(|d| d.get_temp::(processing_id()))
+ .unwrap_or(false)
+ {
+ ctx.request_repaint_after(PROCESSING_FRAME);
+ } else {
+ ctx.request_repaint();
+ }
}
-/// Make egui look like it belongs on this platform.
+/// A value easing from where it was to a new target over a fixed time.
///
-/// egui's defaults are square, high-contrast and tight. Rounding, spacing and a
-/// palette that is not pure black do most of the work of making it not look like
-/// a debug window.
+/// `ctx.animate_value_with_time` is linear; this takes any easing and restarts
+/// from the current position when the target moves mid-flight.
+#[derive(Clone, Copy, Default)]
+pub struct Tween {
+ from: f32,
+ to: f32,
+ start: f64,
+ duration: f32,
+ set: bool,
+}
+
+impl Tween {
+ /// The value now. Requests a repaint while moving.
+ pub fn follow(
+ ctx: &egui::Context,
+ id: egui::Id,
+ target: f32,
+ duration: f32,
+ ease: fn(f32) -> f32,
+ ) -> f32 {
+ let now = ctx.input(|i| i.time);
+ let mut tween = ctx.data(|d| d.get_temp::(id)).unwrap_or_default();
+ if !tween.set {
+ tween = Self {
+ from: target,
+ to: target,
+ start: now,
+ duration,
+ set: true,
+ };
+ } else if (tween.to - target).abs() > f32::EPSILON {
+ tween = Self {
+ from: tween.value(now, ease),
+ to: target,
+ start: now,
+ duration,
+ set: true,
+ };
+ }
+ ctx.data_mut(|d| d.insert_temp(id, tween));
+ let value = tween.value(now, ease);
+ if (value - target).abs() > 1e-3 {
+ request_frame(ctx);
+ }
+ value
+ }
+
+ fn value(&self, now: f64, ease: fn(f32) -> f32) -> f32 {
+ let t = if self.duration > 0.0 {
+ ((now - self.start) as f32 / self.duration).clamp(0.0, 1.0)
+ } else {
+ 1.0
+ };
+ self.from + (self.to - self.from) * ease(t)
+ }
+}
+
+/// Bind Instrument Sans at three weights, with egui's own fonts behind it for
+/// any glyph it lacks.
+fn install_fonts(ctx: &egui::Context) {
+ let mut fonts = egui::FontDefinitions::default();
+ let fallbacks = fonts
+ .families
+ .get(&FontFamily::Proportional)
+ .cloned()
+ .unwrap_or_default();
+ for weight in Weight::ALL {
+ let name = weight.font_name();
+ fonts.font_data.insert(
+ name.to_owned(),
+ Arc::new(
+ egui::FontData::from_static(INSTRUMENT_SANS).tweak(FontTweak {
+ coords: VariationCoords::new([("wght", weight.wght())]),
+ ..Default::default()
+ }),
+ ),
+ );
+ let mut chain = vec![name.to_owned()];
+ chain.extend(fallbacks.iter().cloned());
+ fonts.families.insert(weight.family(), chain);
+ }
+ ctx.set_fonts(fonts);
+}
+
+/// Fonts once per context, style every frame.
pub fn install(ctx: &egui::Context) {
- let palette = Palette::of(ctx);
- let theme = ctx.theme();
- let mut style = (*ctx.style_of(theme)).clone();
+ let flag = egui::Id::new("stemd-fonts-installed");
+ if !ctx.data(|d| d.get_temp::(flag).unwrap_or(false)) {
+ install_fonts(ctx);
+ ctx.data_mut(|d| d.insert_temp(flag, true));
+ }
+ ctx.set_theme(egui::ThemePreference::Dark);
- style.spacing.item_spacing = egui::vec2(8.0, 8.0);
- style.spacing.button_padding = egui::vec2(10.0, 5.0);
- style.spacing.window_margin = egui::Margin::same(12);
- style.spacing.menu_margin = egui::Margin::same(8);
- style.spacing.interact_size.y = 24.0;
+ let palette = Palette::navy();
+ let mut style = (*ctx.style_of(egui::Theme::Dark)).clone();
+
+ style.text_styles = [
+ (egui::TextStyle::Small, font(11.0, Weight::Regular)),
+ (egui::TextStyle::Body, font(13.0, Weight::Regular)),
+ (egui::TextStyle::Button, font(13.0, Weight::Medium)),
+ (egui::TextStyle::Heading, font(18.0, Weight::Semibold)),
+ (egui::TextStyle::Monospace, FontId::monospace(11.0)),
+ ]
+ .into();
+ style.spacing.item_spacing = vec2(8.0, 8.0);
+ style.spacing.button_padding = vec2(14.0, 6.0);
+ style.spacing.window_margin = egui::Margin::same(16);
+ style.spacing.menu_margin = egui::Margin::same(6);
+ style.spacing.interact_size.y = 26.0;
let visuals = &mut style.visuals;
- visuals.panel_fill = palette.window;
- visuals.window_fill = palette.card;
- visuals.extreme_bg_color = palette.window;
+ visuals.dark_mode = true;
+ visuals.panel_fill = Color32::TRANSPARENT;
+ visuals.window_fill = palette.menu;
+ visuals.extreme_bg_color = black(0.3);
+ visuals.faint_bg_color = white(0.04);
visuals.override_text_color = Some(palette.text);
- visuals.hyperlink_color = palette.accent;
- visuals.selection.bg_fill = Palette::wash(palette.accent, 0x55);
+ visuals.hyperlink_color = palette.blue;
+ visuals.selection.bg_fill = Color32::from_rgba_unmultiplied(91, 132, 216, 0x66);
visuals.selection.stroke = Stroke::new(1.0, palette.text);
- visuals.window_corner_radius = CornerRadius::same(CARD_RADIUS);
- visuals.menu_corner_radius = CornerRadius::same(CARD_RADIUS);
- visuals.window_stroke = Stroke::new(1.0, palette.outline);
- visuals.window_shadow = egui::epaint::Shadow {
- offset: [0, 8],
- blur: 24,
+ visuals.window_corner_radius = CornerRadius::same(14);
+ visuals.menu_corner_radius = CornerRadius::same(14);
+ visuals.window_stroke = Stroke::new(1.0, palette.menu_outline);
+ visuals.window_shadow = Shadow {
+ offset: [0, 16],
+ blur: 36,
spread: 0,
- color: Color32::from_black_alpha(if visuals.dark_mode { 0x66 } else { 0x22 }),
+ color: black(0.5),
};
visuals.popup_shadow = visuals.window_shadow;
@@ -196,17 +630,235 @@ pub fn install(ctx: &egui::Context) {
&mut visuals.widgets.open,
&mut visuals.widgets.noninteractive,
] {
- widget.corner_radius = CornerRadius::same(7);
- widget.bg_stroke = Stroke::new(1.0, palette.outline);
+ widget.corner_radius = CornerRadius::same(9);
+ widget.bg_stroke = Stroke::new(1.0, white(0.14));
widget.fg_stroke.color = palette.text;
}
- visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, palette.outline);
- visuals.widgets.inactive.weak_bg_fill = palette.card;
- visuals.widgets.hovered.weak_bg_fill = palette.card_hover;
- visuals.widgets.active.weak_bg_fill = palette.card_hover;
- visuals.widgets.open.weak_bg_fill = palette.card_hover;
+ visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, palette.hairline);
+ visuals.widgets.inactive.weak_bg_fill = white(0.08);
+ visuals.widgets.inactive.bg_fill = white(0.08);
+ visuals.widgets.hovered.weak_bg_fill = white(0.16);
+ visuals.widgets.hovered.bg_fill = white(0.16);
+ visuals.widgets.active.weak_bg_fill = white(0.20);
+ visuals.widgets.active.bg_fill = white(0.20);
+ visuals.widgets.open.weak_bg_fill = white(0.16);
+
+ ctx.set_style_of(egui::Theme::Dark, style);
+}
+
+// ---------------------------------------------------------------------------
+// Baked images.
+
+#[derive(Clone, Copy)]
+struct Rgba {
+ r: f32,
+ g: f32,
+ b: f32,
+ a: f32,
+}
+
+impl Rgba {
+ const fn hex(rgb: u32, a: f32) -> Self {
+ Self {
+ r: ((rgb >> 16) & 0xFF) as f32 / 255.0,
+ g: ((rgb >> 8) & 0xFF) as f32 / 255.0,
+ b: (rgb & 0xFF) as f32 / 255.0,
+ a,
+ }
+ }
+
+ fn lerp(self, other: Self, t: f32) -> Self {
+ Self {
+ r: self.r + (other.r - self.r) * t,
+ g: self.g + (other.g - self.g) * t,
+ b: self.b + (other.b - self.b) * t,
+ a: self.a + (other.a - self.a) * t,
+ }
+ }
+
+ /// Source-over onto an opaque `below`.
+ fn over(self, below: Self) -> Self {
+ Self {
+ r: self.r * self.a + below.r * (1.0 - self.a),
+ g: self.g * self.a + below.g * (1.0 - self.a),
+ b: self.b * self.a + below.b * (1.0 - self.a),
+ a: 1.0,
+ }
+ }
+
+ fn colour(self) -> Color32 {
+ let c = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
+ Color32::from_rgba_unmultiplied(c(self.r), c(self.g), c(self.b), c(self.a))
+ }
+}
+
+/// Position `t` along gradient `stops` (offset, colour), clamped at both ends.
+fn gradient(stops: &[(f32, Rgba)], t: f32) -> Rgba {
+ let (first, last) = (stops[0], stops[stops.len() - 1]);
+ if t <= first.0 {
+ return first.1;
+ }
+ for pair in stops.windows(2) {
+ let ((a, ca), (b, cb)) = (pair[0], pair[1]);
+ if t <= b {
+ return ca.lerp(cb, (t - a) / (b - a));
+ }
+ }
+ last.1
+}
+
+/// The window background: a navy radial gradient under three faint colour
+/// glows (red top left, blue top right, green from below).
+///
+/// Sized in fractions of the window, so it stretches with the window.
+fn backdrop(size: usize) -> egui::ColorImage {
+ let base = [
+ (0.0, Rgba::hex(0x27335B, 1.0)),
+ (0.46, Rgba::hex(0x161D38, 1.0)),
+ (1.0, Rgba::hex(0x0A0E1B, 1.0)),
+ ];
+ // Centre, radii, colour and alpha, bottom layer first.
+ let glows = [
+ (0.50, 1.12, 0.80, 0.50, 0x6FB47C, 0.12),
+ (0.96, 0.04, 0.70, 0.50, 0x5B84D8, 0.22),
+ (0.06, 0.00, 0.70, 0.50, 0xD4685F, 0.16),
+ ];
+ let ellipse = |u: f32, v: f32, cx: f32, cy: f32, rx: f32, ry: f32| {
+ (((u - cx) / rx).powi(2) + ((v - cy) / ry).powi(2)).sqrt()
+ };
+ let mut pixels = Vec::with_capacity(size * size);
+ for y in 0..size {
+ for x in 0..size {
+ let u = (x as f32 + 0.5) / size as f32;
+ let v = (y as f32 + 0.5) / size as f32;
+ let mut colour = gradient(&base, ellipse(u, v, 0.5, 0.18, 1.2, 0.85));
+ for (cx, cy, rx, ry, rgb, alpha) in glows {
+ let t = ellipse(u, v, cx, cy, rx, ry) / 0.7;
+ let a = alpha * (1.0 - t).max(0.0);
+ colour = Rgba {
+ a,
+ ..Rgba::hex(rgb, a)
+ }
+ .over(colour);
+ }
+ pixels.push(colour.colour());
+ }
+ }
+ egui::ColorImage::new([size, size], pixels)
+}
+
+/// Pastel red, green, blue and back to red, so it tiles.
+fn sheen(width: usize) -> egui::ColorImage {
+ let stops = [
+ (0.0, Rgba::hex(0xF3B9B3, 1.0)),
+ (0.33, Rgba::hex(0xC4E8CB, 1.0)),
+ (0.66, Rgba::hex(0xBBCEF7, 1.0)),
+ (1.0, Rgba::hex(0xF3B9B3, 1.0)),
+ ];
+ let pixels = (0..width)
+ .map(|x| gradient(&stops, x as f32 / width as f32).colour())
+ .collect();
+ egui::ColorImage::new([width, 1], pixels)
+}
+
+/// Three texels: the slice colours, sampled from the first centre to the last.
+fn spectrum() -> egui::ColorImage {
+ let palette = Palette::navy();
+ egui::ColorImage::new([3, 1], vec![palette.red, palette.green, palette.blue])
+}
+
+/// `linear-gradient(180deg, rgba(255,255,255,.4) 0%, transparent 60%)`.
+fn gloss(height: usize) -> egui::ColorImage {
+ let pixels = (0..height)
+ .map(|y| {
+ let t = (y as f32 + 0.5) / height as f32;
+ white(0.4 * (1.0 - t / 0.6).max(0.0))
+ })
+ .collect();
+ egui::ColorImage::new([1, height], pixels)
+}
+
+/// The globe's shading over the bounding square of its circle: white top left,
+/// clear through the middle, darker at the far edge.
+///
+/// SVG `radialGradient cx=.36 cy=.26 r=.85` in that square.
+fn shade(size: usize) -> egui::ColorImage {
+ let mut pixels = Vec::with_capacity(size * size);
+ for y in 0..size {
+ for x in 0..size {
+ let u = (x as f32 + 0.5) / size as f32;
+ let v = (y as f32 + 0.5) / size as f32;
+ let t = ((u - 0.36).powi(2) + (v - 0.26).powi(2)).sqrt() / 0.85;
+ pixels.push(if t < 0.5 {
+ white(0.34 * (1.0 - t / 0.5))
+ } else {
+ black(0.3 * ((t - 0.5) / 0.5).min(1.0))
+ });
+ }
+ }
+ egui::ColorImage::new([size, size], pixels)
+}
- ctx.set_style_of(theme, style);
+/// The standard normal CDF, close enough for a blur edge.
+fn phi(x: f32) -> f32 {
+ // Abramowitz and Stegun 7.1.26 on erf.
+ let z = x / std::f32::consts::SQRT_2;
+ let t = 1.0 / (1.0 + 0.327_591_1 * z.abs());
+ let poly = t
+ * (0.254_829_6
+ + t * (-0.284_496_7 + t * (1.421_413_7 + t * (-1.453_152 + t * 1.061_405_4))));
+ let erf = 1.0 - poly * (-z * z).exp();
+ 0.5 * (1.0 + erf.copysign(z))
+}
+
+/// Fraction of the shadow image's half-size that the unblurred disc covers.
+pub const SHADOW_DISC: f32 = 0.6;
+
+/// A black disc of radius `SHADOW_DISC` with a Gaussian edge filling the rest
+/// of the image over three deviations.
+fn shadow_disc(size: usize) -> egui::ColorImage {
+ let half = size as f32 / 2.0;
+ let sigma = (1.0 - SHADOW_DISC) / 3.0;
+ let mut pixels = Vec::with_capacity(size * size);
+ for y in 0..size {
+ for x in 0..size {
+ let dx = (x as f32 + 0.5 - half) / half;
+ let dy = (y as f32 + 0.5 - half) / half;
+ let d = (dx * dx + dy * dy).sqrt();
+ pixels.push(black(phi((SHADOW_DISC - d) / sigma)));
+ }
+ }
+ egui::ColorImage::new([size, size], pixels)
+}
+
+/// Fraction of the halo image's half-size that the unblurred disc covers.
+pub const HALO_DISC: f32 = 0.53;
+
+/// A conic red, green, blue gradient in a disc, blurred at the edge. Full
+/// alpha: the caller fades it.
+fn halo(size: usize) -> egui::ColorImage {
+ let half = size as f32 / 2.0;
+ let sigma = (1.0 - HALO_DISC) / 3.0;
+ let stops = [
+ (0.0, Rgba::hex(0xD4685F, 1.0)),
+ (1.0 / 3.0, Rgba::hex(0x6FB47C, 1.0)),
+ (2.0 / 3.0, Rgba::hex(0x5B84D8, 1.0)),
+ (1.0, Rgba::hex(0xD4685F, 1.0)),
+ ];
+ let mut pixels = Vec::with_capacity(size * size);
+ for y in 0..size {
+ for x in 0..size {
+ let dx = (x as f32 + 0.5 - half) / half;
+ let dy = (y as f32 + 0.5 - half) / half;
+ let d = (dx * dx + dy * dy).sqrt();
+ // Clockwise from twelve o'clock, as CSS `conic-gradient`.
+ let turn = (dx.atan2(-dy) / std::f32::consts::TAU).rem_euclid(1.0);
+ let colour = gradient(&stops, turn);
+ let a = phi((HALO_DISC - d) / sigma);
+ pixels.push(Rgba { a, ..colour }.colour());
+ }
+ }
+ egui::ColorImage::new([size, size], pixels)
}
#[cfg(test)]
@@ -215,68 +867,95 @@ mod tests {
/// A `Color32` whose channels exceed its alpha is additive in epaint, not
/// translucent: it adds its own brightness on top of whatever is behind it.
- ///
- /// `from_rgba_premultiplied(0xFF, 0xFF, 0xFF, 0x0E)` painted the drop zone as a
- /// near-solid white ramp, because full-intensity white was added at every pixel.
- /// `from_rgba_unmultiplied` spells the intended meaning, and nothing about the
- /// wrong version looks wrong in the source.
+ /// `from_rgba_unmultiplied`, `white` and `black` spell the intended meaning.
#[test]
fn no_palette_colour_is_secretly_additive() {
- for (name, palette) in [("dark", Palette::dark()), ("light", Palette::light())] {
- let (top, bottom) = palette.sheen;
- for colour in [palette.outline, top, bottom] {
- let alpha = colour.a();
- for channel in [colour.r(), colour.g(), colour.b()] {
- assert!(
- channel <= alpha,
- "{name}: {colour:?} has a channel above its alpha, so it \
- adds light instead of blending — use from_rgba_unmultiplied"
- );
- }
+ let palette = Palette::navy();
+ let glass = [Glass::Panel, Glass::List]
+ .into_iter()
+ .map(Glass::spec)
+ .flat_map(|g| [g.top, g.bottom, g.outline, g.highlight])
+ .map(white);
+ for colour in palette.translucent().into_iter().chain(glass) {
+ let alpha = colour.a();
+ for channel in [colour.r(), colour.g(), colour.b()] {
+ assert!(
+ channel <= alpha,
+ "{colour:?} has a channel above its alpha, so it adds light \
+ instead of blending"
+ );
}
}
}
- /// Both appearances have to define every colour: a `None` here would be a
- /// hole in one theme that the other hides.
+ /// The baked images carry the same guarantee, texel by texel.
#[test]
- fn the_two_appearances_are_genuinely_different() {
- let (dark, light) = (Palette::dark(), Palette::light());
- assert_ne!(dark.window, light.window);
- assert_ne!(dark.text, light.text);
- assert_ne!(dark.card, light.card);
+ fn no_baked_texel_is_additive() {
+ for (name, image) in [
+ ("gloss", gloss(16)),
+ ("shade", shade(32)),
+ ("shadow", shadow_disc(32)),
+ ("halo", halo(32)),
+ ] {
+ for texel in &image.pixels {
+ assert!(
+ texel.r() <= texel.a() && texel.g() <= texel.a() && texel.b() <= texel.a(),
+ "{name}: {texel:?} is additive"
+ );
+ }
+ }
}
- /// Text has to stay readable on the surface it sits on. Not a contrast
- /// audit, just a guard against a palette edit that swaps a pair.
+ /// Text has to stay readable on the darkest and lightest part of the backdrop.
#[test]
fn text_is_not_the_colour_of_what_it_sits_on() {
- for palette in [Palette::dark(), Palette::light()] {
- for surface in [palette.window, palette.card, palette.card_hover] {
- let gap = i32::from(palette.text.r()) - i32::from(surface.r());
- assert!(gap.abs() > 60, "text on {surface:?} is too close to it");
+ let palette = Palette::navy();
+ let image = backdrop(32);
+ for texel in &image.pixels {
+ for text in [palette.text, palette.muted] {
+ let gap = i32::from(text.g()) - i32::from(texel.g());
+ assert!(gap > 100, "{text:?} on {texel:?} is too close to it");
}
}
}
- /// `Color32` stores premultiplied in eight bits, so a wash cannot be exact:
- /// a dark colour at low alpha is quantised on the way in and does not
- /// unmultiply back to itself. Close is the guarantee, and all a tint needs.
#[test]
- fn a_wash_keeps_the_colour_and_only_drops_the_alpha() {
- let colour = Color32::from_rgb(10, 20, 30);
- assert_eq!(
- Palette::wash(colour, 0xFF),
- colour,
- "opaque changes nothing"
+ fn the_backdrop_is_lightest_near_the_top_centre() {
+ let image = backdrop(64);
+ let at = |x: usize, y: usize| image.pixels[y * 64 + x];
+ let top = at(32, 11);
+ let bottom = at(32, 63);
+ assert!(
+ top.b() > bottom.b(),
+ "{top:?} is not lighter than {bottom:?}"
);
+ }
- let faint = Palette::wash(colour, 0x40);
- assert_eq!(faint.a(), 0x40, "the alpha asked for is the alpha stored");
- let back = faint.to_srgba_unmultiplied();
- for (channel, want) in back.iter().zip([10, 20, 30]) {
- let drift = i32::from(*channel) - want;
- assert!(drift.abs() <= 4, "{back:?} is not the colour that went in");
+ #[test]
+ fn css_easings_hit_their_ends_and_known_points() {
+ for ease in [ease_spring, ease_out, ease_in, ease_in_out] {
+ assert!(ease(0.0).abs() < 1e-3);
+ assert!((ease(1.0) - 1.0).abs() < 1e-3);
}
+ // ease-in-out is symmetric about its middle.
+ assert!((ease_in_out(0.5) - 0.5).abs() < 1e-3);
+ // The spring is well past half by a third of the way.
+ assert!(ease_spring(0.33) > 0.7);
+ }
+
+ #[test]
+ fn a_rounded_outline_closes_and_stays_inside_its_rect() {
+ let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(200.0, 80.0));
+ let points = rounded_outline(rect, 22.0, 8);
+ assert_eq!(points.first(), points.last());
+ for p in &points {
+ assert!(rect.expand(0.01).contains(*p), "{p:?} is outside {rect:?}");
+ }
+ }
+
+ #[test]
+ fn the_normal_cdf_is_a_cdf() {
+ assert!((phi(0.0) - 0.5).abs() < 1e-4);
+ assert!(phi(3.0) > 0.998 && phi(-3.0) < 0.002);
}
}
diff --git a/crates/stemd-server/src/ui/titlebar.rs b/crates/stemd-server/src/ui/titlebar.rs
new file mode 100644
index 0000000..ffb2c7f
--- /dev/null
+++ b/crates/stemd-server/src/ui/titlebar.rs
@@ -0,0 +1,253 @@
+//! The window's own title bar on macOS and Windows: the name and version centred
+//! and, on Windows, the window buttons on the right. macOS keeps its traffic
+//! lights. Linux keeps the system's title bar.
+
+use eframe::egui::{self, Rect, pos2, vec2};
+
+use super::theme::{self, Palette, Weight};
+
+/// Height of the bar. The content starts under it.
+pub const HEIGHT: f32 = if cfg!(target_os = "macos") {
+ 28.0
+} else if cfg!(windows) {
+ 32.0
+} else {
+ 0.0
+};
+
+/// Centre line of the name: level with the traffic lights on macOS.
+const TEXT_Y: f32 = if cfg!(target_os = "macos") {
+ 16.0
+} else {
+ HEIGHT / 2.0
+};
+
+/// Draw the bar over the top of the window. Called last, so its controls sit
+/// above everything else.
+pub fn show(ui: &mut egui::Ui, palette: &Palette) {
+ if HEIGHT == 0.0 {
+ return;
+ }
+ let ctx = ui.ctx().clone();
+ let screen = ctx.content_rect();
+ let bar = Rect::from_min_size(screen.min, vec2(screen.width(), HEIGHT));
+ let focused = ctx.input(|i| i.viewport().focused.unwrap_or(true));
+ let dim = if focused { 1.0 } else { 0.5 };
+
+ let painter = ui.painter();
+ let name = painter.layout_no_wrap(
+ "Stemd".to_owned(),
+ theme::font(12.0, Weight::Semibold),
+ palette.text.gamma_multiply(dim),
+ );
+ let version = painter.layout_no_wrap(
+ format!("v{}", env!("CARGO_PKG_VERSION")),
+ theme::font(12.0, Weight::Regular),
+ palette.muted.gamma_multiply(dim),
+ );
+ let y = bar.top() + TEXT_Y;
+ let width = name.size().x + 6.0 + version.size().x;
+ // Centred, as macOS draws window titles.
+ let x = bar.center().x - width / 2.0;
+ let version_x = x + name.size().x + 6.0;
+ painter.galley(
+ pos2(x, y - name.size().y / 2.0),
+ name,
+ palette.text.gamma_multiply(dim),
+ );
+ painter.galley(
+ pos2(version_x, y - version.size().y / 2.0),
+ version,
+ palette.muted.gamma_multiply(dim),
+ );
+
+ #[cfg(windows)]
+ windows::controls(ui, &ctx, bar, screen, palette, dim);
+}
+
+#[cfg(windows)]
+mod windows {
+ use eframe::egui::{
+ self, Color32, CursorIcon, PointerButton, Rect, Sense, Stroke, ViewportCommand, pos2, vec2,
+ viewport::ResizeDirection,
+ };
+
+ use super::super::theme::{Palette, white};
+ use super::HEIGHT;
+
+ /// Size of one window button, as Windows 11 draws them.
+ const BUTTON: egui::Vec2 = vec2(46.0, HEIGHT);
+
+ /// Width of the resize grip along each edge, and of each corner.
+ const EDGE: f32 = 5.0;
+ const CORNER: f32 = 12.0;
+
+ /// The red Windows fills the close button with under the pointer.
+ const CLOSE_RED: Color32 = Color32::from_rgb(0xC4, 0x2B, 0x1C);
+
+ #[derive(Clone, Copy, PartialEq, Eq)]
+ enum Glyph {
+ Minimize,
+ Maximize,
+ Restore,
+ Close,
+ }
+
+ pub fn controls(
+ ui: &mut egui::Ui,
+ ctx: &egui::Context,
+ bar: Rect,
+ screen: Rect,
+ palette: &Palette,
+ dim: f32,
+ ) {
+ let maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
+ let close = Rect::from_min_size(pos2(bar.right() - BUTTON.x, bar.top()), BUTTON);
+ let middle = close.translate(vec2(-BUTTON.x, 0.0));
+ let minimize = middle.translate(vec2(-BUTTON.x, 0.0));
+
+ // The rest of the bar moves the window; a double click maximizes it.
+ let drag = Rect::from_min_max(bar.min, pos2(minimize.left(), bar.bottom()));
+ let moved = ui.interact(drag, ui.id().with("title-drag"), Sense::click_and_drag());
+ if moved.double_clicked() {
+ ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized));
+ } else if moved.drag_started_by(PointerButton::Primary) {
+ ctx.send_viewport_cmd(ViewportCommand::StartDrag);
+ }
+
+ if button(ui, minimize, Glyph::Minimize, palette, dim) {
+ ctx.send_viewport_cmd(ViewportCommand::Minimized(true));
+ }
+ let glyph = if maximized {
+ Glyph::Restore
+ } else {
+ Glyph::Maximize
+ };
+ if button(ui, middle, glyph, palette, dim) {
+ ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized));
+ }
+ if button(ui, close, Glyph::Close, palette, dim) {
+ ctx.send_viewport_cmd(ViewportCommand::Close);
+ }
+
+ if !maximized {
+ resize_edges(ui, ctx, screen);
+ }
+ }
+
+ fn button(ui: &mut egui::Ui, rect: Rect, glyph: Glyph, palette: &Palette, dim: f32) -> bool {
+ let id = ui.id().with(("title-button", glyph as u8));
+ let response = ui.interact(rect, id, Sense::click());
+ let hover = super::super::widgets::fade(ui, id, response.hovered());
+ let pressed = response.is_pointer_button_down_on();
+ let painter = ui.painter();
+ let mut ink = palette.text.gamma_multiply(dim);
+ if glyph == Glyph::Close {
+ if hover > 0.0 {
+ let alpha = if pressed { 0.85 } else { 1.0 };
+ painter.rect_filled(rect, 0.0, CLOSE_RED.gamma_multiply(hover * alpha));
+ ink = super::super::theme::mix(ink, Color32::WHITE, hover);
+ }
+ } else if hover > 0.0 {
+ let wash = if pressed { 0.05 } else { 0.08 };
+ painter.rect_filled(rect, 0.0, white(wash * hover));
+ }
+ draw(ui, rect.center(), glyph, ink);
+ response.clicked()
+ }
+
+ /// The glyphs Windows 11 draws: 10 points across, one pixel wide, on the
+ /// pixel grid so they stay sharp.
+ fn draw(ui: &egui::Ui, centre: egui::Pos2, glyph: Glyph, ink: Color32) {
+ let ppp = ui.ctx().pixels_per_point();
+ let snap = |v: f32| (v * ppp).round() / ppp + 0.5 / ppp;
+ let c = pos2(snap(centre.x), snap(centre.y));
+ let stroke = Stroke::new(1.0, ink);
+ let painter = ui.painter();
+ let h = 5.0;
+ match glyph {
+ Glyph::Minimize => {
+ painter.line_segment([c + vec2(-h, 0.0), c + vec2(h, 0.0)], stroke);
+ }
+ Glyph::Maximize => {
+ painter.rect_stroke(
+ Rect::from_center_size(c, vec2(2.0 * h, 2.0 * h)),
+ 1.0,
+ stroke,
+ egui::StrokeKind::Middle,
+ );
+ }
+ Glyph::Restore => {
+ let front = Rect::from_min_size(c + vec2(-h, -h + 2.0), vec2(8.0, 8.0));
+ painter.rect_stroke(front, 1.0, stroke, egui::StrokeKind::Middle);
+ painter.line_segment([c + vec2(-h + 2.0, -h), c + vec2(h, -h)], stroke);
+ painter.line_segment([c + vec2(h, -h), c + vec2(h, h - 2.0)], stroke);
+ }
+ Glyph::Close => {
+ painter.line_segment([c + vec2(-h, -h), c + vec2(h, h)], stroke);
+ painter.line_segment([c + vec2(-h, h), c + vec2(h, -h)], stroke);
+ }
+ }
+ }
+
+ /// Grips along the edges and corners. Without the system frame the window
+ /// has no other way to be resized.
+ fn resize_edges(ui: &mut egui::Ui, ctx: &egui::Context, s: Rect) {
+ use CursorIcon as C;
+ use ResizeDirection as D;
+ let zones = [
+ (
+ Rect::from_min_max(s.left_top(), pos2(s.left() + EDGE, s.bottom())),
+ D::West,
+ C::ResizeWest,
+ ),
+ (
+ Rect::from_min_max(pos2(s.right() - EDGE, s.top()), s.right_bottom()),
+ D::East,
+ C::ResizeEast,
+ ),
+ (
+ Rect::from_min_max(s.left_top(), pos2(s.right(), s.top() + EDGE)),
+ D::North,
+ C::ResizeNorth,
+ ),
+ (
+ Rect::from_min_max(pos2(s.left(), s.bottom() - EDGE), s.right_bottom()),
+ D::South,
+ C::ResizeSouth,
+ ),
+ (
+ Rect::from_min_size(s.left_top(), vec2(CORNER, CORNER)),
+ D::NorthWest,
+ C::ResizeNorthWest,
+ ),
+ (
+ Rect::from_min_size(pos2(s.right() - CORNER, s.top()), vec2(CORNER, CORNER)),
+ D::NorthEast,
+ C::ResizeNorthEast,
+ ),
+ (
+ Rect::from_min_size(pos2(s.left(), s.bottom() - CORNER), vec2(CORNER, CORNER)),
+ D::SouthWest,
+ C::ResizeSouthWest,
+ ),
+ (
+ Rect::from_min_size(
+ s.right_bottom() - vec2(CORNER, CORNER),
+ vec2(CORNER, CORNER),
+ ),
+ D::SouthEast,
+ C::ResizeSouthEast,
+ ),
+ ];
+ let pressed = ctx.input(|i| i.pointer.primary_pressed());
+ for (n, (rect, direction, cursor)) in zones.into_iter().enumerate() {
+ let response = ui
+ .interact(rect, ui.id().with(("resize", n)), Sense::drag())
+ .on_hover_cursor(cursor);
+ if response.hovered() && pressed {
+ ctx.send_viewport_cmd(ViewportCommand::BeginResize(direction));
+ }
+ }
+ }
+}
diff --git a/crates/stemd-server/src/ui/widgets.rs b/crates/stemd-server/src/ui/widgets.rs
new file mode 100644
index 0000000..ecdf122
--- /dev/null
+++ b/crates/stemd-server/src/ui/widgets.rs
@@ -0,0 +1,267 @@
+//! Painted controls shared across the window: pill buttons and frosted menus.
+
+use eframe::egui::{self, Rect, Sense, Stroke, pos2, vec2};
+
+use super::blur::{self, Frosted};
+use super::icons;
+use super::theme::{self, Brushes, Palette, Weight, white};
+
+/// Seconds a hover wash takes to come and go.
+pub const HOVER_SECS: f32 = 0.15;
+
+/// Inner margin of every menu panel.
+const MENU_MARGIN: i8 = 6;
+
+/// 0 to 1 as `on` turns on and off, for hover washes.
+pub fn fade(ui: &egui::Ui, id: egui::Id, on: bool) -> f32 {
+ ui.ctx()
+ .animate_bool_with_time(id.with("fade"), on, HOVER_SECS)
+}
+
+/// How a pill button is drawn.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum Pill {
+ /// Translucent, with a hairline: the everyday button.
+ Ghost,
+ /// The same, with its label in the danger colour.
+ Danger,
+ /// The light pill: the one answer that matters.
+ Light,
+}
+
+/// Width of a pill that fits `label` at `height`.
+pub fn pill_width(ui: &egui::Ui, label: &str, height: f32) -> f32 {
+ let galley = ui.painter().layout_no_wrap(
+ label.to_owned(),
+ pill_font(height, Pill::Ghost),
+ egui::Color32::PLACEHOLDER,
+ );
+ galley.size().x + height
+}
+
+fn pill_font(height: f32, kind: Pill) -> egui::FontId {
+ let size = if height >= 30.0 { 13.0 } else { 12.0 };
+ let weight = if kind == Pill::Light {
+ Weight::Semibold
+ } else {
+ Weight::Medium
+ };
+ theme::font(size, weight)
+}
+
+/// A pill button filling `rect`. Disabled, it is dimmed and does not respond.
+#[allow(clippy::too_many_arguments)]
+pub fn pill(
+ ui: &egui::Ui,
+ rect: Rect,
+ id: egui::Id,
+ label: &str,
+ kind: Pill,
+ enabled: bool,
+ palette: &Palette,
+ brushes: &Brushes,
+) -> egui::Response {
+ let sense = if enabled {
+ Sense::click()
+ } else {
+ Sense::hover()
+ };
+ let response = ui.interact(rect, id, sense);
+ let hover = fade(ui, id, response.hovered() && enabled);
+ let pressed = ui.ctx().animate_bool_with_time(
+ id.with("press"),
+ response.is_pointer_button_down_on() && enabled,
+ 0.1,
+ );
+ let drawn = Rect::from_center_size(rect.center(), rect.size() * (1.0 - 0.03 * pressed));
+ let mut painter = ui.painter().clone();
+ if !enabled {
+ painter.multiply_opacity(0.45);
+ }
+ let r = drawn.height() / 2.0;
+ let text = match kind {
+ Pill::Light => {
+ brushes.pill(&painter, drawn, r, 1.0);
+ if hover > 0.0 {
+ painter.rect_filled(drawn, r, white(0.08 * hover));
+ }
+ palette.ink
+ }
+ Pill::Ghost | Pill::Danger => {
+ painter.rect_filled(drawn, r, white(0.08 + 0.08 * hover));
+ painter.rect_stroke(
+ drawn,
+ r,
+ Stroke::new(1.0, white(0.14)),
+ egui::StrokeKind::Inside,
+ );
+ painter.add(theme::top_highlight(drawn, r, white(0.2)));
+ if kind == Pill::Danger {
+ palette.danger
+ } else {
+ palette.text
+ }
+ }
+ };
+ painter.text(
+ drawn.center(),
+ egui::Align2::CENTER_CENTER,
+ label,
+ pill_font(rect.height(), kind),
+ text,
+ );
+ if enabled {
+ response.on_hover_cursor(egui::CursorIcon::PointingHand)
+ } else {
+ response
+ }
+}
+
+/// The panel every menu sits on.
+pub const fn menu_look(palette: &Palette) -> Frosted {
+ Frosted {
+ radius: 14.0,
+ margin: MENU_MARGIN,
+ fill: palette.frost,
+ outline: palette.menu_outline,
+ }
+}
+
+/// The panel a dialog sits on.
+pub const fn dialog_look(palette: &Palette) -> Frosted {
+ Frosted {
+ radius: 18.0,
+ margin: 18,
+ fill: palette.frost,
+ outline: palette.menu_outline,
+ }
+}
+
+/// Width of a menu row in a menu `width` wide: the panel's margin and outline
+/// come off both sides.
+pub fn row_width(width: f32) -> f32 {
+ width - 2.0 * f32::from(MENU_MARGIN) - 2.0
+}
+
+/// A frosted menu that opens under `button`, toggled by clicking it.
+pub fn menu(
+ button: &egui::Response,
+ align: egui::RectAlign,
+ close: egui::PopupCloseBehavior,
+ palette: &Palette,
+ add: impl FnOnce(&mut egui::Ui) -> R,
+) -> bool {
+ let popup = egui::Popup::from_toggle_button_response(button)
+ .id(button.id.with("menu"))
+ .anchor(button.rect.expand2(vec2(4.0, 0.0)))
+ .gap(10.0)
+ .align(align)
+ .frame(egui::Frame::NONE)
+ .close_behavior(close);
+ let open = popup.is_open();
+ popup.show(|ui| blur::frosted(ui, &menu_look(palette), add));
+ open
+}
+
+/// One menu row with a hover wash. Returns the response.
+pub fn menu_row(
+ ui: &mut egui::Ui,
+ width: f32,
+ height: f32,
+ id: egui::Id,
+) -> (Rect, egui::Response) {
+ let (rect, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover());
+ let response = ui.interact(rect, id, Sense::click());
+ let hover = fade(ui, id, response.hovered());
+ if hover > 0.0 {
+ ui.painter().rect_filled(rect, 9.0, white(0.10 * hover));
+ }
+ (
+ rect,
+ response.on_hover_cursor(egui::CursorIcon::PointingHand),
+ )
+}
+
+/// A list of labels with a tick on the current one, in a menu `width` wide.
+/// Each item is a label, whether it is current, and a hover hint (empty for
+/// none). Returns the index clicked.
+pub fn pick_menu(
+ ui: &mut egui::Ui,
+ width: f32,
+ labels: &[(String, bool, &'static str)],
+ palette: &Palette,
+) -> Option {
+ ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
+ let inner = row_width(width);
+ let mut picked = None;
+ for (n, (label, on, hint)) in labels.iter().enumerate() {
+ let (rect, response) = menu_row(ui, inner, 32.0, ui.id().with(("pick", n)));
+ let painter = ui.painter();
+ painter.text(
+ pos2(rect.left() + 10.0, rect.center().y),
+ egui::Align2::LEFT_CENTER,
+ label,
+ theme::font(13.0, Weight::Regular),
+ palette.text,
+ );
+ if *on {
+ icons::check(
+ painter,
+ icons::square(pos2(rect.right() - 8.0 - 5.5, rect.center().y), 11.0),
+ palette.text,
+ 1.6,
+ );
+ }
+ let response = if hint.is_empty() {
+ response
+ } else {
+ response.on_hover_text(*hint)
+ };
+ if response.clicked() {
+ picked = Some(n);
+ }
+ }
+ picked
+}
+
+/// Rows stacked top to bottom, each with a gap under it. Rows are added once
+/// and their offsets kept, so the block's height and the rows' positions come
+/// from the same numbers.
+#[derive(Default)]
+pub struct Column {
+ used: f32,
+ last_gap: f32,
+}
+
+impl Column {
+ /// Add a row; returns its offset from the top.
+ pub fn add(&mut self, height: f32, gap_after: f32) -> f32 {
+ let at = self.used;
+ self.used += height + gap_after;
+ self.last_gap = gap_after;
+ at
+ }
+
+ /// Offset of the next row to be added.
+ pub const fn next(&self) -> f32 {
+ self.used
+ }
+
+ /// Height of the block, without the gap under its last row.
+ pub fn height(&self) -> f32 {
+ self.used - self.last_gap
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_column_places_rows_and_leaves_off_the_last_gap() {
+ let mut column = Column::default();
+ assert_eq!(column.add(10.0, 4.0), 0.0);
+ assert_eq!(column.add(20.0, 6.0), 14.0);
+ assert_eq!(column.height(), 34.0);
+ }
+}
diff --git a/docs/building.md b/docs/building.md
index 2603513..fc6423d 100644
--- a/docs/building.md
+++ b/docs/building.md
@@ -16,8 +16,8 @@ the fix for a clone that forgot. It is a fork rather than a version because
0.25.3 and 0.2.0 are the newest published `mlx-rs` and `mlx-sys`, and neither
declares a CUDA feature.
-Everywhere: `cmake` on `PATH`, and a Rust new enough for edition 2024, so 1.85
-or later. MLX builds from source on the first `cargo build`.
+Everywhere: `cmake` on `PATH`, and Rust 1.89 or later. MLX builds from source on
+the first `cargo build`.
## Per platform
@@ -88,7 +88,7 @@ one compiles and then finds no execution plan for any graph at run time.
## Packaging
```bash
-./scripts/bundle-app.sh # macOS: dist/stemd.app, about 29 MB
+./scripts/bundle-app.sh # macOS: dist/Stemd.app, about 29 MB
./scripts/bundle.sh # macOS: a signed, notarized, stapled .dmg
./scripts/bundle-windows.ps1 # Windows: dist/stemd-windows
./scripts/package-windows.ps1 # Windows: an installer and a zip
@@ -103,10 +103,10 @@ released artefacts use the lists in `packaging/mlx-prebuilt.env`; five
architectures take about two and a half hours on sixteen cores.
The Windows installer offers both modes. Just for me, into
-`%LOCALAPPDATA%\Programs\stemd`, is the default; for all users goes to Program
-Files. `Programs\stemd` rather than `stemd` because `%LOCALAPPDATA%\stemd` is
-where the program keeps its weights and its cache, and an uninstaller pointed at
-that directory would take a gigabyte of downloads with it.
+`%LOCALAPPDATA%\Programs\Stemd`, is the default; for all users goes to Program
+Files. It is under `Programs` because `%LOCALAPPDATA%\stemd` is where the
+program keeps its weights and its cache, and an uninstaller pointed at that
+directory would take a gigabyte of downloads with it.
CUDA is what the two modes actually differ in. `install-cuda.cmd` writes about
1.2 GB beside the executable, which a per-user install can do and Program Files
diff --git a/packaging/linux/THIRD-PARTY b/packaging/linux/THIRD-PARTY
index 7969b1c..2f3a2cb 100644
--- a/packaging/linux/THIRD-PARTY
+++ b/packaging/linux/THIRD-PARTY
@@ -29,6 +29,10 @@ libopenblas*, libgfortran*, libquadmath*)
Avahi compatibility library (lib/libdns_sd*, libavahi-*)
LGPL-2.1-or-later. Taken from Ubuntu 22.04.
+Instrument Sans, compiled into bin/stemd-server
+ Copyright 2022 The Instrument Sans Project Authors. SIL Open Font License 1.1:
+ https://openfontlicense.org
+
LAME 3.100, statically linked into bin/stemd-server
LGPL-2.0 (GNU Library General Public License version 2).
Source: https://lame.sourceforge.io/
diff --git a/packaging/linux/stemd.desktop b/packaging/linux/stemd.desktop
index f307110..9e77469 100644
--- a/packaging/linux/stemd.desktop
+++ b/packaging/linux/stemd.desktop
@@ -1,6 +1,6 @@
[Desktop Entry]
Type=Application
-Name=stemd
+Name=Stemd
GenericName=Stem separation
Comment=Separate a track into stems on the GPU
Exec=stemd-server
diff --git a/resources/fonts/InstrumentSans-OFL.txt b/resources/fonts/InstrumentSans-OFL.txt
new file mode 100644
index 0000000..26bd2f9
--- /dev/null
+++ b/resources/fonts/InstrumentSans-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/resources/fonts/InstrumentSans.ttf b/resources/fonts/InstrumentSans.ttf
new file mode 100644
index 0000000..3589b81
Binary files /dev/null and b/resources/fonts/InstrumentSans.ttf differ
diff --git a/resources/screenshot.png b/resources/screenshot.png
index a2218d0..d3d2992 100644
Binary files a/resources/screenshot.png and b/resources/screenshot.png differ
diff --git a/scripts/bundle-app.sh b/scripts/bundle-app.sh
index 9c5ef24..7594205 100755
--- a/scripts/bundle-app.sh
+++ b/scripts/bundle-app.sh
@@ -1,5 +1,5 @@
#!/bin/bash
-# Assemble stemd.app.
+# Assemble Stemd.app.
#
# One thing makes this more than a copy: weights are fetched on first run into
# Application Support, so the bundle stays small. STEMD_EMBED_MODELS puts them
@@ -14,7 +14,7 @@
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-APP="${1:-$ROOT/dist/stemd.app}"
+APP="${1:-$ROOT/dist/Stemd.app}"
MODELS="$ROOT/models"
VERSION="$(sed -n 's/^version *= *"\(.*\)"/\1/p' "$ROOT/Cargo.toml" | head -1)"
@@ -172,8 +172,8 @@ cat > "$APP/Contents/Info.plist" <CFBundleExecutable stemd
$ICON_PLIST
CFBundleIdentifier io.nasper.stemd
- CFBundleName stemd
- CFBundleDisplayName stemd
+ CFBundleName Stemd
+ CFBundleDisplayName Stemd
CFBundlePackageType APPL
CFBundleShortVersionString $VERSION
CFBundleVersion $VERSION
@@ -181,7 +181,7 @@ $ICON_PLIST
NSHighResolutionCapable
NSLocalNetworkUsageDescription
- stemd advertises itself so players on this network can find it.
+ Stemd advertises itself so players on this network can find it.
PLIST
diff --git a/scripts/bundle.sh b/scripts/bundle.sh
index ac8d68c..e022562 100755
--- a/scripts/bundle.sh
+++ b/scripts/bundle.sh
@@ -1,5 +1,5 @@
#!/bin/bash
-# Wrap stemd.app into a signed, notarized, stapled .dmg.
+# Wrap Stemd.app into a signed, notarized, stapled .dmg.
#
# Needs a keychain profile, made once:
# xcrun notarytool store-credentials stemd-notary \
@@ -10,10 +10,10 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DIST="$ROOT/dist"
-APP="$DIST/stemd.app"
+APP="$DIST/Stemd.app"
VERSION="$(sed -n 's/^version *= *"\(.*\)"/\1/p' "$ROOT/Cargo.toml" | head -1)"
DMG="$DIST/stemd-$VERSION-macos-arm64.dmg"
-VOLNAME="stemd $VERSION"
+VOLNAME="Stemd $VERSION"
# The image's window is the artwork's. resources/stemd-dmg.png is the 2x
# rendition of a 640 by 400 window with two placeholder squares drawn on it,
@@ -157,7 +157,7 @@ on run argv
set text size of opts to 12
set label position of opts to bottom
set background picture of opts to file ".background:stemd.tiff"
- set position of item "stemd.app" of container window to {appX, appY}
+ set position of item "Stemd.app" of container window to {appX, appY}
set position of item "Applications" of container window to {appsX, appsY}
close
open
diff --git a/scripts/installer-windows.nsi b/scripts/installer-windows.nsi
index c42d58d..eb28549 100644
--- a/scripts/installer-windows.nsi
+++ b/scripts/installer-windows.nsi
@@ -1,10 +1,10 @@
; The Windows installer, built from a staged bundle by package-windows.ps1.
;
; Two modes, chosen on the first page. Just for me, into
-; %LOCALAPPDATA%\Programs\stemd, is the default and needs no administrator. For
+; %LOCALAPPDATA%\Programs\Stemd, is the default and needs no administrator. For
; all users goes to Program Files and does.
;
-; The per-user directory has to be `Programs\stemd` and not `stemd`, because
+; The per-user directory has to be `Programs\Stemd` and not `Stemd`, because
; %LOCALAPPDATA%\stemd is where the program keeps its weights and its cache, and
; an uninstaller pointed at that directory would take a gigabyte the user
; downloaded with it. MultiUser resolves FOLDERID_UserProgramFiles, so that is
@@ -37,7 +37,7 @@ SetCompressor /SOLID lzma
!error "ICON is not defined; it names the .ico to brand this with"
!endif
-!define NAME "stemd"
+!define NAME "Stemd"
!define PUBLISHER "nsaintot"
!define HOMEPAGE "https://github.com/nsaintot/stemd"
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\stemd"
@@ -73,7 +73,7 @@ VIProductVersion "${VERSION}.0"
VIAddVersionKey "ProductName" "${NAME}"
VIAddVersionKey "ProductVersion" "${VERSION}"
VIAddVersionKey "FileVersion" "${VERSION}.0"
-VIAddVersionKey "FileDescription" "stemd installer"
+VIAddVersionKey "FileDescription" "Stemd installer"
VIAddVersionKey "CompanyName" "${PUBLISHER}"
VIAddVersionKey "LegalCopyright" "MIT OR Apache-2.0"
@@ -82,7 +82,7 @@ VIAddVersionKey "LegalCopyright" "MIT OR Apache-2.0"
!define MUI_ABORTWARNING
!define MUI_FINISHPAGE_RUN
-!define MUI_FINISHPAGE_RUN_TEXT "Start stemd"
+!define MUI_FINISHPAGE_RUN_TEXT "Start Stemd"
!define MUI_FINISHPAGE_RUN_FUNCTION LaunchStemd
!define MUI_FINISHPAGE_LINK "github.com/nsaintot/stemd"
!define MUI_FINISHPAGE_LINK_LOCATION "${HOMEPAGE}"
@@ -114,7 +114,7 @@ Function WriteElevatingCudaCmd
FileWrite $0 "@echo off$\r$\n"
FileWrite $0 "rem Fetch the CUDA runtime beside stemd-server.exe. About 1.2 GB,$\r$\n"
FileWrite $0 "rem once, and only useful on a machine with an NVIDIA card. This copy$\r$\n"
- FileWrite $0 "rem of stemd is installed for all users, so the libraries land where$\r$\n"
+ FileWrite $0 "rem of Stemd is installed for all users, so the libraries land where$\r$\n"
FileWrite $0 "rem writing needs an administrator.$\r$\n"
FileWrite $0 "net session >nul 2>&1$\r$\n"
FileWrite $0 "if errorlevel 1 ($\r$\n"
@@ -126,7 +126,7 @@ Function WriteElevatingCudaCmd
FileClose $0
FunctionEnd
-Section "stemd" SecMain
+Section "Stemd" SecMain
SectionIn RO
SetOutPath "$INSTDIR"
; Overwritten in place rather than uninstalled first: a previous install may
@@ -140,7 +140,11 @@ Section "stemd" SecMain
${EndIf}
; SHCTX and $SMPROGRAMS both follow the mode, so this is the all-users Start
- ; menu for an all-users install and this account's for the other.
+ ; menu for an all-users install and this account's for the other. Removed
+ ; first: Windows keeps a name's old case when a file is overwritten, so an
+ ; upgrade would keep the lowercase shortcut.
+ Delete "$SMPROGRAMS\${NAME}\${NAME}.lnk"
+ RMDir "$SMPROGRAMS\${NAME}"
CreateDirectory "$SMPROGRAMS\${NAME}"
CreateShortCut "$SMPROGRAMS\${NAME}\${NAME}.lnk" "$INSTDIR\stemd-server.exe" \
"" "$INSTDIR\stemd-server.exe" 0
@@ -171,20 +175,20 @@ SectionEnd
; difference is a separation in seconds instead of minutes. A failed download
; leaves a working program on the CPU and the script to try again.
Section "GPU support (CUDA runtime, 1.2 GB download)" SecCuda
- DetailPrint "Fetching the CUDA runtime beside stemd, about 1.2 GB..."
+ DetailPrint "Fetching the CUDA runtime beside Stemd, about 1.2 GB..."
nsExec::ExecToLog '"$INSTDIR\stemd-server.exe" --install-cuda'
Pop $0
${If} $0 != 0
- DetailPrint "The CUDA runtime could not be fetched (exit $0); stemd runs on the CPU until install-cuda.cmd is run."
+ DetailPrint "The CUDA runtime could not be fetched (exit $0); Stemd runs on the CPU until install-cuda.cmd is run."
${IfNot} ${Silent}
- MessageBox MB_ICONEXCLAMATION|MB_OK "The CUDA runtime could not be fetched, so stemd will run on the CPU for now.$\r$\n$\r$\nRun install-cuda.cmd in the install folder to try again."
+ MessageBox MB_ICONEXCLAMATION|MB_OK "The CUDA runtime could not be fetched, so Stemd will run on the CPU for now.$\r$\n$\r$\nRun install-cuda.cmd in the install folder to try again."
${EndIf}
${EndIf}
SectionEnd
!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN
- !insertmacro MUI_DESCRIPTION_TEXT ${SecMain} "stemd, its command-line client and the runtime they need."
- !insertmacro MUI_DESCRIPTION_TEXT ${SecCuda} "Downloads the CUDA runtime and cuDNN from NVIDIA beside stemd, so separations run on the card rather than the CPU. Needs an NVIDIA driver."
+ !insertmacro MUI_DESCRIPTION_TEXT ${SecMain} "Stemd, its command-line client and the runtime they need."
+ !insertmacro MUI_DESCRIPTION_TEXT ${SecCuda} "Downloads the CUDA runtime and cuDNN from NVIDIA beside Stemd, so separations run on the card rather than the CPU. Needs an NVIDIA driver."
!insertmacro MUI_FUNCTION_DESCRIPTION_END
; Below the sections, because .onInit names one of them.
@@ -195,7 +199,7 @@ SectionEnd
; install, so both halves have to agree on where that key lives.
Function .onInit
${IfNot} ${RunningX64}
- MessageBox MB_ICONSTOP "stemd is built for 64-bit Windows only."
+ MessageBox MB_ICONSTOP "Stemd is built for 64-bit Windows only."
Abort
${EndIf}
SetRegView 64
diff --git a/scripts/package-windows.ps1 b/scripts/package-windows.ps1
index 23d0501..065b343 100644
--- a/scripts/package-windows.ps1
+++ b/scripts/package-windows.ps1
@@ -91,6 +91,6 @@ Write-Host ("done, from {0:N1} MB staged:" -f ($staged / 1MB))
foreach ($f in $installer, $zip) {
Write-Host (" {0,-34} {1,7:N1} MB" -f (Split-Path $f -Leaf), ((Get-Item $f).Length / 1MB))
}
-Write-Host ' the installer is per user, into %LOCALAPPDATA%\Programs\stemd: no'
+Write-Host ' the installer is per user, into %LOCALAPPDATA%\Programs\Stemd: no'
Write-Host ' administrator, and install-cuda.cmd can write beside the executable'
Write-Host ' without one either'