Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec7f1f8a14 | |||
| 24f6c797f2 | |||
| a2885a3283 | |||
| 0143f1e465 | |||
| dc57f9af20 |
Generated
+3
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user