diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 8d35e65d..30e29492 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -128,6 +128,10 @@ fn app_icon() -> egui::IconData { eframe::icon_data::from_png_bytes(png).unwrap_or_default() } +/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`). +/// macOS keeps its traffic lights over the integrated title strip. +const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); + fn main() -> eframe::Result { let mut control_port: Option = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); let mut files = Vec::new(); @@ -148,6 +152,7 @@ fn main() -> eframe::Result { .with_inner_size([1440.0, 900.0]) .with_min_inner_size([800.0, 500.0]) .with_drag_and_drop(true) + .with_decorations(!CUSTOM_TITLEBAR) .with_fullsize_content_view(true) .with_titlebar_shown(false) .with_title_shown(false) @@ -165,6 +170,7 @@ fn main() -> eframe::Result { let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string())); app.session.swatch_libraries.set_user_dir(swatches); app.integrated_titlebar = cfg!(target_os = "macos"); + app.custom_titlebar = CUSTOM_TITLEBAR; if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index c7a411fe..010ff5d3 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -8,12 +8,20 @@ use crate::panels::stroke as stroke_panel; use crate::state::{ZOOM_STOPS, zoom_label}; use crate::theme::{self, Tokens}; use crate::widgets::{self, paint_chip}; -use crate::{VectorcraftApp, icons, menus}; +use crate::{VectorcraftApp, icons, menus, titlebar}; +/// The application bar: brand mark, Home, menus, then Discord, search and the workspace switcher +/// at the right. With [`VectorcraftApp::custom_titlebar`] it is also the window's title bar +/// ([`titlebar`]): the caption buttons take the right end and the rest of the bar drags the window. pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + let custom = app.custom_titlebar; let left = if app.integrated_titlebar { 78 } else { 8 }; - egui::Panel::top("app_bar").exact_size(44.0).frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: 14, top: 0, bottom: 0 }).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| { + let frame = egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: if custom { 0 } else { 14 }, top: 0, bottom: 0 }); + let bar = egui::Panel::top("app_bar").exact_size(44.0).frame(frame.stroke(Stroke::new(1.0, t.border))).show(ui, |ui| { + if custom { + titlebar::drag_area(ui, ui.max_rect()); + } ui.horizontal_centered(|ui| { // Brand mark (our own artwork, matches the app icon). let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); @@ -23,19 +31,31 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { app.ui.dialog = Some(crate::state::Dialog::new("newDocument", json!({"preset": "Letter", "width": "612 pt", "height": "792 pt", "units": "Points", "artboards": 1, "colorMode": "RGB", "name": "Untitled-1"}))); } ui.add_space(2.0); - if app.native_menu { + let menus_end = if app.native_menu { let full = ui.max_rect(); ui.painter().text(full.center(), egui::Align2::CENTER_CENTER, "VectorCraft", egui::FontId::proportional(13.5), t.text); + ui.cursor().min.x } else { - menus::menu_bar(app, ui); - } + menus::menu_bar(app, ui) + }; + // The right-side group fills the space after the menus from the right; when it runs + // short, Discord goes first (it is also under Help), then the search box becomes an + // icon, then the workspace switcher narrows. let full = ui.max_rect(); - let right = egui::Rect::from_min_max(egui::pos2(full.right() - 440.0, full.top()), full.right_bottom()); + let right_edge = if custom { full.right() - titlebar::WIDTH - 10.0 } else { full.right() }; + let right = egui::Rect::from_min_max(egui::pos2(menus_end + 8.0, full.top()), egui::pos2(right_edge, full.bottom())); + let room = right.width(); + let ws = ui.painter().layout_no_wrap(app.ui.workspace.clone(), egui::FontId::proportional(12.0), t.text); + let ws_w = (ws.size().x + 36.0).clamp(112.0, 190.0); + let gap = ui.spacing().item_spacing.x; + let with_search = ws_w + 8.0 + gap + 200.0; + let search_full = room >= with_search; + let discord = room >= with_search + 10.0 + gap + crate::community::discord_width(ui, false); + let ws_w = if search_full { ws_w } else { ws_w.min(room - 8.0 - gap - 24.0).max(64.0) }; let mut rui = ui.new_child(egui::UiBuilder::new().max_rect(right).layout(egui::Layout::right_to_left(egui::Align::Center))); let ui = &mut rui; // Workspace switcher: shows the current workspace, opens Window → Workspace. - let ws = ui.painter().layout_no_wrap(app.ui.workspace.clone(), egui::FontId::proportional(12.0), t.text); - let (wr, wresp) = ui.allocate_exact_size(vec2((ws.size().x + 36.0).clamp(112.0, 190.0), 24.0), Sense::click()); + let (wr, wresp) = ui.allocate_exact_size(vec2(ws_w, 24.0), Sense::click()); ui.painter().rect_filled(wr, CornerRadius::same(4), if wresp.hovered() { t.hover } else { t.panel }); ui.painter().with_clip_rect(wr.shrink2(vec2(4.0, 0.0))).galley(wr.left_center() + vec2(10.0, -ws.size().y / 2.0), ws, t.text); icons::paint(ui, "chevron-down", egui::Rect::from_center_size(wr.right_center() - vec2(12.0, 0.0), vec2(12.0, 12.0)), t.text_dim); @@ -43,19 +63,29 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { egui::Popup::menu(&wresp).show(|ui| crate::workspaces::popup(app, ui)); ui.add_space(8.0); // Search box → command palette. - let (r, resp) = ui.allocate_exact_size(vec2(200.0, 24.0), Sense::click()); - ui.painter().rect_filled(r, CornerRadius::same(12), t.input); - ui.painter().rect_stroke(r, CornerRadius::same(12), Stroke::new(1.0, if resp.hovered() { t.input_border } else { t.divider }), StrokeKind::Inside); - icons::paint(ui, "search", egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(13.0, 13.0)), t.text_dim); - ui.painter().text(r.left_center() + vec2(26.0, 0.0), egui::Align2::LEFT_CENTER, "Search commands and tools", egui::FontId::proportional(11.5), t.text_dim); - if resp.clicked() { + let open_palette = if search_full { + let (r, resp) = ui.allocate_exact_size(vec2(200.0, 24.0), Sense::click()); + ui.painter().rect_filled(r, CornerRadius::same(12), t.input); + ui.painter().rect_stroke(r, CornerRadius::same(12), Stroke::new(1.0, if resp.hovered() { t.input_border } else { t.divider }), StrokeKind::Inside); + icons::paint(ui, "search", egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(13.0, 13.0)), t.text_dim); + ui.painter().text(r.left_center() + vec2(26.0, 0.0), egui::Align2::LEFT_CENTER, "Search commands and tools", egui::FontId::proportional(11.5), t.text_dim); + resp.clicked() + } else { + widgets::icon_button(ui, "search", "Search commands and tools", false, 24.0).clicked() + }; + if open_palette { app.ui.palette_open = true; app.ui.palette_query.clear(); } - ui.add_space(10.0); - crate::community::discord_button(app, ui, false); + if discord { + ui.add_space(10.0); + crate::community::discord_button(app, ui, false); + } }); }); + if custom { + titlebar::caption_buttons(app, ui, bar.response.rect); + } } /// A Control bar chip that opens something: `draw` paints the chip, a chevron follows it. diff --git a/crates/ui-egui/src/community.rs b/crates/ui-egui/src/community.rs index 0beb7a1f..16ac8ea5 100644 --- a/crates/ui-egui/src/community.rs +++ b/crates/ui-egui/src/community.rs @@ -10,12 +10,23 @@ use crate::{VectorcraftApp, icons}; /// Discord's brand colour (a colour, not a logo). const DISCORD: Color32 = Color32::from_rgb(0x58, 0x65, 0xF2); -/// A Discord pill button: compact for the header, large for About / Home. -pub fn discord_button(app: &mut VectorcraftApp, ui: &mut Ui, large: bool) -> egui::Response { +/// The Discord button's label, height and width. +fn discord_layout(ui: &Ui, large: bool) -> (std::sync::Arc, f32, f32) { let (h, font, label) = if large { (36.0, theme::semibold(14.0), "Join our Discord") } else { (24.0, theme::semibold(12.0), "Discord") }; let galley = ui.painter().layout_no_wrap(label.to_string(), font, Color32::WHITE); + let w = galley.size().x + h * 0.55 + h * 0.9; + (galley, h, w) +} + +/// The Discord button's width (the app bar drops it when space runs out). +pub fn discord_width(ui: &Ui, large: bool) -> f32 { + discord_layout(ui, large).2 +} + +/// A Discord pill button: compact for the header, large for About / Home. +pub fn discord_button(app: &mut VectorcraftApp, ui: &mut Ui, large: bool) -> egui::Response { + let (galley, h, w) = discord_layout(ui, large); let icon = h * 0.55; - let w = galley.size().x + icon + h * 0.9; let (r, resp) = ui.allocate_exact_size(vec2(w, h), Sense::click()); let fill = if resp.hovered() { DISCORD.gamma_multiply(0.85) } else { DISCORD }; ui.painter().rect_filled(r, CornerRadius::same((h / 2.0) as u8), fill); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 673c65bd..dfbcd2e8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -26,6 +26,7 @@ pub mod shortcut_editor; pub mod shortcuts; pub mod state; pub mod theme; +pub mod titlebar; pub mod toolbar; pub mod unsaved; pub mod widgets; @@ -146,6 +147,9 @@ pub struct VectorcraftApp { pub(crate) clipboard_in: Option, /// A URL to open through egui next frame (when the host has no `open_url` service). pending_url: Option, + /// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag, + /// double-click to maximize, caption buttons) and invisible edge zones resize the window. + pub custom_titlebar: bool, } impl VectorcraftApp { @@ -184,6 +188,7 @@ impl VectorcraftApp { last_time: 0.0, canvas_rect: None, hover_doc: None, + custom_titlebar: false, } } @@ -520,6 +525,9 @@ impl VectorcraftApp { panels::library_panel::show_window(self, &ctx); dialogs::show(self, &ctx); palette::show(self, &ctx); + if self.custom_titlebar { + titlebar::resize_zones(ui); + } self.perf.frame_ms = now_ms() - t0; let _ = json!(null); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6802cfe3..cb01b4f6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1396,23 +1396,28 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { } /// Render the menu bar. -pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) { +/// The in-window menu bar. Returns where its titles end (x): the bar itself takes the full width. +pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 { let t = Tokens::get(ui.ctx()); let mut clicked: Option<(String, Value)> = None; let tree = menu_tree(); - egui::MenuBar::new().ui(ui, |ui| { - for (i, (title, items)) in tree.iter().enumerate() { - let text = if i == 0 { - egui::RichText::new(*title).font(theme::semibold(13.0)).color(t.text) - } else { - egui::RichText::new(*title).size(13.0).color(t.text) - }; - ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked)); - } - }); + let end = egui::MenuBar::new() + .ui(ui, |ui| { + for (i, (title, items)) in tree.iter().enumerate() { + let text = if i == 0 { + egui::RichText::new(*title).font(theme::semibold(13.0)).color(t.text) + } else { + egui::RichText::new(*title).size(13.0).color(t.text) + }; + ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked)); + } + ui.cursor().min.x + }) + .inner; if let Some((id, p)) = clicked { invoke(app, &id, p); } + end } /// A top-level menu's popup: as wide as its widest item (label plus shortcut), at least 230 pt. diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index bfef706c..14d7b398 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -78,6 +78,9 @@ pub struct Tokens { pub flyout_active: Color32, /// Hint-bar / tab-bar heights etc. scale with this base font size. pub font_size: f32, + /// Window Close button (custom title bar) when hovered, and its glyph there. + pub caption_close: Color32, + pub caption_close_text: Color32, } impl Tokens { @@ -116,6 +119,8 @@ impl Tokens { anchor_selected: hex(0x3d82ff), flyout_active: hex(0x54b2f7), font_size: 13.0, + caption_close: hex(0xc42b1c), + caption_close_text: Color32::WHITE, }; match b { Brightness::Dark => base, diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs new file mode 100644 index 00000000..9209b30a --- /dev/null +++ b/crates/ui-egui/src/titlebar.rs @@ -0,0 +1,364 @@ +//! The window's own title bar on Windows and Linux, where the window has no OS decorations +//! ([`VectorcraftApp::custom_titlebar`]): empty space in the app bar 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. + +use egui::{CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2}; +use serde_json::json; + +use crate::VectorcraftApp; +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; +/// Resize zone thickness along an edge, and the side of a corner zone. +const EDGE: f32 = 5.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 app bar's outer rect), full +/// bar height down to its bottom border, flush with the window's edge so a maximized window's +/// corner hits Close. Close runs `app.quit`, which asks about unsaved documents first. +pub fn caption_buttons(app: &mut VectorcraftApp, ui: &mut Ui, bar: Rect) { + let t = Tokens::get(ui.ctx()); + 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() - 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 => t.caption_close.gamma_multiply(0.8), + (_, true) if close => t.caption_close, + (true, _) => t.tool_active, + (_, true) => t.hover, + _ => egui::Color32::TRANSPARENT, + }; + ui.painter().rect_filled(r, 0.0, fill); + let ink = if close && resp.hovered() { t.caption_close_text } else { t.icon }; + 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); + } + } + match clicked { + Some(Caption::Minimize) => ui.ctx().send_viewport_cmd(ViewportCommand::Minimized(true)), + Some(Caption::Maximize) => toggle_maximize(ui.ctx()), + Some(Caption::Close) => { + app.run("app.quit", json!({})).ok(); + } + 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}; + use vectorcraft_engine::Session; + + /// A headless window `width` pt wide showing the app bar (as the title bar) and the resize zones. + struct Win { + ctx: egui::Context, + app: VectorcraftApp, + width: f32, + time: f64, + maximized: bool, + } + + impl Win { + fn new(width: f32) -> Self { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + let mut app = VectorcraftApp::new(Session::new(), 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, 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 mut out = self.ctx.run_ui(input, |ui| { + crate::chrome::app_bar(app, ui); + resize_zones(ui); + }); + out.textures_delta.clear(); + out + } + } + + 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 + } + + fn button(p: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } + } + + impl Win { + /// 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 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) + } + + /// The bar's clickable widgets other than its background and the caption buttons. + fn bar_widgets(&self) -> Vec { + let skip: Vec = std::iter::once(Id::new("titlebar-drag")).chain(CAPTIONS.map(caption_id)).collect(); + self.ctx.viewport(|v| { + v.prev_pass + .widgets + .layers() + .filter(|(l, _)| l.order != Order::Foreground) + .flat_map(|(_, ws)| ws.iter()) + .filter(|w| w.sense.senses_click() && w.rect.top() < 44.0 && !skip.contains(&w.id)) + .map(|w| w.rect) + .collect() + }) + } + } + + #[test] + fn caption_buttons_take_the_right_end_and_nothing_overlaps_down_to_800_pt() { + for width in [800.0, 1100.0, 1440.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!(close.height() >= 42.0 && [min, max, close].iter().all(|r| r.width() == BUTTON_WIDTH)); + assert_eq!((min.right(), max.right()), (max.left(), close.left())); + let widgets = w.bar_widgets(); + assert!(widgets.len() >= 12, "home, nine menus, workspace switcher, search: {widgets:?}"); + for (i, a) in widgets.iter().enumerate() { + assert!(a.right() <= min.left() - 8.0, "{a:?} runs into the caption buttons at {width}"); + for b in &widgets[i + 1..] { + assert!(!a.shrink(0.5).intersects(b.shrink(0.5)), "{a:?} overlaps {b:?} at {width}"); + } + } + // Wide windows show the whole group; narrow ones drop Discord and shorten the search. + let texts: Vec = texts(&out).into_iter().map(|(t, _)| t).collect(); + let has = |s: &str| texts.iter().any(|t| t == s); + assert_eq!((has("Discord"), has("Search commands and tools")), (width > 1000.0, width > 1000.0), "{width}: {texts:?}"); + } + } + + #[test] + fn close_runs_the_quit_flow_instead_of_closing_the_window() { + let mut w = Win::new(1000.0); + w.app.run("file.new", serde_json::json!({"width": 100, "height": 100})).unwrap(); + w.app.run("shape.rectangle", serde_json::json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + w.frame(vec![]); + let close = w.caption(Caption::Close).center(); + let cmds = w.click(close); + assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::Close)), "{cmds:?}"); + let dialog = w.app.ui.dialog.as_ref().map(|d| d.kind.clone()); + assert_eq!(dialog.as_deref(), Some(crate::unsaved::KIND), "a modified document asks first"); + assert_ne!(w.app.ui.status, "quit"); + // Nothing to save: the host is told to quit. + w.app.ui.dialog = None; + w.app.session.doc_mut().unwrap().mark_saved(); + w.click(close); + assert_eq!(w.app.ui.status, "quit"); + } + + #[test] + fn minimize_and_maximize_follow_the_window_state() { + let mut w = Win::new(1000.0); + w.frame(vec![]); + let (min, max) = (w.caption(Caption::Minimize).center(), w.caption(Caption::Maximize).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"); + } + + #[test] + fn empty_bar_space_drags_and_double_click_maximizes() { + let mut w = Win::new(1440.0); + w.frame(vec![]); + let empty = pos2(700.0, 22.0); + assert!(w.bar_widgets().iter().all(|r| !r.contains(empty)), "the probe point must be empty bar space"); + 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:?}"); + // Double-click toggles maximize. + 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:?}"); + // Widgets keep their clicks: dragging from a menu title doesn't move the window. + let file = texts(&w.frame(vec![])).into_iter().find(|(t, _)| t == "File").map(|(_, r)| r.center()); + let file = file.expect("the File menu title"); + let cmds = w.gesture(&[file, file + vec2(12.0, 4.0), file + 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, 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:?}"); + } + let out = w.frame(vec![Event::PointerMoved(pos2(1.0, 300.0))]); + assert_eq!(out.platform_output.cursor_icon, CursorIcon::ResizeHorizontal); + // Maximized: no zones, and the window's top-right corner is Close. + 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:?}"); + assert!(w.click(pos2(999.5, 0.5)).is_empty()); + assert_eq!(w.app.ui.status, "quit"); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5c616691..a0278e51 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -94,3 +94,9 @@ folder, `selectedOnly` with `names`); `ui.dialog.confirm` runs `swatch.library.s Tile Edge Color: Object → Pattern → Tile Edge Color… (`ui.tileEdgeColor`) opens the `tileEdgeColor` dialog (field `color`: `#rrggbb` or a preset name such as "Light Blue"); `ui.dialog.confirm` sets the preference `patternTileEdgeColor` (`prefs.set`), the colour pattern editing mode draws the tile edge and the swatch bounds in. + +Window title bar: on Windows and Linux the window has no OS decorations and the app bar is the title bar. Its +caption buttons (Minimize, Maximize/Restore, Close) sit at the bar's right end, 46 pt each; they are window chrome, +not commands, so drive them with `ui.click` if needed. Close runs `app.quit` (the same `saveChanges` questions for +modified documents), and empty bar space and the 5 pt window edges move and resize the window. macOS and the web +build are unchanged.