5 Commits

Author SHA1 Message Date
Marcos Paulo ec7f1f8a14 feat(ui): refresh-rate frame cap, Task Manager-accurate RAM/CPU, menu toggle and themed close button
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 08:55:37 -03:00
Marcos Paulo 24f6c797f2 chore(release): 0.8.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 01:16:28 -03:00
Marcos Paulo a2885a3283 perf(ui): cache the Design tab's lint and feedback until the document changes
The tab ran the design lint over every node, and scanned every node for open
comments, on every frame. On a 12.6k-node page that cost hundreds of ms per
frame (377 ms worst) while the tab was open. Both results are now kept per
(doc_stamp, active page); the open-feedback count of other pages refreshes once
a second.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 01:14:34 -03:00
Marcos Paulo 0143f1e465 feat(perf): RAM and CPU on Windows; refresh rates up to 360 Hz
The performance overlay showed n/a for RAM and CPU on Windows: the readings
existed only for macOS and Linux. They now come from GetProcessMemoryInfo
(working set) and GetProcessTimes (kernel plus user time).

The "Warn below" chips become "Refresh rate" and now go up to 144, 165, 240
and 360, for high-refresh monitors. The default stays 60.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 01:14:34 -03:00
Marcos Paulo dc57f9af20 feat(ui): window buttons in the toolbar on Windows/Linux; controls and acrylic from craft-ui
- Drop the separate title bar: minimize/maximize/close sit at the toolbar's right,
  its empty middle drags the window (double-click maximizes). Home gets the same.
