7 Commits

Author SHA1 Message Date
Marcos Paulo deb5fc6e8f feat(ui): custom title bar on Windows and Linux 2026-10-12 10:39:09 -03:00
Marcos Paulo 6299bd1db3 chore: release 0.5.1 2026-10-12 10:10:55 -03:00
Marcos Paulo 1eb21f7293 build(windows): package standalone soundcraft-cli zip, set working directory and auto-detect rc.exe 2026-10-12 10:10:38 -03:00
Marcos Paulo e27bb59b0c fix(ui): readable text on accent fills in Catppuccin Latte
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 09:58:03 -03:00
Marcos Paulo 09172264e9 feat(ui): Catppuccin themes
Adds Latte, Frappé, Macchiato and Mocha to the Appearance setting (ui.theme
ids catppuccinLatte|catppuccinFrappe|catppuccinMacchiato|catppuccinMocha),
listed in Setup > Appearance.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 03:13:29 -03:00
Marcos Paulo 097fbd0c8a build: use the masi/craft-libs fork
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 02:57:33 -03:00
Marcos Paulo 30944cd0ab chore: debrand ArtCraft and Discord references
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 02:57:21 -03:00
34 changed files with 625 additions and 213 deletions
+12
View File
@@ -0,0 +1,12 @@
# Strip upstream (ArtCraft / Discord) branding. Run: perl tools/craft-skills/skills/debrand/debrand.pl . (--check in CI)
delete docs/brand
# MIT/Apache texts stay verbatim. The copyright holder line stays everywhere (legal requirement).
keep ^(LICENSE-(MIT|APACHE)|\.debrand)$
dropline ^ATTRIBUTION\.md$ :: \|\s*`docs/brand/
sub (?s)\nThe ArtCraft name, wordmark and logos in docs/brand/.*?LICENSE-brand\.txt\.\n =>
sub \n- The ArtCraft trademarks in `docs/brand/` are the only exception, under `docs/brand/LICENSE-brand\.txt`\. =>
sub until the owner draws SoundCraft's icon in ArtCraft like => until the owner draws SoundCraft's icon like
sub https://getartcraft\.com/apps/soundcraft => https://github.com/storytold/soundcraft
sub https://discord\.gg/artcraft => https://github.com/storytold/soundcraft/issues
forbid (?i)artcraft(?! Team and the SoundCraft contributors)
forbid (?i)discord
+3
View File
@@ -0,0 +1,3 @@
[submodule "tools/craft-skills"]
path = tools/craft-skills
url = https://git.marcospaulo.dev.br/storytold/craft-skills.git
-1
View File
@@ -16,7 +16,6 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
- **Every bundled asset gets a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (path, author, source, license) in the same commit that adds it. `cargo xtask assets` enforces this; CI fails otherwise.
- Never copy files out of the Pro Tools bundle (`/Applications/Pro Tools.app`): no plugins, presets, icons, sounds, session templates or text. Observing the running app (screenshots kept under `plan/`, menu names) is fine; copying its resources is not.
- Demo audio is synthesised in code (`crates/engine/src/demo.rs`). Demo and README media must be ours or public domain.
- The ArtCraft trademarks in `docs/brand/` are the only exception, under `docs/brand/LICENSE-brand.txt`.
## Clean-room
Pro Tools is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic sessions, take screenshots by window id (`screencapture -l <id>`), stored only under `plan/protools/screenshots/`. Never read, disassemble or copy anything inside the Pro Tools bundle; never copy Avid icons, artwork, plugin names, presets, or wording beyond feature names. Never copy GPL/AGPL/LGPL code (Ardour, Audacity, LMMS, Freeverb sources…). Our plugins have our own names.
-8
View File
@@ -29,14 +29,6 @@ and, at runtime, the operating system's own UI fonts when available (never redis
| Path | Author | Source | License |
|---|---|---|---|
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `assets/app-icon/icon.svg` | SoundCraft contributors | original (hand-placed SVG paths; placeholder app icon) | MIT OR Apache-2.0 |
| `packaging/macos/dmg/` (all files) | @XusBadia | original (macOS DMG window background and layout, from the app icon; see `packaging/macos/dmg/README.md`) | MIT OR Apache-2.0 |
| `assets/app-icon/soundcraft-1024.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
Generated
+19 -19
View File
@@ -785,7 +785,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
source = "git+https://git.marcospaulo.dev.br/masi/craft-libs?rev=22544b6d619d4f16779b8bdfa3f0b642a86fc065#22544b6d619d4f16779b8bdfa3f0b642a86fc065"
dependencies = [
"egui",
"serde",
@@ -3412,7 +3412,7 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"eframe",
"egui",
@@ -3428,7 +3428,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"log",
"serde",
@@ -3438,7 +3438,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"log",
"proptest",
@@ -3450,7 +3450,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"log",
"serde_json",
@@ -3460,7 +3460,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"clap-sys",
"libloading",
@@ -3472,7 +3472,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"serde_json",
"soundcraft-audio-io",
@@ -3483,7 +3483,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"log",
"proptest",
@@ -3494,7 +3494,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"log",
"proptest",
@@ -3515,7 +3515,7 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"proptest",
"serde",
@@ -3525,7 +3525,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"log",
"rayon",
@@ -3541,7 +3541,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"proptest",
"serde",
@@ -3554,7 +3554,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"cpal",
"log",
@@ -3567,7 +3567,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"proptest",
"serde",
@@ -3577,7 +3577,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"craft-ui",
"egui",
@@ -3603,7 +3603,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"image",
"proptest",
@@ -3614,7 +3614,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"libloading",
"log",
@@ -3629,7 +3629,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"eframe",
"egui",
@@ -5176,7 +5176,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"serde_json",
]
+2 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.5.0"
version = "0.5.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -41,7 +41,7 @@ rustfft = "6"
cpal = "0.16"
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "22544b6d619d4f16779b8bdfa3f0b642a86fc065", features = ["serde"] }
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
-4
View File
@@ -3,10 +3,6 @@ Copyright (c) 2026 ArtCraft Team and the SoundCraft contributors.
Licensed under the MIT License or the Apache License, Version 2.0, at your option
(LICENSE-MIT, LICENSE-APACHE).
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
not open source and are not covered by the license above; their terms are in
docs/brand/LICENSE-brand.txt.
Third-party material
This product bundles third-party material under its own licenses, listed below. The per-file
+12 -68
View File
@@ -1,12 +1,3 @@
<p align="center">
<a href="https://getartcraft.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
</picture>
</a>
</p>
<h1 align="center">SoundCraft</h1>
<p align="center">
@@ -27,27 +18,12 @@
<img alt="Status: pre-alpha" src="https://img.shields.io/badge/status-pre--alpha-orange?style=flat-square">
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<p align="center">
<a href="https://getartcraft.com/apps/soundcraft"><b>SoundCraft on getartcraft.com</b></a> ·
<a href="https://getartcraft.com/">ArtCraft</a> ·
<a href="https://getartcraft.com/apps">All Crafting Apps</a>
</p>
<p align="center">
<img alt="SoundCraft's Edit window with a nine-track session: drums, bass, pad, keys and lead, a reverb aux and a master fader" src="docs/images/edit.png" width="100%">
<br>
<sub>The Edit window: tracks, waveforms, MIDI, rulers, markers and the transport, playing the built-in demo song (every sound in it is synthesised by SoundCraft itself).</sub>
</p>
> [!NOTE]
> **ArtCraft is a community of artists from all walks of life.** Painters, photographers,
> filmmakers, illustrators, designers, animators, hobbyists, and people who picked up a pencil
> last week. If you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).**
<p align="center">
<a href="#what-it-does">What it does</a> ·
<a href="#getting-started">Getting started</a> ·
@@ -246,7 +222,7 @@ estimates are in [`ROADMAP.md`](ROADMAP.md); the full assessment is in
[`docs/target-app-parity.md`](docs/target-app-parity.md), the ranked list of what's missing in
[`docs/gaps.md`](docs/gaps.md), and the menu-by-menu comparison in
[`docs/parity-checklist.md`](docs/parity-checklist.md). Bug reports and wish lists are
very welcome, in the issues or on Discord.
very welcome, in the issues.
## Downloads
@@ -311,44 +287,22 @@ RISC-V (riscv64): `soundcraft-<ver>-linux-riscv64.tar.gz`, a tarball only (glibc
## The Crafting Apps
SoundCraft is one of the **Crafting Apps**: free, open-source creative tools from the
[ArtCraft](https://getartcraft.com/) team, each written from scratch in Rust and each able to
[storytold](https://github.com/storytold) team, each written from scratch in Rust and each able to
stand on its own.
| | App | What it's for | Code | Learn more |
|:-:|---|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | **Recording, editing and mixing audio · you are here** | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
| | App | What it's for | Code |
|:-:|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | **Recording, editing and mixing audio · you are here** | [GitHub](https://github.com/storytold/soundcraft) |
<br>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<h3 align="center">Come make things with us</h3>
<p align="center">
Our Discord is where artists of every kind hang out: people who paint, shoot, draw, cut film,
set type, and people still figuring out what they like to make. Share what you're working on,
ask for help, tell us what's broken, or tell us what you wish these tools could do.
Whatever your medium and however long you've been at it, you're welcome here.
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
<a href="https://getartcraft.com/">getartcraft.com</a> ·
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
<a href="https://getartcraft.com/apps/soundcraft">SoundCraft</a>
</p>
## License and credits
SoundCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
@@ -361,18 +315,8 @@ Every icon in the interface is drawn in code, the app icon is original, and the
screenshots' audio are synthesised by SoundCraft itself, so there are no samples or artwork from
anyone else in this repository.
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
part of this repository and SoundCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Avid and Pro Tools are trademarks or registered trademarks of Avid Technology, Inc. in the United States and/or other countries. SoundCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Avid Technology, Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. SoundCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
+28
View File
@@ -135,6 +135,17 @@ fn save_prefs(ui: &UiState) {
}
}
/// Windows and Linux: no OS title bar; the menu bar row is the title bar
/// (`soundcraft_ui_egui::titlebar`). macOS keeps its traffic lights over the integrated top row.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
/// Whether this start draws its own title bar: Windows and Linux do, unless Settings ▸ Appearance ▸
/// Use System Title Bar asks for the system's. Read from the saved settings before the window
/// opens; a missing or unreadable file keeps the default.
fn custom_titlebar() -> bool {
CUSTOM_TITLEBAR && !load_prefs().map(|u| u.system_title_bar).unwrap_or(false)
}
fn services() -> Services {
Services {
pick_open: Some(Box::new(|purpose: &str, _exts: &[&str]| {
@@ -213,12 +224,17 @@ fn main() -> eframe::Result {
}
let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
let custom_titlebar = custom_titlebar();
let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("SoundCraft")
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([960.0, 600.0])
.with_drag_and_drop(true)
.with_decorations(!custom_titlebar)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_app_id("ai.storyteller.soundcraft"),
..Default::default()
};
@@ -234,6 +250,8 @@ fn main() -> eframe::Result {
Box::new(move |cc| {
let player = if no_audio { None } else { Some(soundcraft_playback::Player::new(Arc::new(engine.session().clone()))) };
let mut app = SoundApp::new(engine, player, services());
app.custom_titlebar = custom_titlebar;
app.integrated_titlebar = cfg!(target_os = "macos");
app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave")));
app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets")));
if let Some(ui) = load_prefs() {
@@ -267,3 +285,13 @@ fn main() -> eframe::Result {
}),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn custom_titlebar_defaults_true_on_non_mac() {
assert_eq!(CUSTOM_TITLEBAR, !cfg!(target_os = "macos"));
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ owl-template framing (craftrules `standards/icon-design.md`): a full-bleed colou
in three-quarter view with its bill open and its eye on the viewer, its breast and wing running off
the bottom and right edges, three song arcs leaving the bill.
This is a **placeholder** until the owner draws SoundCraft's icon in ArtCraft like the other apps'
This is a **placeholder** until the owner draws SoundCraft's icon like the other apps'
icons. Replace `icon.svg` and run `packaging/icons.sh`; nothing else needs to change.
## Palette
+4 -1
View File
@@ -44,7 +44,10 @@ fn main() {
.unwrap_or((1600.0, 1000.0));
let engine = if blank { soundcraft_engine::Engine::default() } else { soundcraft_engine::demo::demo_engine() };
let (tx, rx) = mpsc::channel::<ControlRequest>();
let app = SoundApp::new(engine, None, Services::default()).with_control(rx);
let mut app = SoundApp::new(engine, None, Services::default()).with_control(rx);
if args.iter().any(|a| a == "--custom-titlebar") {
app.custom_titlebar = true;
}
let mut harness =
egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(2.0).with_max_steps(1_000_000).wgpu().build_ui_state(
|ui, app: &mut SoundApp| {
+7
View File
@@ -175,10 +175,17 @@ fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) {
ui.label("Auto-update configuration");
ui.checkbox(&mut app.extra.auto_update_config, "");
ui.end_row();
if !cfg!(target_os = "macos") {
ui.label("Use system title bar");
ui.checkbox(&mut app.ui.system_title_bar, "")
.on_hover_text("Takes effect from the next launch");
ui.end_row();
}
});
if ui.button("Reset").clicked() {
app.extra.ui_scale = 1.0;
app.extra.reduce_motion = false;
app.ui.system_title_bar = false;
let _ = app.run("ui.theme", json!({"mode": crate::theme::ThemeMode::default().id()}));
}
},
+20 -6
View File
@@ -21,6 +21,7 @@ pub mod panels;
pub mod score_editor;
pub mod shortcuts;
pub mod theme;
pub mod titlebar;
pub mod toolbar;
pub mod video_track;
pub mod video_window;
@@ -99,8 +100,11 @@ pub struct UiState {
pub video_burn_in: bool,
pub workspace_dir: String,
pub configurations: Vec<(String, Value)>,
/// Appearance (`ui.theme`): dark by default, light or follow the system on request.
/// Appearance (`ui.theme`): dark by default; light, a Catppuccin flavour or the system on request.
pub theme: theme::ThemeMode,
/// Windows and Linux: keep the system's title bar instead of drawing the window's own.
/// Read before the window opens: it applies from the next launch.
pub system_title_bar: bool,
}
impl Default for UiState {
@@ -153,6 +157,7 @@ impl Default for UiState {
workspace_dir: String::new(),
configurations: Vec::new(),
theme: theme::ThemeMode::Dark,
system_title_bar: false,
}
}
}
@@ -212,6 +217,10 @@ pub struct SoundApp {
pub services: Services,
/// Set only by a desktop host after installing an OS menu bar.
pub native_menu_bar: bool,
/// Windows and Linux: no OS decorations; the menu bar row is the title bar ([`titlebar`]).
pub custom_titlebar: bool,
/// macOS: the traffic lights sit over the top row (no system title bar).
pub integrated_titlebar: bool,
/// Folder for crash-recovery autosaves (native apps set it).
pub autosave_dir: Option<std::path::PathBuf>,
/// Folder for user plugin presets (native apps set it).
@@ -234,7 +243,7 @@ pub struct SoundApp {
fonts_ready: bool,
fonts_installed: bool,
/// The palette last applied to the context (light?), so visuals are set only on a change.
theme_applied: Option<bool>,
theme_applied: Option<theme::ThemeMode>,
/// Last panel host context, used to cancel renderer-owned gestures on explicit replacement.
pub(crate) docking_context: Option<egui::Context>,
/// Transport simulation when there is no player (tests, offscreen renders).
@@ -274,6 +283,8 @@ impl SoundApp {
audio_health_report: None,
services,
native_menu_bar: false,
custom_titlebar: false,
integrated_titlebar: false,
autosave_dir: None,
preset_dir: None,
last_autosave: 0.0,
@@ -769,10 +780,10 @@ impl SoundApp {
ctx.request_repaint();
}
}
let light = theme::wants_light(ctx, self.ui.theme);
if self.theme_applied != Some(light) || theme::is_light() != light {
theme::apply(ctx, light);
self.theme_applied = Some(light);
let mode = theme::resolve(ctx, self.ui.theme);
if self.theme_applied != Some(mode) || theme::active_mode() != mode {
theme::apply(ctx, mode);
self.theme_applied = Some(mode);
}
let now = ctx.input(|i| i.time);
let dt = self.last_frame.map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25);
@@ -873,6 +884,9 @@ impl SoundApp {
score_editor::show(self, &ctx);
palette::show(self, &ctx);
dialogs::show(self, &ctx);
if self.custom_titlebar {
titlebar::resize_zones(ui);
}
}
/// UI state as JSON (control channel `ui.inspect`).
+20 -6
View File
@@ -205,10 +205,19 @@ pub fn tree() -> Vec<MenuNode> {
pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::current();
egui::Panel::top("menu_bar").exact_size(24.0).frame(egui::Frame::NONE.fill(t.menu_bar).inner_margin(egui::Margin::symmetric(8, 2))).show(
ui,
|ui| {
let height = if app.custom_titlebar { crate::titlebar::HEIGHT } else { 24.0 };
egui::Panel::top("menu_bar")
.exact_size(height)
.frame(egui::Frame::NONE.fill(t.menu_bar).inner_margin(egui::Margin { left: 8, right: 0, top: 0, bottom: 0 }))
.show(ui, |ui| {
let bar_rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(ui.ctx().content_rect().width(), height));
if app.custom_titlebar || app.integrated_titlebar {
crate::titlebar::drag_area(ui, bar_rect);
}
egui::MenuBar::new().ui(ui, |ui| {
if app.integrated_titlebar {
ui.add_space(crate::titlebar::MAC_INSET);
}
ui.menu_button("SoundCraft", |ui| {
if ui.button("About SoundCraft").clicked() {
app.ui.show_about = true;
@@ -243,11 +252,16 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
if app.ui.window == MainWindow::Edit { "Edit" } else { "Mix" }
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if app.custom_titlebar {
ui.add_space(crate::titlebar::WIDTH);
}
ui.label(egui::RichText::new(title).color(t.text_dim));
});
});
},
);
if app.custom_titlebar {
crate::titlebar::caption_buttons(app, ui, bar_rect);
}
});
}
fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str, &str)]) {
@@ -571,7 +585,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
return Some(Ok(json!({"mode": app.ui.theme.id()})));
};
let Some(mode) = crate::theme::ThemeMode::parse(mode) else {
return Some(Err(format!("unknown theme `{mode}` (system, light or dark)")));
return Some(Err(format!("unknown theme `{mode}` ({})", crate::theme::ThemeMode::id_list())));
};
app.ui.theme = mode;
json!({"mode": mode.id()})
+2 -3
View File
@@ -501,10 +501,9 @@ fn about(app: &mut SoundApp, ctx: &egui::Context) {
_ => {
ui.label(egui::RichText::new("SoundCraft").font(bold(22.0)));
ui.label(format!("Version {}", env!("CARGO_PKG_VERSION")));
ui.label("An open-source digital audio workstation from the ArtCraft team.");
ui.label("An open-source digital audio workstation from the storytold team.");
ui.label("Dual-licensed MIT OR Apache-2.0. Made with Rust and egui.");
ui.hyperlink_to("getartcraft.com/apps/soundcraft", "https://getartcraft.com/apps/soundcraft");
ui.hyperlink_to("Join us on Discord", "https://discord.gg/artcraft");
ui.hyperlink_to("github.com/storytold/soundcraft", "https://github.com/storytold/soundcraft");
}
}
});
+168 -16
View File
@@ -1,10 +1,11 @@
//! Design tokens. Colours were chosen by eye to give a dark, studio-style look; they are ours.
use craft_ui::theme::catppuccin::Flavor;
use egui::{Color32, FontFamily, FontId};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicU8, Ordering};
/// Whether the Light palette is active. Dark is the default (the incumbent is dark-only).
static LIGHT_THEME: AtomicBool = AtomicBool::new(false);
/// Index into `ThemeMode::ALL` of the active palette. Dark (0) is the default (the incumbent is dark-only).
static ACTIVE_THEME: AtomicU8 = AtomicU8::new(0);
/// Unit tests that change the process-wide palette must own it until their assertions finish.
#[cfg(test)]
@@ -13,7 +14,7 @@ pub(crate) fn lock_test_palette() -> std::sync::MutexGuard<'static, ()> {
PALETTE.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The Appearance setting (`ui.theme {mode}`). Dark by default; System and Light are opt-in.
/// The Appearance setting (`ui.theme {mode}`). Dark by default; System, Light and the Catppuccin flavours are opt-in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ThemeMode {
@@ -21,16 +22,45 @@ pub enum ThemeMode {
Light,
#[default]
Dark,
#[serde(rename = "catppuccinLatte")]
CatppuccinLatte,
#[serde(rename = "catppuccinFrappe")]
CatppuccinFrappe,
#[serde(rename = "catppuccinMacchiato")]
CatppuccinMacchiato,
#[serde(rename = "catppuccinMocha")]
CatppuccinMocha,
}
impl ThemeMode {
pub const ALL: [Self; 3] = [Self::Dark, Self::Light, Self::System];
pub const ALL: [Self; 7] =
[Self::Dark, Self::Light, Self::System, Self::CatppuccinLatte, Self::CatppuccinFrappe, Self::CatppuccinMacchiato, Self::CatppuccinMocha];
/// The Catppuccin flavour behind this mode, if it is one.
pub const fn flavor(self) -> Option<Flavor> {
match self {
Self::CatppuccinLatte => Some(Flavor::Latte),
Self::CatppuccinFrappe => Some(Flavor::Frappe),
Self::CatppuccinMacchiato => Some(Flavor::Macchiato),
Self::CatppuccinMocha => Some(Flavor::Mocha),
Self::System | Self::Light | Self::Dark => None,
}
}
/// Every id, `|`-separated: for error messages.
pub fn id_list() -> String {
Self::ALL.map(Self::id).join("|")
}
pub const fn label(self) -> &'static str {
match self {
Self::System => "System",
Self::Light => "Light",
Self::Dark => "Dark",
Self::CatppuccinLatte => "Catppuccin Latte",
Self::CatppuccinFrappe => "Catppuccin Frappé",
Self::CatppuccinMacchiato => "Catppuccin Macchiato",
Self::CatppuccinMocha => "Catppuccin Mocha",
}
}
@@ -40,6 +70,10 @@ impl ThemeMode {
Self::System => "system",
Self::Light => "light",
Self::Dark => "dark",
Self::CatppuccinLatte => "catppuccinLatte",
Self::CatppuccinFrappe => "catppuccinFrappe",
Self::CatppuccinMacchiato => "catppuccinMacchiato",
Self::CatppuccinMocha => "catppuccinMocha",
}
}
@@ -326,21 +360,126 @@ impl Tokens {
};
pub fn current() -> Tokens {
if is_light() { Self::LIGHT } else { Self::DARK }
Self::for_mode(active_mode())
}
/// The tokens of a resolved mode (`System` reads as Dark: resolve it with `resolve` first).
pub fn for_mode(mode: ThemeMode) -> Tokens {
match mode.flavor() {
Some(f) => catppuccin(f),
None if mode == ThemeMode::Light => Self::LIGHT,
None => Self::DARK,
}
}
}
/// Whether the Light palette is the active one.
/// The palette in use: never `System`.
pub fn active_mode() -> ThemeMode {
ThemeMode::ALL.get(usize::from(ACTIVE_THEME.load(Ordering::Relaxed))).copied().unwrap_or_default()
}
/// Whether the active palette is a light one (Light or Catppuccin Latte).
pub fn is_light() -> bool {
LIGHT_THEME.load(Ordering::Relaxed)
let mode = active_mode();
mode.flavor().map_or(mode == ThemeMode::Light, |f| !f.is_dark())
}
/// Resolve each frame from the integration's current OS appearance. Unknown uses Dark.
pub fn wants_light(ctx: &egui::Context, mode: ThemeMode) -> bool {
pub fn resolve(ctx: &egui::Context, mode: ThemeMode) -> ThemeMode {
match mode {
ThemeMode::Light => true,
ThemeMode::Dark => false,
ThemeMode::System => ctx.system_theme() == Some(egui::Theme::Light),
ThemeMode::System if ctx.system_theme() == Some(egui::Theme::Light) => ThemeMode::Light,
ThemeMode::System => ThemeMode::Dark,
_ => mode,
}
}
/// A Catppuccin flavour on SoundCraft's token set (accent Mauve, selection Blue).
fn catppuccin(f: Flavor) -> Tokens {
let p = f.palette();
let dark = f.is_dark();
let a = |c: Color32, alpha: u8| Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), alpha);
let tint = |c: Color32, t: f32| p.base.lerp_to_gamma(c, t);
Tokens {
window_bg: p.base,
panel_bg: p.base,
panel_bg2: p.surface0,
toolbar_bg: p.mantle,
toolbar_group: p.crust,
border: if dark { p.crust } else { p.surface1 },
border_light: p.surface1,
text: p.text,
text_dim: p.subtext0,
text_dark: if dark { p.crust } else { p.base },
header_text: p.subtext1,
counter_bg: p.crust,
counter_text: p.green,
counter_label: p.subtext1,
accent: p.mauve,
accent_dark: tint(p.mauve, 0.6),
mode_on: p.green,
mode_on_text: p.crust,
mode_off_text: p.green,
button: p.surface0,
button_hi: p.surface1,
button_border: p.surface2,
name_field: if dark { p.text } else { p.base },
name_field_sel: p.rosewater,
ruler_bg: p.mantle,
ruler_label_bg: p.mantle,
ruler_text: p.subtext0,
ruler_tick: p.overlay0,
tempo_ruler: tint(p.green, 0.5),
meter_ruler: tint(p.sapphire, 0.5),
marker_ruler: p.surface0,
playlist_bg: p.base,
playlist_alt: p.mantle,
grid_line: p.surface0,
bar_line: p.surface1,
selection: a(p.blue, 110),
playhead: p.red,
insertion: p.text,
solo: p.yellow,
mute: p.peach,
rec: p.red,
input: p.green,
auto_read: p.green,
auto_write: p.red,
auto_touch: p.peach,
auto_latch: p.mauve,
meter_green: p.green,
meter_yellow: p.yellow,
meter_red: p.red,
fader_track: p.crust,
strip_bg: p.mantle,
strip_section: p.base,
slot_bg: p.crust,
automation_line: p.text,
menu_bar: p.mantle,
group_border: if dark { p.crust } else { p.surface1 },
mode_off: p.surface0,
counter_off: p.surface1,
counter_border: p.surface1,
counter_rule: p.surface0,
tool_bracket_off: p.surface1,
row_selected: tint(p.blue, 0.35),
visible_dot: p.overlay1,
big_counter_bg: p.crust,
header_selected: p.surface0,
strip_selected: p.surface0,
marker_text: p.text,
playlist_lane: p.mantle,
universe_bg: p.crust,
universe_view: p.text,
meter_bg: p.crust,
meter_clip_off: tint(p.red, 0.3),
knob_bg: p.surface0,
knob_ring: p.overlay0,
knob_pointer: p.text,
insert_on: tint(p.blue, 0.3),
insert_bypass: tint(p.yellow, 0.3),
send_on: tint(p.green, 0.3),
slot_border: p.surface1,
section_label: p.subtext0,
}
}
@@ -379,11 +518,12 @@ pub fn mono(size: f32) -> FontId {
FontId::new(size, FontFamily::Monospace)
}
/// Make `light` (or dark) the active palette and apply its visuals. Call only when it changes.
pub fn apply(ctx: &egui::Context, light: bool) {
LIGHT_THEME.store(light, Ordering::Relaxed);
/// Make `mode` (already resolved, not `System`) the active palette and apply its visuals. Call only when it changes.
pub fn apply(ctx: &egui::Context, mode: ThemeMode) {
let index = ThemeMode::ALL.iter().position(|m| *m == mode).unwrap_or_default();
ACTIVE_THEME.store(u8::try_from(index).unwrap_or_default(), Ordering::Relaxed);
let t = Tokens::current();
let mut v = if light { egui::Visuals::light() } else { egui::Visuals::dark() };
let mut v = if is_light() { egui::Visuals::light() } else { egui::Visuals::dark() };
v.panel_fill = t.panel_bg;
v.window_fill = t.panel_bg2;
v.extreme_bg_color = t.slot_bg;
@@ -436,6 +576,18 @@ mod tests {
assert_eq!(ThemeMode::parse("sepia"), None);
}
#[test]
fn catppuccin_themes_use_the_flavour_palette() {
for mode in ThemeMode::ALL {
let Some(f) = mode.flavor() else { continue };
let (t, p) = (Tokens::for_mode(mode), f.palette());
assert_eq!((t.window_bg, t.panel_bg, t.text, t.accent), (p.base, p.base, p.text, p.mauve), "{}", mode.id());
}
assert_eq!(ThemeMode::ALL.iter().filter(|m| m.flavor().is_some()).count(), Flavor::ALL.len());
assert_eq!(ThemeMode::parse("catppuccinMocha"), Some(ThemeMode::CatppuccinMocha));
assert_eq!(ThemeMode::id_list().split('|').count(), ThemeMode::ALL.len());
}
#[test]
fn theme_command_sets_and_reports_the_mode() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
+286
View File
@@ -0,0 +1,286 @@
//! The window's own title bar on Windows and Linux, where the window has no OS decorations
//! ([`SoundApp::custom_titlebar`]): the menu bar row is the title bar. Empty space in it
//! drags the window and a double-click there maximizes it, caption buttons at the far right
//! minimize, maximize/restore and close, and thin invisible zones along the window's edges resize
//! it. On macOS the traffic lights sit over the same row ([`MAC_INSET`]) and only the drag works.
use egui::{CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2};
use serde_json::json;
use crate::SoundApp;
/// One caption button: as wide as the native Windows ones.
pub const BUTTON_WIDTH: f32 = 46.0;
/// The three caption buttons together.
pub const WIDTH: f32 = 3.0 * BUTTON_WIDTH;
/// Height of the title row (menu bar) when it carries the caption buttons.
pub const HEIGHT: f32 = 32.0;
/// Room for the macOS traffic lights at the row's left end.
pub const MAC_INSET: f32 = 80.0;
/// Resize zone thickness along an edge, and the side of a corner zone.
const EDGE: f32 = 6.0;
const CORNER: f32 = 12.0;
#[derive(Clone, Copy, PartialEq, Debug)]
enum Caption {
Minimize,
Maximize,
Close,
}
/// Left to right.
const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::Close];
/// Caption button ids (stable, so tests and agents can find them).
fn caption_id(c: Caption) -> Id {
Id::new(("titlebar-caption", c as u8))
}
fn maximized(ctx: &egui::Context) -> bool {
ctx.input(|i| i.viewport().maximized.unwrap_or(false))
}
fn toggle_maximize(ctx: &egui::Context) {
ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized(ctx)));
}
/// The bar's background: a press-and-drag on empty space moves the window, a double-click toggles
/// maximize. Call it before laying out the bar so its widgets, registered later, take the clicks.
pub fn drag_area(ui: &Ui, rect: Rect) {
let ctx = ui.ctx();
let resp = ui.interact(rect, Id::new("titlebar-drag"), Sense::click_and_drag());
// egui hands a drag that starts on a click-only widget (a menu title) to the drag-sensing
// background below it, so remember whether the press itself landed on bare background.
if ctx.input(|i| i.pointer.any_pressed()) {
let bare = ctx.viewport(|v| v.hits.click.is_some_and(|w| w.id == resp.id));
ctx.data_mut(|d| d.insert_temp(resp.id, bare));
}
if resp.double_clicked() {
toggle_maximize(ctx);
} else if resp.drag_started_by(PointerButton::Primary) && ctx.data(|d| d.get_temp(resp.id).unwrap_or(false)) {
ctx.send_viewport_cmd(ViewportCommand::StartDrag);
}
}
/// Minimize, Maximize/Restore and Close at the right end of `bar` (the title row's outer rect),
/// full row height, flush with the window's edge so a maximized window's corner hits Close. Close
/// runs `app.quit`, which asks about unsaved changes first.
pub fn caption_buttons(app: &mut SoundApp, ui: &mut Ui, bar: Rect) {
let t = crate::theme::Tokens::current();
let max = maximized(ui.ctx());
let mut clicked = None;
let danger = egui::Color32::from_rgb(218, 54, 51);
for (i, c) in CAPTIONS.into_iter().enumerate() {
let r = Rect::from_min_size(pos2(bar.right() - WIDTH + i as f32 * BUTTON_WIDTH, bar.top()), vec2(BUTTON_WIDTH, bar.height() - 1.0));
let resp = ui.interact(r, caption_id(c), Sense::click());
let close = c == Caption::Close;
let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) {
(true, _) if close => danger.gamma_multiply(0.8),
(_, true) if close => danger,
(true, _) => t.mode_on,
(_, true) => t.button_hi,
_ => egui::Color32::TRANSPARENT,
};
ui.painter().rect_filled(r, 0.0, fill);
let ink = if close && resp.hovered() { egui::Color32::WHITE } else { t.text_dim };
paint_glyph(ui, c, max, r.center(), Stroke::new(1.0, ink));
let tip = match c {
Caption::Minimize => "Minimize",
Caption::Maximize if max => "Restore",
Caption::Maximize => "Maximize",
Caption::Close => "Close",
};
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
if resp.on_hover_text(tip).clicked() {
clicked = Some(c);
}
}
match clicked {
Some(Caption::Minimize) => ui.ctx().send_viewport_cmd(ViewportCommand::Minimized(true)),
Some(Caption::Maximize) => toggle_maximize(ui.ctx()),
Some(Caption::Close) => {
let _ = app.run("app.quit", json!({}));
ui.ctx().send_viewport_cmd(ViewportCommand::Close);
}
None => {}
}
}
/// The 10 pt line glyphs of the caption buttons.
fn paint_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) {
let p = ui.painter();
let g = Rect::from_center_size(at, vec2(10.0, 10.0));
match c {
Caption::Minimize => {
p.line_segment([g.left_center(), g.right_center()], s);
}
// Restore: a front square with the corner of the one behind it.
Caption::Maximize if maximized => {
let front = Rect::from_min_max(g.min + vec2(0.0, 2.0), g.max - vec2(2.0, 0.0));
p.rect_stroke(front, 0.0, s, egui::StrokeKind::Middle);
p.line(vec![g.min + vec2(2.0, 2.0), g.min + vec2(2.0, 0.0), g.right_top(), g.max - vec2(0.0, 2.0), g.max - vec2(2.0, 2.0)], s);
}
Caption::Maximize => {
p.rect_stroke(g, 0.0, s, egui::StrokeKind::Middle);
}
Caption::Close => {
p.line_segment([g.left_top(), g.right_bottom()], s);
p.line_segment([g.right_top(), g.left_bottom()], s);
}
}
}
/// The edge and corner zones of a window with content rect `w`: (zone, direction, cursor). Corners
/// come last so they win where they overlap the edges.
fn zones(w: Rect) -> [(Rect, ResizeDirection, CursorIcon); 8] {
use ResizeDirection::*;
let (e, c) = (EDGE, CORNER);
[
(Rect::from_min_max(w.left_top(), pos2(w.right(), w.top() + e)), North, CursorIcon::ResizeVertical),
(Rect::from_min_max(pos2(w.left(), w.bottom() - e), w.right_bottom()), South, CursorIcon::ResizeVertical),
(Rect::from_min_max(w.left_top(), pos2(w.left() + e, w.bottom())), West, CursorIcon::ResizeHorizontal),
(Rect::from_min_max(pos2(w.right() - e, w.top()), w.right_bottom()), East, CursorIcon::ResizeHorizontal),
(Rect::from_min_size(w.left_top(), vec2(c, c)), NorthWest, CursorIcon::ResizeNwSe),
(Rect::from_min_size(w.right_top() - vec2(c, 0.0), vec2(c, c)), NorthEast, CursorIcon::ResizeNeSw),
(Rect::from_min_size(w.left_bottom() - vec2(0.0, c), vec2(c, c)), SouthWest, CursorIcon::ResizeNeSw),
(Rect::from_min_size(w.right_bottom() - vec2(c, c), vec2(c, c)), SouthEast, CursorIcon::ResizeNwSe),
]
}
/// Invisible resize zones along the window's edges, above everything else; a press in one starts
/// an OS resize in that direction. None while the window is maximized or fullscreen.
pub fn resize_zones(ui: &mut Ui) {
let ctx = ui.ctx().clone();
if maximized(&ctx) || ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)) {
return;
}
let w = ctx.content_rect();
let layer = LayerId::new(Order::Foreground, Id::new("titlebar-resize"));
let zui = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(w));
for (i, (r, dir, cursor)) in zones(w).into_iter().enumerate() {
let resp = zui.interact(r, Id::new(("titlebar-resize", i)), Sense::drag()).on_hover_cursor(cursor);
if resp.drag_started_by(PointerButton::Primary) {
ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui::{Event, Pos2, RawInput, ViewportId};
/// A headless window `width` pt wide with a title row: the drag area, the caption buttons and
/// the resize zones.
struct Win {
ctx: egui::Context,
app: SoundApp,
width: f32,
time: f64,
maximized: bool,
}
impl Win {
fn new(width: f32) -> Self {
let ctx = egui::Context::default();
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, crate::Services::default());
app.custom_titlebar = true;
Self { ctx, app, width, time: 0.0, maximized: false }
}
fn frame(&mut self, events: Vec<Event>) -> egui::FullOutput {
self.time += 0.05;
let mut input =
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 600.0))), time: Some(self.time), events, ..Default::default() };
if let Some(v) = input.viewports.get_mut(&ViewportId::ROOT) {
v.maximized = Some(self.maximized);
}
let app = &mut self.app;
let width = self.width;
let mut out = self.ctx.run_ui(input, |ui| {
let bar = Rect::from_min_size(Pos2::ZERO, vec2(width, HEIGHT));
drag_area(ui, bar);
caption_buttons(app, ui, bar);
resize_zones(ui);
});
out.textures_delta.clear();
out
}
fn gesture(&mut self, path: &[Pos2]) -> Vec<ViewportCommand> {
let button = |p: Pos2, pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() };
let mut events = vec![vec![Event::PointerMoved(path[0])], vec![button(path[0], true)]];
events.extend(path[1..].iter().map(|p| vec![Event::PointerMoved(*p)]));
events.push(vec![button(*path.last().unwrap_or(&path[0]), false)]);
events.into_iter().flat_map(|e| self.frame(e).viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()).collect()
}
fn click(&mut self, p: Pos2) -> Vec<ViewportCommand> {
self.gesture(&[p])
}
fn caption(&self, c: Caption) -> Rect {
self.ctx.read_response(caption_id(c)).map(|r| r.rect).unwrap_or(Rect::NOTHING)
}
}
#[test]
fn caption_buttons_take_the_right_end_of_the_row() {
for width in [800.0, 1440.0] {
let mut w = Win::new(width);
w.frame(vec![]);
w.frame(vec![]);
let [min, max, close] = CAPTIONS.map(|c| w.caption(c));
assert_eq!((close.right(), close.top()), (width, 0.0), "Close sits in the window's corner at {width}");
assert!(close.height() >= 30.0 && [min, max, close].iter().all(|r| r.width() == BUTTON_WIDTH));
assert_eq!((min.right(), max.right()), (max.left(), close.left()));
}
}
#[test]
fn minimize_maximize_and_close_follow_the_window_state() {
let mut w = Win::new(1000.0);
w.frame(vec![]);
let (min, max, close) = (w.caption(Caption::Minimize).center(), w.caption(Caption::Maximize).center(), w.caption(Caption::Close).center());
assert!(w.click(min).contains(&ViewportCommand::Minimized(true)));
assert!(w.click(max).contains(&ViewportCommand::Maximized(true)));
w.maximized = true;
assert!(w.click(max).contains(&ViewportCommand::Maximized(false)), "Restore");
assert!(w.click(close).contains(&ViewportCommand::Close));
}
#[test]
fn empty_row_space_drags_and_double_click_maximizes() {
let mut w = Win::new(1440.0);
w.frame(vec![]);
let empty = pos2(700.0, 16.0);
let cmds = w.gesture(&[empty, empty + vec2(12.0, 4.0), empty + vec2(30.0, 8.0)]);
assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}");
let mut cmds = w.click(empty);
cmds.extend(w.click(empty));
assert!(cmds.contains(&ViewportCommand::Maximized(true)), "{cmds:?}");
}
#[test]
fn edge_zones_resize_unless_maximized() {
use ResizeDirection::*;
let mut w = Win::new(1000.0);
w.frame(vec![]);
for (p, dir) in [
(pos2(1.0, 300.0), West),
(pos2(998.0, 300.0), East),
(pos2(500.0, 1.0), North),
(pos2(500.0, 598.0), South),
(pos2(2.0, 2.0), NorthWest),
(pos2(997.0, 3.0), NorthEast),
(pos2(3.0, 597.0), SouthWest),
(pos2(998.0, 598.0), SouthEast),
] {
let cmds = w.gesture(&[p, p + vec2(6.0, 6.0)]);
assert_eq!(cmds, vec![ViewportCommand::BeginResize(dir)], "at {p:?}");
}
w.maximized = true;
let cmds = w.gesture(&[pos2(1.0, 300.0), pos2(8.0, 300.0)]);
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::BeginResize(_))), "{cmds:?}");
}
}
+4 -4
View File
@@ -405,7 +405,7 @@ mod selector_visual_regressions {
move |ui, regions: &mut serde_json::Map<String, serde_json::Value>| {
if !ready {
crate::fonts::install(ui.ctx());
crate::theme::apply(ui.ctx(), false);
crate::theme::apply(ui.ctx(), crate::theme::ThemeMode::Dark);
ready = true;
return;
}
@@ -458,7 +458,7 @@ mod selector_visual_regressions {
for scale in [1.0, 1.5, 2.0] {
let ctx = egui::Context::default();
crate::fonts::install(&ctx);
crate::theme::apply(&ctx, false);
crate::theme::apply(&ctx, crate::theme::ThemeMode::Dark);
let mut app = mixer_app(narrow, mode);
let mut output = ctx.run_ui(scaled_input(vec2(320.0, 640.0), scale), |ui| crate::mix_window::show(&mut app, ui));
output.textures_delta.clear();
@@ -505,7 +505,7 @@ mod selector_visual_regressions {
move |ui, app: &mut crate::SoundApp| {
if !ready {
crate::fonts::install(ui.ctx());
crate::theme::apply(ui.ctx(), false);
crate::theme::apply(ui.ctx(), crate::theme::ThemeMode::Dark);
ready = true;
return;
}
@@ -553,7 +553,7 @@ mod selector_visual_regressions {
for scale in [1.0, 1.5, 2.0] {
let ctx = egui::Context::default();
crate::fonts::install(&ctx);
crate::theme::apply(&ctx, false);
crate::theme::apply(&ctx, crate::theme::ThemeMode::Dark);
let mut rect = Rect::NOTHING;
let mut output = ctx.run_ui(scaled_input(vec2(320.0, 120.0), scale), |ui| {
rect = selector_box(ui, width, 16.0, label, Tokens::current().text).rect;
+1 -1
View File
@@ -180,7 +180,7 @@ fn shared_controls_visuals() {
}
let mut controls = Harness::builder().with_size(vec2(340.0, 150.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
|ui, rects: &mut Vec<egui::Rect>| {
theme::apply(ui.ctx(), false);
theme::apply(ui.ctx(), theme::ThemeMode::Dark);
rects.clear();
for enabled in [true, false] {
ui.add_enabled_ui(enabled, |ui| {
+2 -2
View File
@@ -43,7 +43,7 @@ fn shared_panels_and_controls_follow_manual_and_live_system_appearance() {
}
}
let ctx = egui::Context::default();
theme::apply(&ctx, light);
theme::apply(&ctx, if light { theme::ThemeMode::Light } else { theme::ThemeMode::Dark });
let tokens = theme::Tokens::current();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
widgets::icon_button(ui, vec2(38.0, 28.0), "loop", true, "Loop playback");
@@ -58,7 +58,7 @@ fn shared_panels_and_controls_follow_manual_and_live_system_appearance() {
if let Some(directory) = std::env::var_os("CRAFT_UI_THEME_SHOTS").map(std::path::PathBuf::from) {
let mut controls = Harness::builder().with_size(vec2(340.0, 150.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
move |ui, _: &mut ()| {
theme::apply(ui.ctx(), light);
theme::apply(ui.ctx(), if light { theme::ThemeMode::Light } else { theme::ThemeMode::Dark });
for enabled in [true, false] {
ui.add_enabled_ui(enabled, |ui| {
ui.horizontal(|ui| {
-42
View File
@@ -1,42 +0,0 @@
ArtCraft brand assets: license
The ArtCraft name, wordmark and mark (the "ArtCraft Marks"), including every file in this
directory in every color, size and format, are owned by the ArtCraft Team. They are trademarks
and are NOT open source. They are not covered by SoundCraft's MIT OR Apache-2.0 license, and nothing in
that license, in NOTICE or in any other file of this repository grants any right to them.
Permission
The ArtCraft Team grants a limited, non-exclusive, non-transferable, revocable, royalty-free
permission to use the ArtCraft Marks only:
1. as part of copies of this repository; and
2. within SoundCraft itself, its documentation and its official builds, to identify SoundCraft as made
by the ArtCraft team and community and to link to getartcraft.com and its community.
Conditions
The permission holds only while every one of these is true:
- The marks are used exactly as supplied in this directory. Do not redraw, recolor, crop,
stretch, rotate, animate, outline or add effects to them, extract parts of them, or combine
them with any other name, mark or artwork.
- They are used only in the context of SoundCraft, and only to say that the ArtCraft team makes it.
They must never suggest that the ArtCraft Team made, sponsors, endorses, certifies or supports
any other product, service, fork, modified version, plug-in, organization or person.
- Neither the marks nor the ArtCraft name appear in the name, logo, icon, domain, social media
handle, app-store listing, advertising or marketing of anything other than SoundCraft.
Forks and modified versions
If you distribute or publish a modified version of SoundCraft, or any work derived from it, you must
remove the ArtCraft Marks from it (or replace them with your own) and must not call it ArtCraft or
present it as an ArtCraft product. You may say, in plain text, that your work is based on SoundCraft by
the ArtCraft team. A fork kept only to propose changes back to this repository may keep the marks
unchanged.
Everything else
Any use not expressly permitted here needs prior written permission from the ArtCraft Team
(https://getartcraft.com/). The permission ends automatically for anyone who breaks these terms.
All other rights are reserved.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -19.42 136.34 136.34" role="img" aria-label="ArtCraft">
<title>ArtCraft</title>
<path fill="#0b0b0c" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -19.42 136.34 136.34" role="img" aria-label="ArtCraft">
<title>ArtCraft</title>
<path fill="#4e7bfb" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 341 B

@@ -26,10 +26,10 @@
<launchable type="desktop-id">ai.storyteller.soundcraft.desktop</launchable>
<icon type="stock">ai.storyteller.soundcraft</icon>
<!-- Only url types known to appstream 0.15 (Ubuntu 22.04, where CI validates): no vcs-browser. -->
<url type="homepage">https://getartcraft.com/apps/soundcraft</url>
<url type="homepage">https://github.com/storytold/soundcraft</url>
<url type="bugtracker">https://github.com/storytold/soundcraft/issues</url>
<url type="help">https://github.com/storytold/soundcraft</url>
<url type="contact">https://discord.gg/artcraft</url>
<url type="contact">https://github.com/storytold/soundcraft/issues</url>
<categories>
<category>AudioVideo</category>
<category>Audio</category>
+1 -1
View File
@@ -14,7 +14,7 @@ section: sound
priority: optional
maintainer: ${SOUNDCRAFT_MAINTAINER}
vendor: Learning Machines LLC
homepage: https://getartcraft.com/apps/soundcraft
homepage: https://github.com/storytold/soundcraft
license: MIT OR Apache-2.0
description: |-
Open-source, native digital audio workstation. Record, edit and mix
+27 -1
View File
@@ -6,6 +6,7 @@
Produces, in $env:DIST (default: dist/release):
soundcraft-<version>-windows-<arch>.msi per-machine installer (WiX v5)
soundcraft-<version>-windows-<arch>-portable.zip soundcraft.exe + soundcraft-cli.exe
soundcraft-cli-<version>-windows-<arch>.zip soundcraft-cli.exe standalone
The binaries link the C runtime statically (+crt-static), so neither the MSI nor the portable
zip needs the Visual C++ redistributable. Signing is delegated to sign.ps1 (skipped with a
@@ -25,6 +26,7 @@ param(
)
$ErrorActionPreference = 'Stop'
$Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
Set-Location $Root
function Invoke-Native([string] $What, [scriptblock] $Block) {
Write-Output "==> $What"
@@ -56,6 +58,17 @@ if (-not $env:SOUNDCRAFT_BUILD_DATE) { $env:SOUNDCRAFT_BUILD_DATE = (Get-Date).T
Write-Output "SoundCraft $Version for Windows $Arch ($Target)"
if (-not $SkipBuild) {
# Locate rc.exe if not in PATH
if (-not (Get-Command rc.exe -ErrorAction SilentlyContinue)) {
$kits = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin'
$foundRc = Get-ChildItem -Path $kits -Recurse -Filter rc.exe -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\x64\\rc\.exe$' } |
Sort-Object FullName -Descending | Select-Object -First 1
if ($foundRc) {
$env:PATH = "$($foundRc.DirectoryName);$env:PATH"
}
}
# Static CRT: no VC++ redistributable needed. Scoped to the target so host build scripts and
# proc-macros are unaffected.
$flagVar = 'CARGO_TARGET_' + ($Target.ToUpper() -replace '-', '_') + '_RUSTFLAGS'
@@ -114,6 +127,19 @@ $Zip = Join-Path $Dist "soundcraft-$Version-windows-$Arch-portable.zip"
Remove-Item -Force $Zip -ErrorAction SilentlyContinue
Compress-Archive -Path $Portable -DestinationPath $Zip
# ---- cli zip -----------------------------------------------------------------------------------
$CliPortable = Join-Path $TargetDir "windows-package\soundcraft-cli-$Version-windows-$Arch"
Remove-Item -Recurse -Force $CliPortable -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $CliPortable | Out-Null
Copy-Item (Join-Path $Stage 'soundcraft-cli.exe') $CliPortable
foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'ATTRIBUTION.md') {
$p = Join-Path $Root $f
if (Test-Path $p) { Copy-Item $p $CliPortable }
}
$CliZip = Join-Path $Dist "soundcraft-cli-$Version-windows-$Arch.zip"
Remove-Item -Force $CliZip -ErrorAction SilentlyContinue
Compress-Archive -Path $CliPortable -DestinationPath $CliZip
# Smoke-test the CLI when this machine can run it. An ARM64 build made on an x64 runner can't run
# here; .github/workflows/windows-arm64.yml installs and runs it on ARM64 instead.
$HostArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()
@@ -122,4 +148,4 @@ if ($Arch -ne 'arm64' -or $HostArch -eq 'arm64') {
} else {
Write-Output "skipping soundcraft-cli --version: an $Arch build doesn't run on this $HostArch machine"
}
Get-Item $Msi, $Zip | Format-Table Name, Length
Get-Item $Msi, $Zip, $CliZip | Format-Table Name, Length
+3 -3
View File
@@ -26,9 +26,9 @@
<Icon Id="SoundcraftIcon.ico" SourceFile="$(var.IconPath)" />
<Property Id="ARPPRODUCTICON" Value="SoundcraftIcon.ico" />
<Property Id="ARPURLINFOABOUT" Value="https://getartcraft.com/apps/soundcraft" />
<Property Id="ARPURLUPDATEINFO" Value="https://getartcraft.com/apps/soundcraft" />
<Property Id="ARPHELPLINK" Value="https://discord.gg/artcraft" />
<Property Id="ARPURLINFOABOUT" Value="https://github.com/storytold/soundcraft" />
<Property Id="ARPURLUPDATEINFO" Value="https://github.com/storytold/soundcraft" />
<Property Id="ARPHELPLINK" Value="https://github.com/storytold/soundcraft/issues" />
<StandardDirectory Id="ProgramFiles6432Folder">
<Directory Id="INSTALLFOLDER" Name="SoundCraft" />
Submodule tools/craft-skills added at 26c1c201a7