From 178fc47ed77f15fd0d71c000633eba582f674343 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 03:54:15 +0000 Subject: [PATCH] Custom title bar: on Windows and Linux the application bar is the window's title bar (drag to move with OS snapping, double-click to maximize or restore, minimize/maximize/close buttons, resize from the edges and corners); macOS keeps its traffic lights over the bar; the web build has no window buttons; ui.window control method (adapted from VectorCraft) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- ATTRIBUTION.md | 1 + apps/solvecraft/src/main.rs | 10 + crates/ui-egui/src/control.rs | 5 + crates/ui-egui/src/lib.rs | 10 + crates/ui-egui/src/titlebar.rs | 349 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/toolbar.rs | 138 +++++++------ docs/control-protocol.md | 1 + 7 files changed, 451 insertions(+), 63 deletions(-) create mode 100644 crates/ui-egui/src/titlebar.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 0e3b5044..3e16f5c6 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -32,6 +32,7 @@ Generated-in-code assets are original and have no file to list: | crates/mcp/src/server.rs, crates/mcp/src/backend.rs (JSON-RPC framing, MCP lifecycle, control-port bridge) | CADCraft `crates/mcp/src/{server,backend}.rs` | MIT OR Apache-2.0 | | crates/mcp/src/tools.rs (tool schema helpers and argument checking) | CADCraft `crates/mcp/src/tools.rs`, GridCraft `crates/mcp/src/tools.rs` | MIT OR Apache-2.0 | | crates/io/src/threemf.rs (bounded zip part reading and writing) | GridCraft `crates/xlsx/src/package.rs` | MIT OR Apache-2.0 | +| crates/ui-egui/src/titlebar.rs (title bar drag, caption buttons, resize zones, their tests) and the window setup in apps/solvecraft/src/main.rs | VectorCraft `crates/ui-egui/src/titlebar.rs`, `apps/vectorcraft/src/main.rs` | MIT OR Apache-2.0 | | LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — | ## Vendored third-party code diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index 599ad07c..e2105d22 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -58,6 +58,10 @@ fn services() -> Services { } } +/// Windows and Linux: no OS title bar; the application bar is the title bar +/// (`solvecraft_ui_egui::titlebar`). macOS keeps its traffic lights over the integrated bar. +const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); + const DEPTH_BITS: u8 = 24; const MSAA: u16 = 4; @@ -87,6 +91,10 @@ fn main() -> eframe::Result { .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.solvecraft"), depth_buffer: DEPTH_BITS, multisampling: MSAA, @@ -97,6 +105,8 @@ fn main() -> eframe::Result { options, Box::new(move |cc| { let mut app = SolveApp::new(Session::default(), services()); + app.custom_titlebar = CUSTOM_TITLEBAR; + app.integrated_titlebar = cfg!(target_os = "macos"); if let Some(p) = cc.storage.and_then(|s| s.get_string(PREFS_KEY)) { app.load_prefs(&p); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 450e633b..086e1863 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -19,6 +19,7 @@ //! menu, or a browser menu with `target: {type: body|sketch|component, …}`); without a point, //! the open menu and its items. `ui.menuPick {item}` runs an item by id or label; //! `ui.rename {text?, commit?}` finishes the rename box +//! - `ui.window {action: minimize|maximize|restore|toggle|close}`: what the title bar's buttons do //! - `ui.resize {width, height}`, `app.quit` use std::sync::mpsc::Sender; @@ -252,6 +253,10 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> err("no rename in progress") } } + "ui.window" => match crate::titlebar::window_action(app, ctx, s("action").unwrap_or("")) { + Ok(()) => ok(json!({"custom_titlebar": app.custom_titlebar, "maximized": ctx.input(|i| i.viewport().maximized)})), + Err(e) => err(e), + }, "ui.resize" => { let (Some(w), Some(h)) = (f("width"), f("height")) else { return err("missing width/height") }; ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w.clamp(320.0, 8192.0) as f32, h.clamp(240.0, 8192.0) as f32))); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613c56aa..c276a936 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,7 @@ mod sketch_edit_tests; pub mod sketch_tools; pub mod theme; pub mod timeline; +pub mod titlebar; pub mod toolbar; pub mod tools; pub mod viewport; @@ -134,6 +135,10 @@ pub struct SolveApp { pub last_command: Option<(String, String)>, pub status: Option<(String, f64, bool)>, pub quit_requested: bool, + /// The application bar is the window's title bar (Windows, Linux: no OS decorations). + pub custom_titlebar: bool, + /// macOS: the traffic lights sit over the application bar's left end. + pub integrated_titlebar: bool, pub frame_ms: f64, pub synthetic: Vec, control_rx: Option>, @@ -166,6 +171,8 @@ impl SolveApp { last_command: None, status: None, quit_requested: false, + custom_titlebar: false, + integrated_titlebar: false, frame_ms: 0.0, synthetic: Vec::new(), control_rx: None, @@ -569,6 +576,9 @@ impl SolveApp { dialogs::show(self, ui.ctx()); params_dialog::show(self, ui.ctx()); context_menu::show(self, ui.ctx()); + if self.custom_titlebar { + titlebar::resize_zones(ui); + } palette::popup(self, ui.ctx()); self.frame_ms = now_ms() - t0; } diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs new file mode 100644 index 00000000..8385d45c --- /dev/null +++ b/crates/ui-egui/src/titlebar.rs @@ -0,0 +1,349 @@ +//! The window's own title bar on Windows and Linux, where the window has no OS decorations +//! ([`SolveApp::custom_titlebar`]): empty space in the application bar drags the window and a +//! double-click there maximizes or restores it, caption buttons at the far right minimize, +//! maximize/restore and close, and thin invisible zones along the window's edges resize it. +//! Dragging uses the OS's own move loop, so Windows snapping (to the screen edges and corners) +//! works as with a native title bar. macOS keeps its traffic lights over the bar +//! ([`SolveApp::integrated_titlebar`]); the web build has neither. +//! +//! Adapted from VectorCraft's `crates/ui-egui/src/titlebar.rs` (MIT OR Apache-2.0). + +use egui::{Color32, CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2}; + +use crate::SolveApp; +use crate::theme::Tokens; + +/// 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; +/// Room for the macOS traffic lights at the bar's left end. +pub const TRAFFIC_LIGHTS: f32 = 76.0; +/// Resize zone thickness along an edge, and the side of a corner zone. +const EDGE: f32 = 5.0; +const CORNER: f32 = 12.0; +/// The Close button's hover colour (as on Windows). +const CLOSE_HOVER: Color32 = Color32::from_rgb(196, 43, 28); + +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum Caption { + Minimize, + Maximize, + Close, +} + +/// Left to right. +pub const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::Close]; + +/// Caption button ids (stable, so tests and agents can find them). +pub 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))); +} + +/// Window actions for the control channel (`ui.window`): minimize, maximize, restore, toggle +/// (maximize or restore), close. +pub fn window_action(app: &mut SolveApp, ctx: &egui::Context, action: &str) -> Result<(), String> { + match action { + "minimize" => ctx.send_viewport_cmd(ViewportCommand::Minimized(true)), + "maximize" => ctx.send_viewport_cmd(ViewportCommand::Maximized(true)), + "restore" => ctx.send_viewport_cmd(ViewportCommand::Maximized(false)), + "toggle" => toggle_maximize(ctx), + "close" => { + app.quit_requested = true; + ctx.send_viewport_cmd(ViewportCommand::Close); + } + other => return Err(format!("unknown window action `{other}` (minimize, maximize, restore, toggle, close)")), + } + Ok(()) +} + +/// 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 buttons, registered later, take +/// their 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 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 application bar's +/// rect), full bar height, flush with the window's edge so a maximized window's corner hits +/// Close. +pub fn caption_buttons(app: &mut SolveApp, ui: &mut Ui, bar: Rect) { + let t = Tokens::get(); + let max = maximized(ui.ctx()); + let mut clicked = None; + 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())); + 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 => CLOSE_HOVER.gamma_multiply(0.8), + (_, true) if close => CLOSE_HOVER, + (true, _) => Color32::from_white_alpha(18), + (_, true) => Color32::from_white_alpha(30), + _ => Color32::TRANSPARENT, + }; + ui.painter().rect_filled(r, 0.0, fill); + let ink = if close && resp.hovered() { Color32::WHITE } else { t.app_bar_text }; + 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", + }; + if resp.on_hover_text(tip).clicked() { + clicked = Some(c); + } + } + let action = match clicked { + Some(Caption::Minimize) => "minimize", + Some(Caption::Maximize) => "toggle", + Some(Caption::Close) => "close", + None => return, + }; + let _ = window_action(app, &ui.ctx().clone(), action); +} + +/// 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}; + use solvecraft_engine::Session; + + /// A headless window `width` pt wide showing the application bar (as the title bar) and the + /// resize zones. + struct Win { + ctx: egui::Context, + app: SolveApp, + width: f32, + time: f64, + maximized: bool, + } + + impl Win { + fn new(width: f32) -> Self { + let ctx = egui::Context::default(); + let mut app = SolveApp::new(Session::default(), Default::default()); + app.custom_titlebar = true; + Self { ctx, app, width, time: 0.0, maximized: false } + } + + fn frame(&mut self, events: Vec) -> egui::FullOutput { + self.time += 0.05; + let mut input = RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 700.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 mut out = self.ctx.run_ui(input, |ui| { + crate::toolbar::app_bar(app, ui); + if app.custom_titlebar { + resize_zones(ui); + } + }); + out.textures_delta.clear(); + out + } + + /// Frames for a pointer gesture: move to `path[0]`, press, move along `path`, release. + /// The viewport commands the frames sent. + fn gesture(&mut self, path: &[Pos2]) -> Vec { + 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 { + self.gesture(&[p]) + } + + fn caption(&self, c: Caption) -> Rect { + self.ctx.read_response(caption_id(c)).map(|r| r.rect).unwrap_or(Rect::NOTHING) + } + } + + fn texts(out: &egui::FullOutput) -> Vec<(String, Rect)> { + fn walk(s: &egui::Shape, v: &mut Vec<(String, Rect)>) { + match s { + egui::Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))), + egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)), + _ => {} + } + } + let mut v = vec![]; + out.shapes.iter().for_each(|c| walk(&c.shape, &mut v)); + v + } + + #[test] + fn caption_buttons_sit_in_the_corner_and_nothing_runs_under_them() { + for width in [960.0, 1600.0] { + let mut w = Win::new(width); + w.frame(vec![]); + let out = 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!([min, max, close].iter().all(|r| r.width() == BUTTON_WIDTH)); + assert_eq!((min.right(), max.right()), (max.left(), close.left())); + let texts = texts(&out); + let version = texts.iter().find(|(t, _)| t.starts_with('v')).map(|(_, r)| *r); + assert!(version.is_some_and(|r| r.right() <= min.left() - 4.0), "the version stays left of the buttons: {version:?}"); + for (t, r) in &texts { + assert!(r.right() <= min.left() || r.top() >= min.bottom(), "`{t}` runs under the caption buttons at {width}"); + } + } + } + + #[test] + fn no_caption_buttons_without_a_custom_title_bar() { + let mut w = Win::new(1200.0); + w.app.custom_titlebar = false; + w.frame(vec![]); + w.frame(vec![]); + assert_eq!(w.caption(Caption::Close), Rect::NOTHING); + } + + #[test] + fn minimize_maximize_and_close() { + let mut w = Win::new(1200.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)); + assert!(w.app.quit_requested); + } + + #[test] + fn empty_bar_space_drags_and_double_click_maximizes() { + let mut w = Win::new(1600.0); + w.frame(vec![]); + let empty = pos2(500.0, 17.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:?}"); + w.maximized = true; + w.time += 1.0; + let mut cmds = w.click(empty); + cmds.extend(w.click(empty)); + assert!(cmds.contains(&ViewportCommand::Maximized(false)), "{cmds:?}"); + // The bar's buttons keep their clicks: dragging from Undo doesn't move the window. + w.time += 1.0; + let undo = pos2(128.0 + 2.0 * 30.0, 17.0); + let cmds = w.gesture(&[undo, undo + vec2(12.0, 4.0), undo + vec2(30.0, 8.0)]); + assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::StartDrag)), "{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, 698.0), South), + (pos2(2.0, 2.0), NorthWest), + (pos2(997.0, 3.0), NorthEast), + (pos2(3.0, 697.0), SouthWest), + (pos2(998.0, 698.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:?}"); + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 2e9881e8..1c04231c 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -38,11 +38,16 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); egui::Panel::top("sc_appbar").exact_size(34.0).frame(egui::Frame::NONE.fill(t.app_bar)).show(ui, |ui| { let r = ui.max_rect(); + // The bar is the window's title bar: its empty space moves the window. + if app.custom_titlebar || app.integrated_titlebar { + crate::titlebar::drag_area(ui, r); + } + let left = r.left() + if app.integrated_titlebar { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 }; // Brand mark: a small solid block drawn in code. - let logo = Rect::from_center_size(pos2(r.left() + 20.0, r.center().y), vec2(20.0, 20.0)); + let logo = Rect::from_center_size(pos2(left + 20.0, r.center().y), vec2(20.0, 20.0)); icons::paint(ui.painter(), logo, "box", Color32::from_rgb(220, 226, 236), Color32::from_rgb(90, 160, 240), Color32::WHITE); - ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, "SolveCraft", FontId::proportional(14.0), t.app_bar_text); - let mut x = r.left() + 128.0; + ui.painter().text(pos2(left + 36.0, r.center().y), Align2::LEFT_CENTER, "SolveCraft", FontId::proportional(14.0), t.app_bar_text); + let mut x = left + 128.0; let mut click = |ui: &mut egui::Ui, icon: &str, tip: &str| -> bool { let br = Rect::from_center_size(pos2(x, r.center().y), vec2(26.0, 26.0)); x += 30.0; @@ -91,13 +96,17 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { let tab = Rect::from_min_size(pos2(r.center().x - 110.0, r.top() + 5.0), vec2(220.0, r.height() - 5.0)); ui.painter().rect_filled(tab, egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 }, t.toolbar); ui.painter().text(tab.center(), Align2::CENTER_CENTER, name, FontId::proportional(13.0), t.text); + let captions = if app.custom_titlebar { crate::titlebar::WIDTH } else { 0.0 }; ui.painter().text( - pos2(r.right() - 12.0, r.center().y), + pos2(r.right() - 12.0 - captions, r.center().y), Align2::RIGHT_CENTER, format!("v{}", env!("CARGO_PKG_VERSION")), FontId::proportional(11.0), Color32::from_rgb(160, 168, 182), ); + if app.custom_titlebar { + crate::titlebar::caption_buttons(app, ui, r); + } }); file_menu(app, ui.ctx()); } @@ -108,67 +117,70 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) { return; } let mut close = false; - let resp = egui::Area::new(egui::Id::new("sc_file_area")).fixed_pos(pos2(100.0, 34.0)).order(egui::Order::Foreground).show(ctx, |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(190.0); - let item = |ui: &mut egui::Ui, label: &str, key: &str| { - ui.add(egui::Button::new(label).shortcut_text(key).frame(false).min_size(vec2(180.0, 22.0))).clicked() - }; - if item(ui, "New Design", "Ctrl+N") { - let _ = app.run("NewDocumentCommand", json!({})); - app.fit_view(); - close = true; - } - if item(ui, "Open…", "Ctrl+O") { - if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) { - app.open_path(&p); + let resp = egui::Area::new(egui::Id::new("sc_file_area")) + .fixed_pos(pos2(100.0 + if app.integrated_titlebar { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 }, 34.0)) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(190.0); + let item = |ui: &mut egui::Ui, label: &str, key: &str| { + ui.add(egui::Button::new(label).shortcut_text(key).frame(false).min_size(vec2(180.0, 22.0))).clicked() + }; + if item(ui, "New Design", "Ctrl+N") { + let _ = app.run("NewDocumentCommand", json!({})); + app.fit_view(); + close = true; } - close = true; - } - if item(ui, "Insert STEP…", "") { - app.start("FusionImportCommandFromToolbar"); - close = true; - } - if item(ui, "Insert Mesh…", "") { - app.start("ParaMeshInsertAlignCommand"); - close = true; - } - if item(ui, "Save", "Ctrl+S") { - save(app); - close = true; - } - if item(ui, "Save As…", "") { - save_as(app); - close = true; - } - ui.separator(); - if item(ui, "Export STL…", "") { - export(app, "stl"); - close = true; - } - if item(ui, "Export STEP…", "") { - export(app, "step"); - close = true; - } - if item(ui, "Export 3MF…", "") { - export(app, "3mf"); - close = true; - } - if item(ui, "Export OBJ…", "") { - export(app, "obj"); - close = true; - } - ui.separator(); - if item(ui, "Preferences…", "") { - app.dialog = Some(crate::dialogs::Dialog::preferences()); - close = true; - } - if item(ui, "Quit", "") { - app.quit_requested = true; - close = true; - } + if item(ui, "Open…", "Ctrl+O") { + if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) { + app.open_path(&p); + } + close = true; + } + if item(ui, "Insert STEP…", "") { + app.start("FusionImportCommandFromToolbar"); + close = true; + } + if item(ui, "Insert Mesh…", "") { + app.start("ParaMeshInsertAlignCommand"); + close = true; + } + if item(ui, "Save", "Ctrl+S") { + save(app); + close = true; + } + if item(ui, "Save As…", "") { + save_as(app); + close = true; + } + ui.separator(); + if item(ui, "Export STL…", "") { + export(app, "stl"); + close = true; + } + if item(ui, "Export STEP…", "") { + export(app, "step"); + close = true; + } + if item(ui, "Export 3MF…", "") { + export(app, "3mf"); + close = true; + } + if item(ui, "Export OBJ…", "") { + export(app, "obj"); + close = true; + } + ui.separator(); + if item(ui, "Preferences…", "") { + app.dialog = Some(crate::dialogs::Dialog::preferences()); + close = true; + } + if item(ui, "Quit", "") { + app.quit_requested = true; + close = true; + } + }); }); - }); let just_opened = ctx.data(|d| d.get_temp::(egui::Id::new("sc_file_menu_opened"))) == Some(ctx.cumulative_pass_nr()); if close || (!just_opened && resp.response.clicked_elsewhere()) { ctx.data_mut(|d| d.insert_temp(id, false)); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 67c6b72e..e447425f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -38,6 +38,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `ui.text` | `text` | typed text | | `ui.screenshot` | `path?` | PNG of the window (needs a presented frame) | | `ui.render` | `path`, `width?`, `height?` | CPU render of the model with the current camera (no window needed) | +| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar) | | `ui.resize` | `width`, `height` | | | `app.quit` | — | |