Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| deb5fc6e8f | |||
| 6299bd1db3 | |||
| 1eb21f7293 | |||
| e27bb59b0c |
Generated
+18
-18
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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()}));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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)]) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user