M1: custom window title bar (#59)
The tab strip is now the window's title bar instead of sitting under the system one. On Windows, Linux and FreeBSD the window is frameless: empty bar space drags it (double-click maximises or restores), minimise / maximise-restore / close buttons sit at the right end (close turns red on hover), and invisible strips along the edges and corners resize it (not while maximised). On macOS the system window buttons are inset into the strip. The browser build is unchanged. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
cf75c6e9ec
commit
20ca885084
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<present::Presenter>,
|
||||
/// 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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Event>) -> Vec<ViewportCommand> {
|
||||
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<ViewportCommand> {
|
||||
self.frame(vec![
|
||||
Event::PointerMoved(p),
|
||||
Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() },
|
||||
])
|
||||
}
|
||||
|
||||
fn click(&mut self, p: Pos2) -> Vec<ViewportCommand> {
|
||||
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<ViewportCommand> {
|
||||
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(_))));
|
||||
}
|
||||
Loading…
Reference in New Issue