4 Commits

Author SHA1 Message Date
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
10 changed files with 355 additions and 502 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.1"
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.1"
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"
+6
View File
@@ -76,6 +76,12 @@ 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.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.
+50 -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(),
@@ -1038,8 +1043,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 +1128,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 {
@@ -1743,3 +1756,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.
+67 -66
View File
@@ -182,6 +182,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 +895,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 +908,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 +1048,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,8 +1158,11 @@ 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 !st.native_chrome {
if ui.max_rect().width() < MENU_BAR_W {
// Not enough room for every menu: they fold into one.
ui.scope(|ui| {
@@ -1263,6 +1262,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 +1476,72 @@ fn theme_menu(ui: &mut Ui) {
});
}
const TITLEBAR_H: f32 = 32.0;
const WIN_BTN_W: f32 = 46.0;
/// 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.
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, hover, tip) in [
(WinCmd::Close, Color32::from_rgb(0xe8, 0x11, 0x23), "Close"),
(WinCmd::ToggleMaximize, p.hover, "Maximize"),
(WinCmd::Minimize, p.hover, "Minimize"),
] {
let (r, resp) = ui.allocate_exact_size(egui::vec2(WIN_BTN_W, h), egui::Sense::click());
let hot = resp.hovered();
if hot {
ui.painter().rect_filled(r.shrink2(egui::vec2(2.0, 4.0)), theme::radius_md(), hover);
}
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 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.
+62 -7
View File
@@ -6,8 +6,8 @@ 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];
/// Refresh rates 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];
const DEFAULT_MIN_FPS: u32 = 60;
/// Frames kept for the averages (about two seconds of steady animation).
const WINDOW: usize = 120;
@@ -36,6 +36,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 {
@@ -143,7 +147,7 @@ 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);
@@ -153,7 +157,7 @@ pub fn settings_page(ui: &mut Ui, perf: &mut Perf) {
}
}
});
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("Frames are judged against this rate: a badge shows on the canvas while they take longer than it allows. Set it to your monitor refresh rate.").size(theme::SECONDARY).color(theme::pal(ui).tertiary));
ui.add_space(16.0);
kit::section_header(ui, "Now", None);
ui.add_space(4.0);
@@ -226,7 +230,19 @@ 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 working set, the "Memory" column of Task Manager's Details tab.
#[cfg(windows)]
fn rss_bytes() -> Option<u64> {
use windows_sys::Win32::System::ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS};
use windows_sys::Win32::System::Threading::GetCurrentProcess;
let mut info: PROCESS_MEMORY_COUNTERS = unsafe { std::mem::zeroed() };
info.cb = std::mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32;
// SAFETY: the current-process pseudo handle is always valid; `info.cb` is its size.
let ok = unsafe { GetProcessMemoryInfo(GetCurrentProcess(), &mut info, info.cb) } != 0;
ok.then_some(info.WorkingSetSize as u64)
}
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
fn rss_bytes() -> Option<u64> {
None
}
@@ -243,7 +259,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 +299,37 @@ 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, 60);
assert_eq!(PerfPrefs::parse("").min_fps, 60, "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, 60, "an unreadable rate falls back to 60");
}
#[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));
}
});