4 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
10 changed files with 405 additions and 27 deletions
Generated
+18 -18
View File
@@ -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",
]
+1 -1
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"
+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"));
}
}
+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()}));
}
},
+14
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;
@@ -101,6 +102,9 @@ pub struct UiState {
pub configurations: Vec<(String, Value)>,
/// 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).
@@ -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,
@@ -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`).
+19 -5
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)]) {
+1 -1
View File
@@ -409,7 +409,7 @@ fn catppuccin(f: Flavor) -> Tokens {
border_light: p.surface1,
text: p.text,
text_dim: p.subtext0,
text_dark: if dark { p.crust } else { p.text },
text_dark: if dark { p.crust } else { p.base },
header_text: p.subtext1,
counter_bg: p.crust,
counter_text: p.green,
+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:?}");
}
}
+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