//! The window's own title bar. On Windows and Linux the window has no OS decorations //! ([`ModelApp::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. //! macOS keeps its traffic lights over the bar ([`ModelApp::integrated_titlebar`]). //! //! Adapted from SolidCraft's `crates/ui-egui/src/titlebar.rs` (itself from VectorCraft's), both //! MIT OR Apache-2.0, see ATTRIBUTION.md. use egui::{Color32, CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2}; use crate::ModelApp; 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)) } pub 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 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`, full bar height, flush with /// the window's edge so a maximized window's corner hits Close. pub fn caption_buttons(app: &mut ModelApp, ui: &mut Ui, bar: Rect) { let t = Tokens::get(app.ui.dark); 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 ctx = ui.ctx(); match clicked { Some(Caption::Minimize) => ctx.send_viewport_cmd(ViewportCommand::Minimized(true)), Some(Caption::Maximize) => toggle_maximize(ctx), Some(Caption::Close) => { app.quit_requested = true; 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 egui::{Event, Pos2, RawInput, ViewportId}; use fieldcraft_engine::Session; use super::*; /// A headless window `width` pt wide showing the application bar and the resize zones. struct Win { ctx: egui::Context, app: ModelApp, width: f32, time: f64, } impl Win { fn new(width: f32) -> Self { let mut app = ModelApp::new(Session::default()); app.custom_titlebar = true; Self { ctx: egui::Context::default(), app, width, time: 0.0 } } fn frame(&mut self, events: Vec) -> egui::FullOutput { self.time += 0.05; let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 700.0))), time: Some(self.time), events, ..Default::default() }; let app = &mut self.app; let mut out = self.ctx.run_ui(input, |ui| { crate::appbar::app_bar(app, ui); resize_zones(ui); }); out.textures_delta.clear(); out } 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 caption(&self, c: Caption) -> Rect { self.ctx.read_response(caption_id(c)).map(|r| r.rect).unwrap_or(Rect::NOTHING) } } #[test] fn caption_buttons_sit_in_the_corner_and_close_quits() { let mut w = Win::new(1200.0); w.frame(vec![]); w.frame(vec![]); let close = w.caption(Caption::Close); assert_eq!((close.right(), close.top()), (1200.0, 0.0)); assert!(w.gesture(&[w.caption(Caption::Minimize).center()]).contains(&ViewportCommand::Minimized(true))); assert!(w.gesture(&[w.caption(Caption::Maximize).center()]).contains(&ViewportCommand::Maximized(true))); assert!(w.gesture(&[close.center()]).contains(&ViewportCommand::Close)); assert!(w.app.quit_requested); } #[test] fn empty_bar_space_drags_and_edges_resize() { let mut w = Win::new(1600.0); w.frame(vec![]); let empty = pos2(1000.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 cmds = w.gesture(&[pos2(1.0, 300.0), pos2(8.0, 300.0)]); assert_eq!(cmds, vec![ViewportCommand::BeginResize(ResizeDirection::West)]); } }