- Linux is undecorated too, with edge resize.
- Settings pages fill the modal height and scroll (Appearance rendered empty).
- Checkbox, toggle, select and the acrylic material now come from craft-ui.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:44:02 -03:00
11 changed files with 560 additions and 517 deletions
Generated
+3 -2
View File
@@ -784,7 +784,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://git.marcospaulo.dev.br/masi/craft-libs?rev=22544b6d619d4f16779b8bdfa3f0b642a86fc065#22544b6d619d4f16779b8bdfa3f0b642a86fc065"
source = "git+https://git.marcospaulo.dev.br/masi/craft-libs?rev=9c470aa935c4ce48f2ba4f0a17f9410c0c8660fb#9c470aa935c4ce48f2ba4f0a17f9410c0c8660fb"
dependencies = [
"egui",
]
@@ -3128,7 +3128,7 @@ dependencies = [
[[package]]
name = "papercraft"
version = "0.8.0"
version = "0.8.2"
dependencies = [
"arboard",
"axum",
@@ -3175,6 +3175,7 @@ dependencies = [
"web-sys",
"web-time",
"wgpu",
"windows-sys 0.61.2",
"winit",
"winresource",
"zstd",
+6 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "papercraft"
version = "0.8.0"
version = "0.8.2"
edition = "2024"
rust-version = "1.95"
description = "A native infinite-canvas app for planning, working and sketching"
@@ -44,7 +44,7 @@ env_logger = "0.11"
rfd = "0.17"
fastrand = "2"
dirs = "6"
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "22544b6d619d4f16779b8bdfa3f0b642a86fc065" }
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "9c470aa935c4ce48f2ba4f0a17f9410c0c8660fb" }
i_overlay = "9.0.1"
miniz_oxide = "0.9.1"
usvg = "0.48"
@@ -101,6 +101,10 @@ libc = "0.2"
zstd = "0.13"
# Browser build (`src/web.rs`): canvas + WebGPU/WebGL surface, DOM events instead of winit.
# Process RAM and CPU readings for the performance overlay (`ui::perf`).
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_ProcessStatus", "Win32_System_Threading"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wgpu = { version = "30", features = ["webgl"] }
wasm-bindgen = "0.2"
+12
View File
@@ -76,6 +76,18 @@ are credited in `THIRD_PARTY.md`.
* **Not carried over**: comments, design tokens, AI image generation, video export, teams;
moving layers between pages; remote image URLs (see README Known gaps).
### 2026-10-28: 0.8.2 (refresh-rate cap, accurate RAM/CPU, toolbar)
* Settings > Performance > Refresh rate is now a real frame cap applied live (30 to 360 Hz, 60 by default); **Auto** draws as fast as the display allows and is the default on macOS.
* Performance Monitor: RAM is the private working set and CPU is divided by logical cores on Windows, so both match Task Manager.
* Windows and Linux toolbar: a hamburger button shows or hides the File / Edit strip (remembered); the close button uses the theme's danger colour on the acrylic surface, with hover animation and a pointing-hand cursor.
### 2026-10-28: 0.8.1 (Performance Monitor on Windows, Design tab speed)
* Performance Monitor: RAM (working set) and CPU now show on Windows; before they were `n/a` there. The overlay and slow-frame badge judge frames against a refresh rate you pick in Settings > Performance: 30 to 360 Hz (144, 165 and 240 included), 60 by default.
* Design tab: the lint and the open-feedback scan are kept until the document changes instead of walking every node each frame; a 12.6k-node page no longer drops to a few FPS while the tab is open.
* Windows and Linux: window buttons in the toolbar; controls and acrylic come from craft-ui.
### 2026-10-27: 0.8.0 (screens, packed pages, canvas performance)
* Screens (`specs/screens`): a frame can be declared a screen or an overlay (drawer, modal, sheet) with `set_screens`, inspector toggles, or Declare N screens on the page. Agent edits to layers hidden behind an overlay or opaque layer in their screen are refused unless `allowHidden`; partly covered ones are reported with their visible share. `get_basic_info` lists screens, `get_node_info` tells a layer's visibility, `lint_design` skips hidden layers, On click lists screens, nested screens get canvas labels.
+59 -1
View File
@@ -60,6 +60,9 @@ const AGENT_FRAME: Duration = Duration::from_millis(50);
pub struct App {
gfx: Option<Gfx>,
/// The undecorated window edge under the pointer (Linux), which a press resizes.
#[cfg(all(unix, not(target_os = "macos")))]
resize_edge: Option<winit::window::ResizeDirection>,
pub ed: Editor,
session: Session,
st: UiState,
@@ -119,6 +122,8 @@ impl App {
let library = crate::library::Library::load(&crate::library::Library::path_in(&store.root));
let mut app = App {
gfx: None,
#[cfg(all(unix, not(target_os = "macos")))]
resize_edge: None,
ed: Editor::default(),
session: Session::new(store),
st: UiState::default(),
@@ -138,6 +143,7 @@ impl App {
};
app.st.library.library = library;
app.st.perf.prefs = crate::ui::perf::PerfPrefs::load();
app.st.menu_bar_hidden = crate::ui::menu_toggle::load_hidden();
app.ed.clipboard_io = crate::io::clipboard::System::new()
.map(|c| Box::new(c) as Box<dyn crate::io::clipboard::Provider>);
match start {
@@ -1038,8 +1044,12 @@ impl ApplicationHandler<UserEvent> for App {
.with_corner_preference(CornerPreference::Round)
.with_taskbar_icon(icon)
};
// Linux: no native frame either; the toolbar carries the window buttons and the window
// edges resize (`edge_resize`).
#[cfg(all(unix, not(target_os = "macos")))]
let attrs = attrs.with_decorations(false);
let window = Arc::new(event_loop.create_window(attrs).expect("create window"));
self.st.custom_titlebar = cfg!(windows);
self.st.custom_titlebar = cfg!(any(windows, all(unix, not(target_os = "macos"))));
window.set_ime_allowed(true);
#[cfg(target_os = "macos")]
{
@@ -1119,6 +1129,10 @@ impl ApplicationHandler<UserEvent> for App {
log::trace!("event {event:?} -> egui repaint {}", resp.repaint);
}
let scale = g.window.scale_factor();
#[cfg(all(unix, not(target_os = "macos")))]
if self.st.custom_titlebar && edge_resize(&g.window, &event, &mut self.resize_edge) {
return;
}
let mut redraw = resp.repaint;
let now = self.now();
match event {
@@ -1155,6 +1169,14 @@ impl ApplicationHandler<UserEvent> for App {
// egui-winit reports `repaint: true` for RedrawRequested itself; honouring it
// re-requested a redraw every frame (a 100% CPU busy loop). Whether another
// frame is needed is decided inside redraw() from egui's repaint delay.
// The frame-rate cap (Settings > Performance) holds a frame that comes too soon
// until its turn; every redraw request ends up here, so one check covers them all.
let since_last = self.last_redraw.map_or(Duration::MAX, |t| t.elapsed());
if let Some(wait) = ui::perf::cap_wait(self.st.perf.prefs.frame_interval(), since_last) {
let due = Instant::now() + wait;
self.next_repaint = Some(self.next_repaint.map_or(due, |r| r.min(due)));
return;
}
self.redraw();
return;
}
@@ -1743,3 +1765,39 @@ mod tests {
));
}
}
/// An undecorated window has no resize border (Linux): the pointer within a few pixels of an
/// edge shows a resize cursor and a press resizes. True when the event was consumed.
#[cfg(all(unix, not(target_os = "macos")))]
fn edge_resize(window: &Window, event: &WindowEvent, edge: &mut Option<winit::window::ResizeDirection>) -> bool {
use winit::window::{CursorIcon, ResizeDirection as D};
const BORDER: f64 = 6.0;
match event {
WindowEvent::CursorMoved { position, .. } => {
let size = window.inner_size();
let b = BORDER * window.scale_factor();
let (l, r) = (position.x < b, position.x > f64::from(size.width) - b);
let (t, bo) = (position.y < b, position.y > f64::from(size.height) - b);
let next = match (l, r, t, bo) {
(true, _, true, _) => Some(D::NorthWest),
(_, true, true, _) => Some(D::NorthEast),
(true, _, _, true) => Some(D::SouthWest),
(_, true, _, true) => Some(D::SouthEast),
(true, ..) => Some(D::West),
(_, true, ..) => Some(D::East),
(_, _, true, _) => Some(D::North),
(.., true) => Some(D::South),
_ => None,
};
if next != *edge {
window.set_cursor(next.map_or(CursorIcon::Default, CursorIcon::from));
*edge = next;
}
false
}
WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => {
edge.is_some_and(|d| window.drag_resize_window(d).is_ok())
}
_ => false,
}
}
+22 -199
View File
@@ -469,9 +469,9 @@ pub fn lit(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> egui::Response)
let r = add(ui);
let dark = ui.visuals().dark_mode;
let radius = f32::from(ui.visuals().widgets.inactive.corner_radius.nw);
let (top, bottom) = lit_fill(pal(ui).field, dark);
let (top, bottom) = craft_ui::material::lit(pal(ui).field, dark);
ui.painter()
.set(slot, vertical_gradient(r.rect, radius, top, bottom));
.set(slot, craft_ui::material::vertical_gradient(r.rect, radius, top, bottom));
r
}
@@ -592,10 +592,7 @@ fn style_for(s: &mut egui::Style, dark: bool, p: Palette) {
/// `over` composited on `base`.
pub fn blend(base: Color32, over: Color32) -> Color32 {
let a = over.a() as f32 / 255.0;
let [or, og, ob, _] = over.to_srgba_unmultiplied();
let mix = |b: u8, o: u8| (b as f32 * (1.0 - a) + o as f32 * a).round() as u8;
Color32::from_rgb(mix(base.r(), or), mix(base.g(), og), mix(base.b(), ob))
craft_ui::material::blend(base, over)
}
/// The appearance setting: follow the system, force light or dark, or a Catppuccin flavour.
@@ -924,110 +921,31 @@ pub fn backdrop_fill(p: Palette) -> Color32 {
}
}
/// The shared material for the chosen [`Material`] (`craft_ui::material` paints it).
fn shared() -> craft_ui::material::Material {
if is_acrylic() {
craft_ui::material::Material::Acrylic
} else {
craft_ui::material::Material::Solid
}
}
/// The acrylic backdrop at `pos`: the backdrop colour lit by a soft accent glow that falls off
/// from the top of the window, a third of the way across.
pub fn backdrop_at(p: Palette, screen: Rect, pos: Pos2) -> Color32 {
const GLOW_ALPHA: f32 = 46.0;
const GLOW_REACH: f32 = 0.8;
let centre = pos2(screen.left() + screen.width() * 0.3, screen.top());
let reach = screen.width().max(screen.height()) * GLOW_REACH;
let d = ((pos - centre).length() / reach).min(1.0);
let a = accent();
let glow = Color32::from_rgba_unmultiplied(
a.r(),
a.g(),
a.b(),
(GLOW_ALPHA * (1.0 - d) * (1.0 - d)) as u8,
);
blend(p.backdrop, glow)
craft_ui::material::backdrop_at(p.backdrop, accent(), screen, pos)
}
/// `rect` filled with the acrylic backdrop: a grid of vertex colours, so the glow is smooth.
pub fn backdrop_mesh(p: Palette, screen: Rect, rect: Rect) -> egui::Mesh {
const CELLS: u32 = 12;
let mut mesh = egui::Mesh::default();
for j in 0..=CELLS {
for i in 0..=CELLS {
let t = vec2(i as f32, j as f32) / CELLS as f32;
let pos = rect.min + rect.size() * t;
mesh.colored_vertex(pos, backdrop_at(p, screen, pos));
}
}
let row = CELLS + 1;
for j in 0..CELLS {
for i in 0..CELLS {
let k = j * row + i;
mesh.add_triangle(k, k + 1, k + row);
mesh.add_triangle(k + 1, k + row + 1, k + row);
}
}
mesh
}
/// A rounded rectangle filled with a vertical gradient. A fan from the centre interpolates a
/// colour that is linear in y exactly; the caller's edge stroke covers the unsmoothed outline.
fn vertical_gradient(rect: Rect, radius: f32, top: Color32, bottom: Color32) -> egui::Mesh {
let mut outline = Vec::new();
egui::epaint::tessellator::path::rounded_rectangle(&mut outline, rect, radius.into());
let at = |y: f32| top.lerp_to_gamma(bottom, ((y - rect.top()) / rect.height()).clamp(0.0, 1.0));
let mut mesh = egui::Mesh::default();
mesh.colored_vertex(rect.center(), at(rect.center().y));
for q in &outline {
mesh.colored_vertex(*q, at(q.y));
}
let n = outline.len() as u32;
for i in 0..n {
mesh.add_triangle(0, 1 + i, 1 + (i + 1) % n);
}
mesh
}
fn paint_vertical_gradient(
painter: &egui::Painter,
rect: Rect,
radius: f32,
top: Color32,
bottom: Color32,
) {
painter.add(vertical_gradient(rect, radius, top, bottom));
}
/// The fill of an acrylic surface lit from above: `fill` lightened at the top, shaded at the bottom.
fn lit_fill(fill: Color32, dark: bool) -> (Color32, Color32) {
// A translucent tint (the inspector's fields): brighter at the top, fainter at the bottom.
if fill.a() < 255 {
let [r, g, b, a] = fill.to_srgba_unmultiplied();
let with = |k: f32| Color32::from_rgba_unmultiplied(r, g, b, (f32::from(a) * k).min(255.0) as u8);
return (with(1.6), with(0.8));
}
if dark {
(
blend(fill, Color32::from_white_alpha(26)),
blend(fill, Color32::from_black_alpha(22)),
)
} else {
(
blend(fill, Color32::from_white_alpha(140)),
blend(fill, Color32::from_black_alpha(4)),
)
}
craft_ui::material::backdrop_mesh(p.backdrop, accent(), screen, rect)
}
/// Under acrylic, lights a modal's own frame (`fill`, `radius`): call first thing in the
/// modal's content. Paints over the frame's flat fill at the window's rect from the last frame,
/// so nothing has to run after the content.
pub fn modal_backing(ui: &egui::Ui, fill: Color32, radius: u8) {
if !is_acrylic() {
return;
}
let Some(rect) = ui.ctx().memory(|m| m.area_rect(ui.layer_id().id)) else {
return;
};
let dark = ui.visuals().dark_mode;
let painter = ui.painter().with_clip_rect(rect);
let (top, bottom) = lit_fill(fill, dark);
paint_vertical_gradient(&painter, rect, f32::from(radius), top, bottom);
painter.rect_stroke(rect, radius, acrylic_edge(dark), egui::StrokeKind::Inside);
craft_ui::material::paint_backing(ui, shared(), fill, radius);
}
/// [`modal_backing`] for a popup, menu or context menu: called first in its closure, it lights
@@ -1049,61 +967,19 @@ pub fn well_fill(p: Palette, dark: bool) -> Color32 {
/// A control's surface (field, chip, select, secondary button, segmented track): lit from above
/// with a fine edge under acrylic, flat when solid.
pub fn fill_control(ui: &egui::Ui, rect: Rect, radius: u8, fill: Color32) {
if !is_acrylic() {
ui.painter().rect_filled(rect, radius, fill);
return;
}
let dark = ui.visuals().dark_mode;
let (top, bottom) = lit_fill(fill, dark);
paint_vertical_gradient(ui.painter(), rect, f32::from(radius), top, bottom);
ui.painter()
.rect_stroke(rect, radius, acrylic_edge(dark), egui::StrokeKind::Inside);
craft_ui::material::paint_control(ui.painter(), shared(), rect, radius, fill, ui.visuals().dark_mode);
}
/// A selected row or item (nav, menu option, layer): an accent wash with an accent edge under
/// acrylic, `fill` when solid.
pub fn fill_selected(ui: &egui::Ui, rect: Rect, radius: u8, fill: Color32) {
if !is_acrylic() {
ui.painter().rect_filled(rect, radius, fill);
return;
}
let a = accent();
let wash = |alpha| Color32::from_rgba_unmultiplied(a.r(), a.g(), a.b(), alpha);
paint_vertical_gradient(
ui.painter(),
rect,
f32::from(radius),
blend(fill, wash(56)),
blend(fill, wash(20)),
);
ui.painter().rect_stroke(
rect,
radius,
Stroke::new(1.0, wash(80)),
egui::StrokeKind::Inside,
);
craft_ui::material::paint_selected(ui.painter(), shared(), rect, radius, fill, accent());
}
/// An accent surface that is not the primary button (toggle on, active segment, checked box):
/// glossy with a light edge under acrylic, no glow, flat when solid.
pub fn fill_accent(ui: &egui::Ui, rect: Rect, radius: u8, fill: Color32) {
if !is_acrylic() {
ui.painter().rect_filled(rect, radius, fill);
return;
}
paint_vertical_gradient(
ui.painter(),
rect,
f32::from(radius),
blend(fill, Color32::from_white_alpha(70)),
blend(fill, Color32::from_black_alpha(40)),
);
ui.painter().rect_stroke(
rect,
radius,
Stroke::new(1.0, Color32::from_white_alpha(90)),
egui::StrokeKind::Inside,
);
craft_ui::material::paint_accent(ui.painter(), shared(), rect, radius, fill);
}
/// `Frame::show` that, under acrylic, paints the frame's fill lit from above with a fine edge.
@@ -1122,25 +998,7 @@ impl Surface for egui::Frame {
ui: &mut egui::Ui,
add: impl FnOnce(&mut egui::Ui) -> R,
) -> egui::InnerResponse<R> {
if !is_acrylic() || self.fill.a() == 0 {
return self.show(ui, add);
}
let dark = ui.visuals().dark_mode;
let (fill, radius) = (self.fill, f32::from(self.corner_radius.nw));
let slot = ui.painter().add(egui::Shape::Noop);
let edge = if self.stroke.width > 0.0 {
self.stroke
} else {
acrylic_edge(dark)
};
let r = self
.fill(Color32::TRANSPARENT)
.stroke(edge)
.show(ui, add);
let (top, bottom) = lit_fill(fill, dark);
ui.painter()
.set(slot, vertical_gradient(r.response.rect, radius, top, bottom));
r
craft_ui::material::show_surface(self, ui, shared(), add)
}
}
@@ -1153,52 +1011,17 @@ pub fn paint_acrylic_panel(
fill: Color32,
dark: bool,
) {
let shadow = Shadow {
offset: [0, 4],
blur: 12,
spread: 0,
color: Color32::from_black_alpha(if dark { 80 } else { 24 }),
};
painter.add(shadow.as_shape(rect, CornerRadius::same(radius)));
let (top, bottom) = lit_fill(fill, dark);
paint_vertical_gradient(painter, rect, f32::from(radius), top, bottom);
painter.rect_stroke(rect, radius, acrylic_edge(dark), egui::StrokeKind::Inside);
craft_ui::material::paint_panel(painter, rect, radius, fill, dark);
}
/// The fine edge of acrylic surfaces.
pub fn acrylic_edge(dark: bool) -> Stroke {
Stroke::new(
1.0,
if dark {
Color32::from_white_alpha(22)
} else {
Color32::from_black_alpha(22)
},
)
craft_ui::material::edge(dark)
}
/// The acrylic primary button: a soft accent glow, a glossy accent gradient and a light edge.
pub fn paint_gloss(painter: &egui::Painter, rect: Rect, radius: u8, fill: Color32) {
let glow = Shadow {
offset: [0, 0],
blur: 16,
spread: 0,
color: Color32::from_rgba_unmultiplied(fill.r(), fill.g(), fill.b(), 70),
};
painter.add(glow.as_shape(rect, CornerRadius::same(radius)));
paint_vertical_gradient(
painter,
rect,
f32::from(radius),
blend(fill, Color32::from_white_alpha(70)),
blend(fill, Color32::from_black_alpha(40)),
);
painter.rect_stroke(
rect,
radius,
Stroke::new(1.0, Color32::from_white_alpha(90)),
egui::StrokeKind::Inside,
);
craft_ui::material::paint_gloss(painter, rect, radius, fill);
}
/// Fonts plus the light and dark styles. egui picks the one matching the system appearance
+104 -33
View File
@@ -3,10 +3,65 @@
use super::UiState;
use crate::editor::Editor;
use crate::mcp::lint::Issue;
use crate::model::NodeId;
use crate::theme;
use egui::{RichText, Ui};
/// Issues listed in the tab; the rest are only counted.
const MAX_ISSUES_SHOWN: usize = 30;
/// How often the open-feedback count of the other pages is refreshed, in seconds.
const ELSEWHERE_REFRESH_SECS: f64 = 1.0;
/// What the tab derives from the page. The lint and the comment scan visit every node, far too
/// much to redo each frame, so they are kept until the document changes.
#[derive(Default)]
pub struct Cache {
key: Option<(u64, usize)>,
issues: Vec<Issue>,
open: Vec<(NodeId, String, String)>,
elsewhere: Option<(f64, usize)>,
}
impl Cache {
/// Bring the cache up to date at time `now` (seconds). True when the page was walked again.
fn refresh(&mut self, ed: &Editor, now: f64) -> bool {
let key = (ed.doc_stamp, ed.active_page);
let walked = self.key != Some(key);
if walked {
self.issues = crate::mcp::lint::lint(&ed.doc, &ed.doc.root, &crate::mcp::lint::DEFAULT_SCALE);
let mut open: Vec<(NodeId, String, String)> = ed
.doc
.nodes
.values()
.filter(|n| n.open_comments() > 0)
.flat_map(|n| {
n.comments
.iter()
.filter(|c| !c.resolved)
.map(|c| (n.id, n.name.clone(), c.text.clone()))
.collect::<Vec<_>>()
})
.collect();
open.sort_by_key(|o| o.0);
self.open = open;
self.key = Some(key);
}
if self.elsewhere.is_none_or(|(t, _)| now - t >= ELSEWHERE_REFRESH_SECS) {
let count: usize = (0..ed.pages.len())
.filter(|i| *i != ed.active_page)
.map(|i| ed.page_doc(i).nodes.values().map(|n| n.open_comments()).sum::<usize>())
.sum();
self.elsewhere = Some((now, count));
}
walked
}
fn elsewhere(&self) -> usize {
self.elsewhere.map_or(0, |(_, n)| n)
}
}
pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
let p = theme::pal(ui);
let heading = |ui: &mut Ui, t: &str| {
@@ -24,30 +79,10 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
.show(ui, |ui| {
crate::theme::figma_fields(ui);
// ---- open feedback on this page (and a pointer to other pages)
let mut open: Vec<(NodeId, String, String)> = ed
.doc
.nodes
.values()
.filter(|n| n.open_comments() > 0)
.flat_map(|n| {
n.comments
.iter()
.filter(|c| !c.resolved)
.map(|c| (n.id, n.name.clone(), c.text.clone()))
.collect::<Vec<_>>()
})
.collect();
open.sort_by_key(|o| o.0);
let elsewhere: usize = (0..ed.pages.len())
.filter(|i| *i != ed.active_page)
.map(|i| {
ed.page_doc(i)
.nodes
.values()
.map(|n| n.open_comments())
.sum::<usize>()
})
.sum();
let now = ui.input(|i| i.time);
st.design_cache.refresh(ed, now);
let open = st.design_cache.open.clone();
let elsewhere = st.design_cache.elsewhere();
if !open.is_empty() || elsewhere > 0 {
heading(ui, &format!("Feedback · {} open", open.len() + elsewhere));
for (id, name, note) in open {
@@ -103,19 +138,16 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
st.variables.open = true;
}
// ---- design lint
let issues = crate::mcp::lint::lint(
&ed.doc,
&ed.doc.root,
&crate::mcp::lint::DEFAULT_SCALE,
);
let total = st.design_cache.issues.len();
let shown: Vec<Issue> = st.design_cache.issues.iter().take(MAX_ISSUES_SHOWN).cloned().collect();
ui.horizontal(|ui| {
let title = if issues.is_empty() {
let title = if total == 0 {
"Lint · Clean".to_string()
} else {
format!("Lint · {} issues", issues.len())
format!("Lint · {total} issues")
};
heading(ui, &title);
if !issues.is_empty() {
if total > 0 {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Zap), "Fix all")
.on_hover_text("Auto-fix grid, color variables, text scale, and touch targets")
@@ -133,7 +165,7 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
});
}
});
for issue in issues.iter().take(30) {
for issue in &shown {
let node_name = ed
.doc
.get(issue.node)
@@ -187,3 +219,42 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geom::Rect;
use crate::model::{Node, NodeKind};
fn add_rect(ed: &mut Editor) {
ed.edit("Add", |tx| {
let mut n = Node::new(NodeId(0), NodeKind::Rect, Rect::new(0.0, 0.0, 10.0, 10.0));
n.id = tx.alloc_id();
tx.insert(n, None, None);
});
}
#[test]
fn walks_the_page_only_when_the_document_changes() {
let mut ed = Editor::default();
let mut c = Cache::default();
assert!(c.refresh(&ed, 0.0), "first frame walks");
assert!(!c.refresh(&ed, 0.1), "an unchanged page is not walked again");
assert!(!c.refresh(&ed, 5.0), "time alone does not walk it");
add_rect(&mut ed);
assert!(c.refresh(&ed, 5.1), "an edit walks it once");
assert!(!c.refresh(&ed, 5.2));
}
#[test]
fn other_pages_are_recounted_on_a_timer_not_a_change() {
let ed = Editor::default();
let mut c = Cache::default();
c.refresh(&ed, 0.0);
let first = c.elsewhere.map(|e| e.0);
c.refresh(&ed, ELSEWHERE_REFRESH_SECS / 2.0);
assert_eq!(c.elsewhere.map(|e| e.0), first, "too soon to recount");
c.refresh(&ed, ELSEWHERE_REFRESH_SECS);
assert_ne!(c.elsewhere.map(|e| e.0), first, "recounted after the interval");
}
}
+29 -191
View File
@@ -394,30 +394,7 @@ pub fn close_button(ui: &mut Ui) -> Response {
/// A pill toggle switch.
pub fn toggle(ui: &mut Ui, on: &mut bool) -> Response {
let p = theme::pal(ui);
let (rect, mut resp) = ui.allocate_exact_size(vec2(42.0, 24.0), Sense::click());
if resp.clicked() {
*on = !*on;
resp.mark_changed();
}
let t = crate::ui::motion::progress(
ui.ctx(),
resp.id.with("toggle"),
*on,
crate::ui::motion::FAST,
crate::ui::motion::ease_out_cubic,
);
let fill = theme::blend(p.field, theme::accent().gamma_multiply(t));
if t > 0.5 {
theme::fill_accent(ui, rect, PILL, fill);
} else {
theme::fill_control(ui, rect, PILL, fill);
}
let knob_x = egui::lerp((rect.left() + 12.0)..=(rect.right() - 12.0), t);
let knob = if t > 0.5 { on_accent(p) } else { p.secondary };
ui.painter()
.circle_filled(egui::pos2(knob_x, rect.center().y), 8.0, knob);
resp
craft_ui::controls::toggle(ui, &control_style(ui), on)
}
/// A sidebar-style navigation row: icon, label, optional selected state. Returns whether it was clicked.
@@ -482,52 +459,29 @@ pub fn lottie_button(ui: &mut Ui, name: &str, tip: &str, size: f32) -> Response
/// A rounded checkbox with a label.
pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response {
craft_ui::controls::checkbox(ui, &control_style(ui), on, label)
}
/// The shared controls' style from the theme (`craft_ui::controls`).
pub fn control_style(ui: &Ui) -> craft_ui::controls::ControlStyle {
let p = theme::pal(ui);
let font = FontId::proportional(theme::BODY);
let w = 26.0 + galley_width(ui, label, font.clone()) + 4.0;
let (rect, mut resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click());
if resp.clicked() {
*on = !*on;
resp.mark_changed();
craft_ui::controls::ControlStyle {
material: if theme::is_acrylic() { craft_ui::material::Material::Acrylic } else { craft_ui::material::Material::Solid },
field: p.field,
hover: p.hover,
text: p.text,
secondary: p.secondary,
accent: theme::accent(),
on_accent: on_accent(p),
selected: p.field,
radius: theme::radius_md(),
box_radius: theme::ru(6),
field_height: theme::FIELD_H,
font: FontId::proportional(theme::BODY),
font_strong: theme::semibold(theme::BODY),
menu_max: vec2(320.0, 420.0),
animation_time: crate::ui::motion::FAST,
}
let t = crate::ui::motion::progress(
ui.ctx(),
resp.id.with("check"),
*on,
crate::ui::motion::FAST,
crate::ui::motion::ease_out_cubic,
);
let hover = crate::ui::motion::hover(ui.ctx(), resp.id.with("check-hover"), &resp);
let bx = Rect::from_center_size(
egui::pos2(rect.left() + 9.0, rect.center().y),
vec2(18.0, 18.0),
);
let fill = theme::blend(
theme::blend(p.field, p.hover.gamma_multiply(hover)),
theme::accent().gamma_multiply(t),
);
if t > 0.5 {
theme::fill_accent(ui, bx, crate::theme::ru(6), fill);
} else {
theme::fill_control(ui, bx, crate::theme::ru(6), fill);
}
if t > 0.0 {
let c = bx.center();
let ink = on_accent(p).gamma_multiply(t);
let stroke = Stroke::new(2.0, ink);
ui.painter()
.line_segment([c + vec2(-4.0, 0.5), c + vec2(-1.0, 3.5)], stroke);
ui.painter()
.line_segment([c + vec2(-1.0, 3.5), c + vec2(4.5, -3.0)], stroke);
}
ui.painter().text(
egui::pos2(rect.left() + 26.0, rect.center().y),
Align2::LEFT_CENTER,
label,
font,
p.text,
);
resp
}
/// A rounded dropdown: the current value in a field, a rounded menu of options below it.
@@ -538,7 +492,7 @@ pub fn select<T: PartialEq + Copy>(
options: &[(T, &str)],
width: f32,
) -> Option<T> {
select_icons(ui, current, options, &[], width)
craft_ui::controls::select(ui, &control_style(ui), current, options, width)
}
/// [`select`] with `icons[i]` before option `i`, in the trigger and the menu.
@@ -549,129 +503,13 @@ pub fn select_icons<T: PartialEq + Copy>(
icons: &[Icon],
width: f32,
) -> Option<T> {
let p = theme::pal(ui);
let icon_of = |i: usize| icons.get(i).copied();
let indent = if icons.is_empty() { 0.0 } else { 22.0 };
let cur_i = options.iter().position(|(o, _)| *o == current);
let label = options
.iter()
.find(|(o, _)| *o == current)
.map_or("—", |(_, l)| *l);
let w = if width > 0.0 {
width
} else {
ui.available_width()
let paint = |painter: &egui::Painter, r: Rect, i: usize, ink: Color32| {
if let Some(icon) = icons.get(i) {
theme::paint_icon(painter, r, *icon, ink);
}
};
let (rect, resp) = ui.allocate_exact_size(vec2(w, theme::FIELD_H), Sense::click());
let hover = crate::ui::motion::hover(ui.ctx(), resp.id.with("select"), &resp);
theme::fill_control(
ui,
rect,
theme::radius_md(),
theme::blend(p.field, p.hover.gamma_multiply(hover)),
);
if let Some(i) = cur_i.and_then(icon_of) {
let r = Rect::from_center_size(egui::pos2(rect.left() + 19.0, rect.center().y), vec2(14.0, 14.0));
theme::paint_icon(ui.painter(), r, i, p.secondary);
}
let g = option_label(ui, label, FontId::proportional(theme::BODY), p.text, rect.width() - 12.0 - indent - 28.0);
ui.painter().galley(egui::pos2(rect.left() + 12.0 + indent, rect.center().y - g.size().y / 2.0), g, p.text);
let ch = Rect::from_center_size(
egui::pos2(rect.right() - 14.0, rect.center().y),
vec2(14.0, 14.0),
);
theme::paint_icon(ui.painter(), ch, Icon::ChevronDown, p.secondary);
let mut chosen = None;
// The menu grows to fit its longest option, up to a cap; longer ones are shortened.
let min_w = rect.width().max(150.0);
let menu_w = if egui::Popup::is_id_open(ui.ctx(), egui::Popup::default_response_id(&resp)) {
let font = theme::semibold(theme::BODY);
let widest = options
.iter()
.map(|(_, l)| ui.painter().layout_no_wrap((*l).to_owned(), font.clone(), p.text).size().x)
.fold(0.0, f32::max);
(widest + 12.0 + indent + 40.0).clamp(min_w, SELECT_MENU_MAX_W)
} else {
min_w
};
egui::Popup::menu(&resp)
.width(menu_w)
.show(|ui| {
crate::theme::popup_backing(ui);
ui.spacing_mut().item_spacing.y = 2.0;
egui::ScrollArea::vertical().max_height(SELECT_MENU_MAX_H).show(ui, |ui| {
for (k, (o, l)) in options.iter().enumerate() {
let (row, r) =
ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
let on = *o == current;
let h = crate::ui::motion::hover(ui.ctx(), r.id.with("opt"), &r);
if on {
theme::fill_selected(ui, row, theme::radius_md(), p.field);
} else if h > 0.0 {
ui.painter()
.rect_filled(row, theme::radius_md(), p.hover.gamma_multiply(h));
}
if let Some(i) = icon_of(k) {
let ir = Rect::from_center_size(egui::pos2(row.left() + 19.0, row.center().y), vec2(14.0, 14.0));
theme::paint_icon(ui.painter(), ir, i, if on { p.text } else { p.secondary });
}
let font = if on { theme::semibold(theme::BODY) } else { FontId::proportional(theme::BODY) };
let g = option_label(ui, l, font, p.text, row.width() - 12.0 - indent - 32.0);
let cut = g.text() != *l;
ui.painter().galley(egui::pos2(row.left() + 12.0 + indent, row.center().y - g.size().y / 2.0), g, p.text);
let r = if cut { r.on_hover_text(*l) } else { r };
if on {
let c = egui::pos2(row.right() - 16.0, row.center().y);
let s = Stroke::new(2.0, theme::accent());
ui.painter()
.line_segment([c + vec2(-4.0, 0.5), c + vec2(-1.0, 3.5)], s);
ui.painter()
.line_segment([c + vec2(-1.0, 3.5), c + vec2(4.5, -3.0)], s);
}
if r.clicked() {
chosen = Some(*o);
ui.close();
}
}
});
});
chosen.filter(|c| *c != current)
}
const SELECT_MENU_MAX_W: f32 = 320.0;
const SELECT_MENU_MAX_H: f32 = 420.0;
/// One line of `text` in `max_w`. A `Group / Sub / Name` path keeps its last parts and drops
/// leading ones (`… / Name`), since the end tells options apart; the path before the name is
/// dimmed. Anything still too long ends in "…".
fn option_label(ui: &Ui, text: &str, font: FontId, ink: Color32, max_w: f32) -> std::sync::Arc<egui::Galley> {
let dim = theme::pal(ui).secondary;
let parts: Vec<&str> = text.split(" / ").collect();
let job = |skip: usize, cut: bool| {
let mut job = egui::text::LayoutJob::default();
let (prefix, name) = parts.split_at(parts.len() - 1);
let mut head = prefix[skip..].join(" / ");
if skip > 0 {
head = if head.is_empty() { "\u{2026}".into() } else { format!("\u{2026} / {head}") };
}
if !head.is_empty() {
job.append(&format!("{head} / "), 0.0, egui::TextFormat::simple(font.clone(), dim));
}
job.append(name[0], 0.0, egui::TextFormat::simple(font.clone(), ink));
if cut {
job.wrap.max_width = max_w;
job.wrap.max_rows = 1;
job.wrap.overflow_character = Some('\u{2026}');
}
job
};
for skip in 0..parts.len() {
let g = ui.painter().layout_job(job(skip, false));
if g.size().x <= max_w {
return g;
}
}
ui.painter().layout_job(job(parts.len() - 1, true))
let icon: Option<craft_ui::controls::PaintOptionIcon<'_>> = (!icons.is_empty()).then_some(&paint);
craft_ui::controls::select_with_icons(ui, &control_style(ui), current, options, width, icon)
}
/// A rounded slider: rail, accent fill, round knob. Returns whether the value changed.
+65
View File
@@ -0,0 +1,65 @@
//! The toolbar's menu button: shows or hides the File / Edit / ... strip on platforms without a
//! native menu bar (macOS has its own). The choice is kept across runs.
use crate::theme;
use egui::{Response, Sense, Stroke, Ui, Vec2};
/// Matches the "All projects" button next to it.
const SIZE: f32 = 26.0;
/// Half the length of a bar, and the gap from the middle bar to the outer ones.
const BAR_HALF: f32 = 6.0;
const BAR_GAP: f32 = 4.0;
const BAR_STROKE: f32 = 1.25;
const HIDDEN: &str = "hidden";
fn path() -> Option<std::path::PathBuf> {
crate::project::config_dir().map(|d| d.join("menu_bar"))
}
/// `"hidden"` hides the strip; no file, or anything else, shows it.
fn parse_hidden(s: &str) -> bool {
s.trim() == HIDDEN
}
pub fn load_hidden() -> bool {
path().and_then(|p| crate::vfs::read_to_string(p).ok()).is_some_and(|s| parse_hidden(&s))
}
pub fn save_hidden(hidden: bool) {
let Some(p) = path() else { return };
if let Some(dir) = p.parent() {
let _ = crate::vfs::create_dir_all(dir);
}
let _ = crate::vfs::write(p, if hidden { HIDDEN } else { "shown" });
}
/// The three-bar button. Clicking toggles the strip: the caller flips and saves the choice.
pub fn button(ui: &mut Ui, hidden: bool) -> Response {
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
if ui.is_rect_visible(rect) {
let p = theme::pal(ui);
let hover = crate::ui::motion::hover(ui.ctx(), resp.id.with("hover"), &resp);
let fill = if resp.is_pointer_button_down_on() { p.pressed } else { p.hover.gamma_multiply(hover) };
ui.painter().rect_filled(rect, theme::radius_md(), fill);
let stroke = Stroke::new(BAR_STROKE, p.text);
let c = rect.center();
for dy in [-BAR_GAP, 0.0, BAR_GAP] {
ui.painter().hline(c.x - BAR_HALF..=c.x + BAR_HALF, c.y + dy, stroke);
}
}
resp.on_hover_text(if hidden { "Show the menu bar" } else { "Hide the menu bar" })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_word_hidden_hides_the_strip() {
assert!(parse_hidden("hidden"));
assert!(parse_hidden("hidden\n"), "a trailing newline is fine");
assert!(!parse_hidden("shown"));
assert!(!parse_hidden(""), "no file yet: shown");
assert!(!parse_hidden("garbage"), "an unreadable choice shows it rather than losing the menus");
}
}
+94 -75
View File
@@ -17,6 +17,7 @@ pub mod props_modal;
pub mod kit;
pub mod library;
pub mod lottie_icon;
pub mod menu_toggle;
pub mod motion;
pub mod pages;
pub mod perf;
@@ -57,6 +58,8 @@ pub struct UiState {
/// The window has native chrome: a system menu bar (so no egui menu) and traffic lights
/// inset into the toolbar / sidebar.
pub native_chrome: bool,
/// The File / Edit / ... strip is folded away (the menu button in the toolbar toggles it).
pub menu_bar_hidden: bool,
/// Windows: native decorations are off and `title_bar` draws the menu, drag area and
/// window buttons.
pub custom_titlebar: bool,
@@ -182,6 +185,8 @@ pub struct UiState {
pub ann_ui: annotations::AnnotationsUi,
/// Frame times, RAM, CPU and GPU for Settings > Performance and its overlay.
pub perf: perf::Perf,
/// The Design tab's lint and feedback, kept until the document changes.
pub design_cache: design::Cache,
}
/// Where a dragged layer lands relative to a row.
@@ -893,13 +898,6 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
// The turn is over: the "Working" pill goes with it, not 6 s after its last tool call.
st.agent = None;
}
if st.custom_titlebar {
let p = theme::pal(ui);
egui::Panel::top("titlebar")
.exact_size(TITLEBAR_H)
.frame(egui::Frame::NONE.fill(p.window))
.show(ui, |ui| title_bar(ui, ed, st));
}
if let Some(a) = &mut st.agent_link {
a.window(ui.ctx());
}
@@ -913,6 +911,9 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
st.updater.open |= std::mem::take(&mut hv.show_updates);
st.updater.show(ui.ctx());
}
if st.custom_titlebar {
home_window_chrome(ui, st);
}
return egui::Rect::NOTHING;
}
for ev in std::mem::take(&mut ed.ui_events) {
@@ -1050,10 +1051,8 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
egui::Panel::top("toolbar")
.exact_size(if floating {
theme::TOOLBAR_H + gap * 2.0
} else if st.native_chrome {
theme::TOOLBAR_H
} else {
34.0
theme::TOOLBAR_H
})
.frame(if floating {
side_frame(0, 0, gap as i8, gap as i8)
@@ -1162,20 +1161,30 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
// In a narrow window only the essentials stay: zoom, present and share.
let compact = ui.max_rect().width() < 720.0;
let tight = ui.max_rect().width() < TIGHT_BAR_W;
if st.custom_titlebar {
drag_area(ui, ui.max_rect(), st);
}
ui.horizontal_centered(|ui| {
if !st.native_chrome && !st.custom_titlebar {
if ui.max_rect().width() < MENU_BAR_W {
// Not enough room for every menu: they fold into one.
ui.scope(|ui| {
flat_menus(ui);
let menu = ui.menu_button("Menu", |ui| {
if !st.native_chrome {
if menu_toggle::button(ui, st.menu_bar_hidden).clicked() {
st.menu_bar_hidden = !st.menu_bar_hidden;
menu_toggle::save_hidden(st.menu_bar_hidden);
}
ui.add_space(4.0);
if !st.menu_bar_hidden {
if ui.max_rect().width() < MENU_BAR_W {
// Not enough room for every menu: they fold into one.
ui.scope(|ui| {
flat_menus(ui);
let menu = ui.menu_button("Menu", |ui| {
crate::theme::popup_backing(ui);
flat_menu(ui, ed)
});
let _ = menu;
});
} else {
ui.scope(|ui| menu_bar(ui, ed, &mut st.agent_link));
let _ = menu;
});
} else {
ui.scope(|ui| menu_bar(ui, ed, &mut st.agent_link));
}
}
ui.add_space(6.0);
ui.separator();
@@ -1263,6 +1272,10 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
agent_pill(ui, st, !tight, right_w);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 2.0;
if st.custom_titlebar {
window_buttons(ui, st, theme::TOOLBAR_H);
ui.add_space(8.0);
}
let share = kit::button_compact(ui, kit::ButtonKind::Primary, Some(Icon::Export), "Share");
egui::Popup::menu(&share).show(|ui| {
crate::theme::popup_backing(ui);
@@ -1473,74 +1486,80 @@ fn theme_menu(ui: &mut Ui) {
});
}
const TITLEBAR_H: f32 = 32.0;
const WIN_BTN_W: f32 = 46.0;
/// Stroke of the minimize, maximize and close glyphs: the weight of the toolbar's icons.
const WIN_GLYPH_STROKE: f32 = 1.25;
/// Custom window chrome: menus on the left, empty space drags the window, buttons on the right.
fn title_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
let p = theme::pal(ui);
let rect = ui.max_rect();
let drag = ui.interact(rect, ui.id().with("drag"), egui::Sense::click_and_drag());
/// Without native chrome (Windows, Linux) the toolbar is the title bar: its empty space drags
/// the window and a double click maximizes. Widgets added after this take the clicks.
fn drag_area(ui: &mut Ui, rect: egui::Rect, st: &mut UiState) {
let drag = ui.interact(rect, ui.id().with("window-drag"), egui::Sense::click_and_drag());
if drag.double_clicked() {
st.win_cmd = Some(WinCmd::ToggleMaximize);
} else if drag.drag_started() {
st.win_cmd = Some(WinCmd::Drag);
}
ui.horizontal_centered(|ui| {
ui.add_space(8.0);
if st.home.is_some() {
theme_menu(ui);
} else {
menu_bar(ui, ed, &mut st.agent_link);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 0.0;
for (cmd, hover) in [
(WinCmd::Close, Color32::from_rgb(0xe8, 0x11, 0x23)),
(WinCmd::ToggleMaximize, p.hover),
(WinCmd::Minimize, p.hover),
] {
let (r, resp) =
ui.allocate_exact_size(egui::vec2(WIN_BTN_W, TITLEBAR_H), egui::Sense::click());
let hot = resp.hovered();
if hot {
ui.painter().rect_filled(r, 0.0, hover);
}
/// Minimize, maximize and close (right to left from the cursor), `h` tall. They follow the
/// toolbar's other buttons: the animated hover tint and the pointing hand come from
/// `motion::hover`, and Close lights up in the theme's danger colour on the accent surface.
fn window_buttons(ui: &mut Ui, st: &mut UiState, h: f32) {
let p = theme::pal(ui);
ui.scope(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
for (cmd, tip) in [(WinCmd::Close, "Close"), (WinCmd::ToggleMaximize, "Maximize"), (WinCmd::Minimize, "Minimize")] {
let (r, resp) = ui.allocate_exact_size(egui::vec2(WIN_BTN_W, h), egui::Sense::click());
let hover = motion::hover(ui.ctx(), resp.id.with("win-btn"), &resp);
let down = resp.is_pointer_button_down_on();
let cell = r.shrink2(egui::vec2(2.0, 4.0));
let closing = matches!(cmd, WinCmd::Close);
let mut ink = p.text;
if closing && (hover > 0.0 || down) {
let fill = if down { p.danger.gamma_multiply(0.8) } else { p.danger.gamma_multiply(hover) };
theme::fill_accent(ui, cell, theme::radius_md(), fill);
ink = kit::ink_on(p.danger);
} else if !closing {
let fill = if down { p.pressed } else { p.hover.gamma_multiply(hover) };
ui.painter().rect_filled(cell, theme::radius_md(), fill);
}
let stroke = egui::Stroke::new(WIN_GLYPH_STROKE, ink);
let c = r.center();
let k = 5.0;
match cmd {
WinCmd::Minimize => _ = ui.painter().hline(c.x - k..=c.x + k, c.y, stroke),
WinCmd::ToggleMaximize => {
ui.painter().rect_stroke(egui::Rect::from_center_size(c, egui::Vec2::splat(k * 2.0)), 0.0, stroke, egui::StrokeKind::Inside);
}
let ink = if hot && matches!(cmd, WinCmd::Close) {
Color32::WHITE
} else {
p.text
};
let stroke = egui::Stroke::new(1.0, ink);
let c = r.center();
let h = 5.0;
match cmd {
WinCmd::Minimize => {
ui.painter().hline(c.x - h..=c.x + h, c.y, stroke);
}
WinCmd::ToggleMaximize => {
ui.painter().rect_stroke(
egui::Rect::from_center_size(c, egui::Vec2::splat(h * 2.0)),
0.0,
stroke,
egui::StrokeKind::Inside,
);
}
_ => {
let d = egui::vec2(h, h);
let e = egui::vec2(h, -h);
ui.painter().line_segment([c - d, c + d], stroke);
ui.painter().line_segment([c - e, c + e], stroke);
}
}
if resp.clicked() {
st.win_cmd = Some(cmd);
_ => {
let (d, e) = (egui::vec2(k, k), egui::vec2(k, -k));
ui.painter().line_segment([c - d, c + d], stroke);
ui.painter().line_segment([c - e, c + e], stroke);
}
}
});
if resp.on_hover_text(tip).clicked() {
st.win_cmd = Some(cmd);
}
}
});
}
/// The home screen has no toolbar: its top strip drags the window, the buttons sit top right.
fn home_window_chrome(ui: &mut Ui, st: &mut UiState) {
let screen = ui.ctx().content_rect();
let strip = egui::Rect::from_min_size(screen.min, egui::vec2(screen.width(), theme::TOOLBAR_H));
egui::Area::new("home-window-drag".into()).order(egui::Order::Background).fixed_pos(strip.min).show(ui.ctx(), |ui| {
drag_area(ui, strip, st);
});
egui::Area::new("home-window-buttons".into())
.order(egui::Order::Foreground)
.pivot(egui::Align2::RIGHT_TOP)
.fixed_pos(screen.right_top())
.show(ui.ctx(), |ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| window_buttons(ui, st, theme::TOOLBAR_H));
});
}
/// The sidebar views as a strip that fills the sidebar: layers, library, design (variables, modes,
/// components), history, comments and the agent log. Each tab grows with the width; when there is
/// room it also shows its name.
+160 -13
View File
@@ -6,9 +6,16 @@ use crate::theme::{self, Icon, Surface as _};
use egui::{RichText, Ui};
use std::collections::VecDeque;
/// Frame-rate floors to choose from in Settings.
pub const THRESHOLDS: [u32; 5] = [30, 45, 60, 90, 120];
const DEFAULT_MIN_FPS: u32 = 60;
/// Frame-rate caps to choose from in Settings, up to the fastest gaming monitors.
pub const THRESHOLDS: [u32; 9] = [30, 45, 60, 90, 120, 144, 165, 240, 360];
/// The stored rate that means "follow the display": no cap, frames paced by vsync.
pub const AUTO: u32 = 0;
/// 60 Hz, except on macOS where many screens run 120 Hz and the display decides.
const DEFAULT_RATE: u32 = if cfg!(target_os = "macos") { AUTO } else { 60 };
/// What "slow" is measured against when the rate is Auto.
const AUTO_JUDGE_FPS: u32 = 60;
/// A frame this much early still draws: timers wake late and vsync snaps the rest.
const CAP_SLACK: std::time::Duration = std::time::Duration::from_millis(1);
/// Frames kept for the averages (about two seconds of steady animation).
const WINDOW: usize = 120;
/// Frames that must run slow before the flag shows, so one hitch doesn't raise it.
@@ -19,16 +26,28 @@ const MB: f64 = 1024.0 * 1024.0;
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct PerfPrefs {
pub overlay: bool,
/// Most frames drawn per second, [`AUTO`] (0) for the display's own rate. Slow frames
/// are judged against it too.
pub min_fps: u32,
}
impl Default for PerfPrefs {
fn default() -> Self {
PerfPrefs { overlay: false, min_fps: DEFAULT_MIN_FPS }
PerfPrefs { overlay: false, min_fps: DEFAULT_RATE }
}
}
impl PerfPrefs {
/// The rate frames are judged against: the cap, or 60 when there is none.
pub fn judge_fps(self) -> u32 {
if self.min_fps == AUTO { AUTO_JUDGE_FPS } else { self.min_fps }
}
/// The shortest time between two frames, or `None` to draw as fast as vsync allows.
pub fn frame_interval(self) -> Option<std::time::Duration> {
(self.min_fps != AUTO).then(|| std::time::Duration::from_secs_f64(1.0 / f64::from(self.min_fps)))
}
fn path() -> Option<std::path::PathBuf> {
Some(dirs::config_dir()?.join("papercraft").join("performance"))
}
@@ -36,6 +55,10 @@ impl PerfPrefs {
/// `"<overlay 0|1> <min fps>"`.
pub fn load() -> PerfPrefs {
let s = Self::path().and_then(|p| crate::vfs::read_to_string(p).ok()).unwrap_or_default();
Self::parse(&s)
}
fn parse(s: &str) -> PerfPrefs {
let mut it = s.split_whitespace();
let d = PerfPrefs::default();
PerfPrefs {
@@ -78,7 +101,7 @@ impl Perf {
self.ram = rss_bytes().map(|b| b as f64 / MB);
let cpu_now = cpu_seconds();
if let (Some((t, c)), Some(c2)) = (self.last_sample, cpu_now) {
self.cpu = Some(((c2 - c) / (now - t) * 100.0) as f32);
self.cpu = Some(cpu_percent(c2 - c, now - t, cpu_share()));
}
self.last_sample = cpu_now.map(|c| (now, c));
}
@@ -102,7 +125,7 @@ impl Perf {
/// longer than one frame at `min_fps`. Measured on work, not on the frame rate, since the
/// app draws nothing while idle.
pub fn is_slow(&self) -> bool {
let budget = 1000.0 / self.prefs.min_fps as f32;
let budget = 1000.0 / self.prefs.judge_fps() as f32;
self.frames.len() >= SLOW_STREAK && self.frames.iter().rev().take(SLOW_STREAK).all(|f| f.1 > budget)
}
@@ -116,7 +139,7 @@ impl Perf {
pub fn stats(&self, ui: &mut Ui) {
let p = theme::pal(ui);
let (avg, worst) = self.frame_ms();
let budget = 1000.0 / self.prefs.min_fps as f32;
let budget = 1000.0 / self.prefs.judge_fps() as f32;
let warn = |bad: bool| if bad { p.danger } else { p.text };
let na = || "n/a".to_string();
let rows = [
@@ -143,17 +166,20 @@ pub fn settings_page(ui: &mut Ui, perf: &mut Perf) {
kit::toggle(ui, &mut perf.prefs.overlay);
});
ui.add_space(12.0);
kit::section_header(ui, "Warn below", None);
kit::section_header(ui, "Refresh rate", None);
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(6.0, 8.0);
if kit::chip(ui, "Auto", perf.prefs.min_fps == AUTO).on_hover_text("Follow the display: no cap").clicked() {
perf.prefs.min_fps = AUTO;
}
for fps in THRESHOLDS {
if kit::chip(ui, &format!("{fps} FPS"), perf.prefs.min_fps == fps).clicked() {
perf.prefs.min_fps = fps;
}
}
});
ui.label(RichText::new("A badge shows on the canvas while frames take longer than this rate allows.").size(theme::SECONDARY).color(theme::pal(ui).tertiary));
ui.label(RichText::new("The most frames PaperCraft draws per second, so a fast monitor does not spend power on frames you cannot use. Auto follows the display. A badge shows on the canvas while frames take longer than this rate allows.").size(theme::SECONDARY).color(theme::pal(ui).tertiary));
ui.add_space(16.0);
kit::section_header(ui, "Now", None);
ui.add_space(4.0);
@@ -191,7 +217,7 @@ pub fn overlay(ctx: &egui::Context, perf: &mut Perf, canvas: egui::Rect) {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover());
theme::paint_icon(ui.painter(), r, Icon::Zap, p.danger);
ui.label(RichText::new(format!("Slow: {:.0} FPS, below {}", perf.capacity(), perf.prefs.min_fps)).size(theme::SECONDARY).color(p.danger));
ui.label(RichText::new(format!("Slow: {:.0} FPS, below {}", perf.capacity(), perf.prefs.judge_fps())).size(theme::SECONDARY).color(p.danger));
});
})
.response
@@ -209,6 +235,31 @@ pub fn overlay(ctx: &egui::Context, perf: &mut Perf, canvas: egui::Rect) {
}
}
/// How long to wait before the next frame may be drawn, or `None` to draw now: the frame cap
/// `interval` against the time since the last frame started.
pub fn cap_wait(interval: Option<std::time::Duration>, since_last: std::time::Duration) -> Option<std::time::Duration> {
let interval = interval?;
(since_last < interval.saturating_sub(CAP_SLACK)).then(|| interval - since_last)
}
/// CPU use in percent: `cpu_secs` of processor time over `wall_secs`, scaled by `share`.
fn cpu_percent(cpu_secs: f64, wall_secs: f64, share: f64) -> f32 {
if wall_secs <= 0.0 {
return 0.0;
}
(cpu_secs / wall_secs * share * 100.0) as f32
}
/// What one busy core counts for. Task Manager shows the share of the whole CPU (a thread
/// pegging one core of 16 reads 6%); Activity Monitor and top count per core (100% per core).
fn cpu_share() -> f64 {
if cfg!(windows) {
std::thread::available_parallelism().map_or(1.0, |n| 1.0 / n.get() as f64)
} else {
1.0
}
}
/// Resident memory of this process, in bytes: the footprint Activity Monitor shows on macOS.
#[cfg(target_os = "macos")]
fn rss_bytes() -> Option<u64> {
@@ -226,7 +277,31 @@ fn rss_bytes() -> Option<u64> {
Some(pages * unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as u64)
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
/// The private working set: the "Memory" column of Task Manager's Processes tab. The plain
/// working set also counts pages shared with other processes (DLLs), which reads higher.
#[cfg(windows)]
fn rss_bytes() -> Option<u64> {
use windows_sys::Win32::System::ProcessStatus::{
GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX2,
};
use windows_sys::Win32::System::Threading::GetCurrentProcess;
let mut info: PROCESS_MEMORY_COUNTERS_EX2 = unsafe { std::mem::zeroed() };
let cb = std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX2>() as u32;
// SAFETY: the current-process pseudo handle is always valid; `cb` is the size of the
// struct behind the pointer, whose first fields are a PROCESS_MEMORY_COUNTERS.
let ok = unsafe { GetProcessMemoryInfo(GetCurrentProcess(), (&raw mut info).cast::<PROCESS_MEMORY_COUNTERS>(), cb) } != 0;
if ok {
return Some(info.PrivateWorkingSetSize as u64);
}
// Before Windows 10 1809 the larger struct is refused: the plain working set is close.
let mut basic: PROCESS_MEMORY_COUNTERS = unsafe { std::mem::zeroed() };
basic.cb = std::mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32;
// SAFETY: as above, with the basic struct and its own size.
let ok = unsafe { GetProcessMemoryInfo(GetCurrentProcess(), &mut basic, basic.cb) } != 0;
ok.then_some(basic.WorkingSetSize as u64)
}
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
fn rss_bytes() -> Option<u64> {
None
}
@@ -243,7 +318,20 @@ fn cpu_seconds() -> Option<f64> {
Some(secs(u.ru_utime) + secs(u.ru_stime))
}
#[cfg(not(all(unix, not(target_arch = "wasm32"))))]
#[cfg(windows)]
fn cpu_seconds() -> Option<f64> {
use windows_sys::Win32::Foundation::FILETIME;
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetProcessTimes};
let zero = FILETIME { dwLowDateTime: 0, dwHighDateTime: 0 };
let (mut creation, mut exit, mut kernel, mut user) = (zero, zero, zero, zero);
// SAFETY: the current-process pseudo handle is always valid; the four out-pointers are live.
let ok = unsafe { GetProcessTimes(GetCurrentProcess(), &mut creation, &mut exit, &mut kernel, &mut user) } != 0;
// FILETIME counts 100 ns ticks.
let secs = |t: FILETIME| ((u64::from(t.dwHighDateTime) << 32) | u64::from(t.dwLowDateTime)) as f64 / 1e7;
ok.then(|| secs(kernel) + secs(user))
}
#[cfg(not(any(all(unix, not(target_arch = "wasm32")), windows)))]
fn cpu_seconds() -> Option<f64> {
None
}
@@ -270,11 +358,70 @@ mod tests {
assert!(!p.is_slow(), "30 ms fits a 30 FPS floor");
}
#[test]
fn refresh_rate_defaults_to_60_and_offers_high_rate_monitors() {
assert_eq!(PerfPrefs::default().min_fps, DEFAULT_RATE);
if !cfg!(target_os = "macos") {
assert_eq!(DEFAULT_RATE, 60);
}
assert_eq!(PerfPrefs::parse("").min_fps, DEFAULT_RATE, "nothing saved yet");
assert!(THRESHOLDS.contains(&60) && THRESHOLDS.contains(&360));
assert!(THRESHOLDS.windows(2).all(|w| w[0] < w[1]), "ascending, so the chips read in order");
}
#[test]
fn saved_prefs_round_trip_the_chosen_rate() {
let p = PerfPrefs { overlay: true, min_fps: 360 };
let saved = format!("{} {}", u8::from(p.overlay), p.min_fps);
assert!(PerfPrefs::parse(&saved) == p);
assert_eq!(PerfPrefs::parse("1 garbage").min_fps, DEFAULT_RATE, "an unreadable rate falls back to the default");
assert_eq!(PerfPrefs::parse("0 0").min_fps, AUTO, "Auto is a saved choice, not a missing one");
}
#[test]
fn auto_has_no_cap_and_is_judged_at_60() {
let auto = PerfPrefs { overlay: false, min_fps: AUTO };
assert_eq!(auto.frame_interval(), None);
assert_eq!(auto.judge_fps(), 60);
let p = PerfPrefs { overlay: false, min_fps: 240 };
assert_eq!(p.judge_fps(), 240);
let ms = p.frame_interval().unwrap().as_secs_f64() * 1000.0;
assert!((ms - 1000.0 / 240.0).abs() < 1e-6, "{ms}");
}
#[test]
fn cap_waits_out_the_rest_of_the_interval() {
use std::time::Duration as D;
let sixty = Some(D::from_micros(16_667));
assert_eq!(cap_wait(None, D::ZERO), None, "no cap, draw now");
assert_eq!(cap_wait(sixty, D::from_millis(20)), None, "a late frame draws at once");
assert_eq!(cap_wait(sixty, D::from_micros(16_000)), None, "within the slack of the beat");
assert_eq!(cap_wait(sixty, D::from_millis(5)), Some(D::from_micros(11_667)), "too soon: wait for the remainder");
assert_eq!(cap_wait(sixty, D::ZERO), Some(D::from_micros(16_667)));
}
#[test]
fn cpu_percent_is_scaled_to_the_share_one_core_counts_for() {
assert_eq!(cpu_percent(0.5, 1.0, 1.0), 50.0, "half a core, per-core accounting");
assert_eq!(cpu_percent(1.0, 1.0, 0.0625), 6.25, "one core busy of 16 reads 6.25%");
assert_eq!(cpu_percent(1.0, 0.0, 1.0), 0.0, "no elapsed time yields 0, not infinity");
}
#[test]
fn samples_memory() {
let mut p = Perf::default();
p.record(0.0, 1.0);
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[cfg(any(target_os = "macos", target_os = "linux", windows))]
assert!(p.ram.is_some_and(|m| m > 1.0));
}
#[test]
fn cpu_seconds_grow_with_work() {
let Some(before) = cpu_seconds() else { return };
let mut x = 0u64;
while cpu_seconds().is_some_and(|now| now - before < 0.05) {
x = std::hint::black_box(x.wrapping_add(1));
}
assert!(cpu_seconds().is_some_and(|now| now > before));
}
}
+6 -1
View File
@@ -345,9 +345,14 @@ pub fn show(ctx: &egui::Context, st: &mut UiState) {
if st.settings.page == Page::Chat {
content(ui, ctx, st);
} else {
// Inside a horizontal row the area would shrink to the row's
// height: it takes the dialog's.
let height = modal_height(ctx) - 24.0;
egui::ScrollArea::vertical()
.id_salt(("settings-page", st.settings.page as u8))
.max_height(modal_height(ctx) - 24.0)
.auto_shrink([false, false])
.max_height(height)
.min_scrolled_height(height)
.show(ui, |ui| content(ui, ctx, st));
}
});