diff --git a/ROADMAP.md b/ROADMAP.md index 89c31d2..4f4435d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -150,7 +150,9 @@ dimension measured side by side. variants, properties, slots), styles and variables, prototype commands, find/replace, batch rename, copy/paste (internal + SVG/text), file new/open/save/import/export (native `.figcraft`, SVG in/out, PNG/JPG/WebP), version snapshots. -- **UI (egui, swappable):** the incumbent's 2026 layout: tab strip, icon rail, file header, pages and +- **UI (egui, swappable):** the incumbent's 2026 layout: tab strip that is also the window's title + bar (frameless on Windows/Linux with drawn window buttons, drag, double-click maximise and edge + resizing; macOS window buttons inset into it), icon rail, file header, pages and layers tree (rename, visibility/lock, drag to reorder/reparent), Assets panel, floating bottom toolbar with flyouts and mode switcher, Design / Prototype / Inspect (CSS) panels, colour picker (HSB square, hue/alpha, hex, gradient stops, image fills), main menu generated from the catalog, diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index a8e8275..329d757 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -112,6 +112,15 @@ fn main() -> eframe::Result { .with_inner_size([1440.0, 900.0]) .with_min_inner_size([800.0, 500.0]) .with_drag_and_drop(true); + // The tab strip is the title bar: macOS keeps its window buttons inset into it; elsewhere the + // window is frameless and FigCraft draws the buttons and resize edges. + if cfg!(target_os = "macos") { + viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false); + fig.ui.title_bar = figcraft_ui_egui::TitleBar::Inset; + } else { + viewport = viewport.with_decorations(false); + fig.ui.title_bar = figcraft_ui_egui::TitleBar::Custom; + } if let Some(icon) = icon { viewport = viewport.with_icon(icon); } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index e94c610..68be62b 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -46,6 +46,10 @@ pub enum Icon { Page, Check, Close, + /// Window buttons: minimise, maximise, restore. + WinMinimize, + WinMaximize, + WinRestore, Search, Grid, AlignLeft, @@ -389,6 +393,16 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { p.line(4.0, 4.0, 12.0, 12.0); p.line(12.0, 4.0, 4.0, 12.0); } + WinMinimize => p.line(3.5, 8.0, 12.5, 8.0), + WinMaximize => p.rect(3.5, 3.5, 12.5, 12.5, 0.5), + WinRestore => { + // A window in front of another. + p.rect(3.5, 5.5, 10.5, 12.5, 0.5); + p.line(5.5, 5.5, 5.5, 3.5); + p.line(5.5, 3.5, 12.5, 3.5); + p.line(12.5, 3.5, 12.5, 10.5); + p.line(12.5, 10.5, 10.5, 10.5); + } Search => { p.circle(7.0, 7.0, 4.0); p.line(10.0, 10.0, 13.5, 13.5); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a6de13e..8f14f84 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -47,6 +47,8 @@ mod layers_drag_tests; mod perf_tests; #[cfg(test)] mod present_tests; +#[cfg(test)] +mod titlebar_tests; use std::collections::BTreeSet; use std::path::PathBuf; @@ -103,6 +105,18 @@ pub enum LeftTab { Assets, } +/// The window's title bar. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum TitleBar { + /// The system draws it (and the browser): the tab strip sits below. + #[default] + Native, + /// A frameless window: the tab strip is the title bar, with our own window buttons and edges. + Custom, + /// macOS: the system's window buttons inset at the left of the tab strip. + Inset, +} + /// How often open documents get an autosave checkpoint (seconds). pub const AUTOSAVE_SECS: f64 = 30.0 * 60.0; @@ -126,6 +140,8 @@ pub struct UiState { pub preferences: bool, pub shortcuts_panel: bool, pub home: bool, + /// How the window's title bar is drawn (the desktop app sets it when it opens the window). + pub title_bar: TitleBar, pub toast: Option<(String, f64)>, pub present: Option, /// The prototype preview pane floating over the canvas, when open. @@ -192,6 +208,7 @@ impl Default for UiState { preferences: false, shortcuts_panel: false, home: false, + title_bar: TitleBar::Native, toast: None, present: None, preview: None, @@ -417,6 +434,9 @@ impl FigApp { let t = theme::Tokens::get(&ctx); // Control requests run first so their results reflect this frame. control::drain(self, &ctx); + if self.ui.title_bar == TitleBar::Custom { + tabs::resize_edges(&ctx); + } theme::set_doc_profile(&ctx, self.session.doc().map_or(figcraft_color::ColorProfile::Srgb, |d| d.color_profile)); // The Inspect tab measures on hover without Alt. self.session.tool.redlines = self.ui.right_tab == RightTab::Inspect; diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 079e7db..b110591 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -1,11 +1,13 @@ -//! The desktop tab strip: home, one tab per open file, new tab. +//! The desktop tab strip: home, one tab per open file, new tab. In a frameless window it is also +//! the title bar: its empty space drags the window (double-click maximises), and it carries the +//! window buttons; the window's edges resize it. use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::json; -use crate::FigApp; use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular}; +use crate::{FigApp, TitleBar}; pub const HEIGHT: f32 = 40.0; @@ -13,7 +15,19 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::top("tab_bar").exact_size(HEIGHT).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { let rect = ui.max_rect(); - let mut x = rect.min.x + if cfg!(target_os = "macos") { 76.0 } else { 8.0 }; + // Empty space moves the window (first, so the tabs and buttons on top take their clicks). + if app.ui.title_bar != TitleBar::Native { + let drag = ui.interact(rect, ui.id().with("titlebar_drag"), Sense::click_and_drag()); + if drag.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + if drag.double_clicked() { + let max = ui.input(|i| i.viewport().maximized.unwrap_or(false)); + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); + } + } + let buttons_w = if app.ui.title_bar == TitleBar::Custom { window_buttons(ui, rect) } else { 0.0 }; + let mut x = rect.min.x + if app.ui.title_bar == TitleBar::Inset { 76.0 } else { 8.0 }; // Home. let hr = Rect::from_min_size(pos2(x, rect.min.y), vec2(44.0, HEIGHT)); let hresp = ui.interact(hr, ui.id().with("home"), Sense::click()).on_hover_text("Home"); @@ -82,8 +96,8 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { app.run("file.new", json!({})); app.ui.home = false; } - // Right side: theme toggle. - let tr = Rect::from_center_size(pos2(rect.max.x - 24.0, rect.center().y), vec2(28.0, 28.0)); + // Right side: theme toggle (left of the window buttons). + let tr = Rect::from_center_size(pos2(rect.max.x - buttons_w - 24.0, rect.center().y), vec2(28.0, 28.0)); let tresp = ui.interact(tr, ui.id().with("theme"), Sense::click()).on_hover_text("Toggle light/dark theme"); if tresp.hovered() { ui.painter().rect_filled(tr, CornerRadius::same(5), t.hover); @@ -113,3 +127,66 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { let _ = Align2::LEFT_TOP; }); } + +/// Minimise, maximise/restore and close at the right end of the title bar; returns their width. +fn window_buttons(ui: &mut Ui, bar: Rect) -> f32 { + const W: f32 = 46.0; + let t = Tokens::get(ui.ctx()); + let max = ui.input(|i| i.viewport().maximized.unwrap_or(false)); + let buttons = [ + (Icon::WinMinimize, "Minimize", egui::ViewportCommand::Minimized(true)), + (if max { Icon::WinRestore } else { Icon::WinMaximize }, if max { "Restore" } else { "Maximize" }, egui::ViewportCommand::Maximized(!max)), + (Icon::Close, "Close", egui::ViewportCommand::Close), + ]; + for (k, (icon, tip, cmd)) in buttons.into_iter().enumerate() { + let r = Rect::from_min_size(pos2(bar.max.x - W * (3 - k) as f32, bar.min.y), vec2(W, bar.height())); + let resp = ui.interact(r, ui.id().with(("window_button", k)), Sense::click()).on_hover_text(tip); + let close = k == 2; + let mut col = t.icon; + if resp.hovered() { + // Closing turns the button red, the others take the hover tint. + let fill = if close { Color32::from_rgb(0xE8, 0x11, 0x23) } else { t.hover }; + ui.painter().rect_filled(r, CornerRadius::ZERO, fill); + if close { + col = Color32::WHITE; + } + } + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), icon, col); + if resp.clicked() { + ui.ctx().send_viewport_cmd(cmd); + } + } + W * 3.0 +} + +/// Invisible strips along a frameless window's edges and corners that resize it. +pub fn resize_edges(ctx: &egui::Context) { + use egui::{CursorIcon, ResizeDirection as D}; + if ctx.input(|i| i.viewport().maximized.unwrap_or(false) || i.viewport().fullscreen.unwrap_or(false)) { + return; + } + const E: f32 = 5.0; + let s = ctx.content_rect(); + let (x0, y0, x1, y1) = (s.min.x, s.min.y, s.max.x, s.max.y); + let zones = [ + (Rect::from_min_max(pos2(x0 + E, y0), pos2(x1 - E, y0 + E)), D::North, CursorIcon::ResizeNorth), + (Rect::from_min_max(pos2(x0 + E, y1 - E), pos2(x1 - E, y1)), D::South, CursorIcon::ResizeSouth), + (Rect::from_min_max(pos2(x0, y0 + E), pos2(x0 + E, y1 - E)), D::West, CursorIcon::ResizeWest), + (Rect::from_min_max(pos2(x1 - E, y0 + E), pos2(x1, y1 - E)), D::East, CursorIcon::ResizeEast), + (Rect::from_min_max(pos2(x0, y0), pos2(x0 + E, y0 + E)), D::NorthWest, CursorIcon::ResizeNorthWest), + (Rect::from_min_max(pos2(x1 - E, y0), pos2(x1, y0 + E)), D::NorthEast, CursorIcon::ResizeNorthEast), + (Rect::from_min_max(pos2(x0, y1 - E), pos2(x0 + E, y1)), D::SouthWest, CursorIcon::ResizeSouthWest), + (Rect::from_min_max(pos2(x1 - E, y1 - E), pos2(x1, y1)), D::SouthEast, CursorIcon::ResizeSouthEast), + ]; + egui::Area::new(egui::Id::new("window_resize_edges")).order(egui::Order::Foreground).fixed_pos(s.min).interactable(true).show(ctx, |ui| { + for (k, (r, dir, cursor)) in zones.into_iter().enumerate() { + let resp = ui.interact(r, egui::Id::new(("window_edge", k)), Sense::drag()); + if resp.hovered() || resp.dragged() { + ui.ctx().set_cursor_icon(cursor); + } + if resp.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::BeginResize(dir)); + } + } + }); +} diff --git a/crates/ui-egui/src/titlebar_tests.rs b/crates/ui-egui/src/titlebar_tests.rs new file mode 100644 index 0000000..796cb67 --- /dev/null +++ b/crates/ui-egui/src/titlebar_tests.rs @@ -0,0 +1,150 @@ +//! The frameless window's title bar, driven through egui headless: its buttons minimise, maximise +//! and close the window, its empty space drags (double-click maximises), and the window's edges +//! resize it. A system-drawn title bar leaves all of that to the system. + +use egui::{Event, PointerButton, Pos2, RawInput, Rect, ResizeDirection, ViewportCommand, ViewportId, pos2, vec2}; +use figcraft_engine::Session; + +use crate::{FigApp, NoServices, TitleBar}; + +const SIZE: egui::Vec2 = vec2(1440.0, 900.0); + +struct Ui { + ctx: egui::Context, + app: FigApp, + time: f64, + /// What the system reports about the window. + maximized: bool, +} + +impl Ui { + fn new(title_bar: TitleBar) -> Self { + let mut app = FigApp::new(Session::new(), Box::new(NoServices)); + app.ui.title_bar = title_bar; + let mut u = Self { ctx: egui::Context::default(), app, time: 0.0, maximized: false }; + for _ in 0..3 { + u.frame(vec![]); + } + u + } + + /// Run one frame; returns the window commands it sent. + fn frame(&mut self, events: Vec) -> Vec { + self.time += 1.0 / 60.0; + let mut input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SIZE)), time: Some(self.time), events, ..Default::default() }; + input.viewports.entry(ViewportId::ROOT).or_default().maximized = Some(self.maximized); + let app = &mut self.app; + let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + out.viewport_output.get(&ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default() + } + + fn button(&mut self, p: Pos2, pressed: bool) -> Vec { + self.frame(vec![ + Event::PointerMoved(p), + Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }, + ]) + } + + fn click(&mut self, p: Pos2) -> Vec { + let mut cmds = self.button(p, true); + cmds.extend(self.button(p, false)); + cmds + } + + /// Press on `p` and move away from it; the commands sent while dragging. + fn drag(&mut self, p: Pos2, by: egui::Vec2) -> Vec { + let mut cmds = self.button(p, true); + for k in 1..=5 { + cmds.extend(self.frame(vec![Event::PointerMoved(p + by * (k as f32 / 5.0))])); + } + cmds.extend(self.button(p + by, false)); + cmds + } +} + +/// The middle of window button `k` (0 minimise, 1 maximise, 2 close) at the bar's right end. +fn window_button(k: usize) -> Pos2 { + pos2(SIZE.x - 46.0 * (3 - k) as f32 + 23.0, crate::tabs::HEIGHT / 2.0) +} + +/// Empty title bar space, right of the tabs. +const EMPTY: Pos2 = pos2(800.0, 20.0); + +#[test] +fn window_buttons_minimise_maximise_and_close() { + let mut u = Ui::new(TitleBar::Custom); + assert!(u.click(window_button(0)).contains(&ViewportCommand::Minimized(true))); + assert!(u.click(window_button(1)).contains(&ViewportCommand::Maximized(true))); + assert!(u.click(window_button(2)).contains(&ViewportCommand::Close)); +} + +#[test] +fn maximise_button_restores_a_maximised_window() { + let mut u = Ui::new(TitleBar::Custom); + u.maximized = true; + let cmds = u.click(window_button(1)); + assert!(cmds.contains(&ViewportCommand::Maximized(false)), "{cmds:?}"); +} + +#[test] +fn empty_bar_drags_the_window_and_double_click_maximises() { + let mut u = Ui::new(TitleBar::Custom); + assert!(u.drag(EMPTY, vec2(40.0, 10.0)).contains(&ViewportCommand::StartDrag)); + let mut cmds = u.click(EMPTY); + cmds.extend(u.click(EMPTY)); + assert!(cmds.contains(&ViewportCommand::Maximized(true)), "{cmds:?}"); + // A single click on the bar does nothing to the window. + u.time += 1.0; + assert!(u.click(EMPTY).is_empty()); +} + +#[test] +fn edges_and_corners_resize_the_window() { + let mut u = Ui::new(TitleBar::Custom); + // Not while maximised. + u.maximized = true; + assert!(u.drag(pos2(SIZE.x - 2.0, SIZE.y - 2.0), vec2(-30.0, -30.0)).iter().all(|c| !matches!(c, ViewportCommand::BeginResize(_)))); + u.maximized = false; + u.frame(vec![]); + for (p, dir) in [ + (pos2(SIZE.x - 2.0, SIZE.y - 2.0), ResizeDirection::SouthEast), + (pos2(SIZE.x - 2.0, 2.0), ResizeDirection::NorthEast), + (pos2(2.0, SIZE.y - 2.0), ResizeDirection::SouthWest), + (pos2(2.0, 450.0), ResizeDirection::West), + (pos2(720.0, SIZE.y - 2.0), ResizeDirection::South), + (pos2(720.0, 2.0), ResizeDirection::North), + ] { + // Pulled in towards the middle. + let inward = ((SIZE / 2.0).to_pos2() - p).normalized() * 30.0; + assert!(u.drag(p, inward).contains(&ViewportCommand::BeginResize(dir)), "{dir:?}"); + } +} + +#[test] +fn tabs_and_new_tab_keep_their_clicks() { + let mut u = Ui::new(TitleBar::Custom); + u.app.ui.home = true; + // The open file's tab switches to it. + assert!(u.click(pos2(100.0, 20.0)).is_empty()); + assert!(!u.app.ui.home); + // The `+` right of it opens a new file (found by clicking along the bar past the tab). + let mut opened = false; + for x in (176..320).step_by(4) { + u.time += 1.0; + assert!(u.click(pos2(x as f32, 20.0)).is_empty(), "{x}"); + if u.app.session.docs.len() > 1 { + opened = true; + break; + } + } + assert!(opened, "the new-tab button took no click"); +} + +#[test] +fn a_system_title_bar_leaves_the_window_alone() { + let mut u = Ui::new(TitleBar::Native); + assert!(!u.click(window_button(2)).contains(&ViewportCommand::Close)); + assert!(u.drag(EMPTY, vec2(40.0, 10.0)).is_empty()); + assert!(u.drag(pos2(SIZE.x - 2.0, SIZE.y - 2.0), vec2(-30.0, -30.0)).iter().all(|c| !matches!(c, ViewportCommand::BeginResize(_)))); +}