6 Commits

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:44:02 -03:00
29 changed files with 1099 additions and 588 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.3"
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.3"
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"
+19
View File
@@ -76,6 +76,25 @@ 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.3 (menus, panel grips, narrow windows)
* Menus open 6 px below their button, are at least 180 px wide and show an icon beside each item. Below 1100 px the hamburger opens every menu in one popup.
* Side panels: a slim rounded grip in the gap resizes them (double click resets) and `/` hides or shows both. Panels size to their content and step aside when the window is too narrow for the canvas.
* Sharper window buttons (pixel-snapped glyphs); the hamburger morphs into a back arrow while the File / Edit strip slides open and shut.
* Fixes: lint Fix buttons no longer cover the issue text; review rows keep their badge and revert button off the hover edge; the Export button stays on one line.
### 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.
+14
View File
@@ -74,6 +74,19 @@ It is an alpha. It is good for building screens, design systems and sketches, an
</tr>
</table>
<table>
<tr>
<td width="50%" valign="top">
<img src="docs/screenshots/readme/toolbar-panels.png" alt="The toolbar with the menu button folded into a back arrow, the File to Theme strip, and the floating left panel with a slim grip beside it" width="100%">
<br><sub>Toolbar and panels: the menu button shows or hides the File / Edit strip, and the slim grip between a panel and the canvas resizes it (double click resets). <kbd>/</kbd> hides both side panels.</sub>
</td>
<td width="50%" valign="top">
<img src="docs/screenshots/readme/menus.png" alt="The Tools menu open below its button, each tool with an icon and its shortcut" width="100%">
<br><sub>Menus open a little below their button, are never narrower than 180 px, and carry an icon next to each item.</sub>
</td>
</tr>
</table>
**Prototyping and review**
* Prototype links between frames and a **Present** mode that follows them with a camera glide.
* **Version history:** named and automatic saved versions per page; rename, restore, delete.
@@ -228,6 +241,7 @@ The scenario tests drive the editor with synthetic pointer and keyboard events;
| PageUp / PageDown | Previous / next page | ⌘\\ | Show / hide UI |
| ⌘J | Design / Chat mode | ⌘L | Add selection to chat (Chat mode) |
| N | Annotate tool | ⌘⇧A | Annotations: full, badges, hidden |
| / | Show / hide the side panels | | |
Draw a frame around existing shapes and it adopts them; dragging a layer into or out of a frame re-parents it. Hold ⌘ while dragging to suspend snapping.
Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

+59 -1
View File
@@ -60,6 +60,9 @@ const AGENT_FRAME: Duration = Duration::from_millis(50);
pub struct App {
gfx: Option<Gfx>,
/// The undecorated window edge under the pointer (Linux), which a press resizes.
#[cfg(all(unix, not(target_os = "macos")))]
resize_edge: Option<winit::window::ResizeDirection>,
pub ed: Editor,
session: Session,
st: UiState,
@@ -119,6 +122,8 @@ impl App {
let library = crate::library::Library::load(&crate::library::Library::path_in(&store.root));
let mut app = App {
gfx: None,
#[cfg(all(unix, not(target_os = "macos")))]
resize_edge: None,
ed: Editor::default(),
session: Session::new(store),
st: UiState::default(),
@@ -138,6 +143,7 @@ impl App {
};
app.st.library.library = library;
app.st.perf.prefs = crate::ui::perf::PerfPrefs::load();
app.st.menu_bar_hidden = crate::ui::menu_toggle::load_hidden();
app.ed.clipboard_io = crate::io::clipboard::System::new()
.map(|c| Box::new(c) as Box<dyn crate::io::clipboard::Provider>);
match start {
@@ -1038,8 +1044,12 @@ impl ApplicationHandler<UserEvent> for App {
.with_corner_preference(CornerPreference::Round)
.with_taskbar_icon(icon)
};
// Linux: no native frame either; the toolbar carries the window buttons and the window
// edges resize (`edge_resize`).
#[cfg(all(unix, not(target_os = "macos")))]
let attrs = attrs.with_decorations(false);
let window = Arc::new(event_loop.create_window(attrs).expect("create window"));
self.st.custom_titlebar = cfg!(windows);
self.st.custom_titlebar = cfg!(any(windows, all(unix, not(target_os = "macos"))));
window.set_ime_allowed(true);
#[cfg(target_os = "macos")]
{
@@ -1119,6 +1129,10 @@ impl ApplicationHandler<UserEvent> for App {
log::trace!("event {event:?} -> egui repaint {}", resp.repaint);
}
let scale = g.window.scale_factor();
#[cfg(all(unix, not(target_os = "macos")))]
if self.st.custom_titlebar && edge_resize(&g.window, &event, &mut self.resize_edge) {
return;
}
let mut redraw = resp.repaint;
let now = self.now();
match event {
@@ -1155,6 +1169,14 @@ impl ApplicationHandler<UserEvent> for App {
// egui-winit reports `repaint: true` for RedrawRequested itself; honouring it
// re-requested a redraw every frame (a 100% CPU busy loop). Whether another
// frame is needed is decided inside redraw() from egui's repaint delay.
// The frame-rate cap (Settings > Performance) holds a frame that comes too soon
// until its turn; every redraw request ends up here, so one check covers them all.
let since_last = self.last_redraw.map_or(Duration::MAX, |t| t.elapsed());
if let Some(wait) = ui::perf::cap_wait(self.st.perf.prefs.frame_interval(), since_last) {
let due = Instant::now() + wait;
self.next_repaint = Some(self.next_repaint.map_or(due, |r| r.min(due)));
return;
}
self.redraw();
return;
}
@@ -1743,3 +1765,39 @@ mod tests {
));
}
}
/// An undecorated window has no resize border (Linux): the pointer within a few pixels of an
/// edge shows a resize cursor and a press resizes. True when the event was consumed.
#[cfg(all(unix, not(target_os = "macos")))]
fn edge_resize(window: &Window, event: &WindowEvent, edge: &mut Option<winit::window::ResizeDirection>) -> bool {
use winit::window::{CursorIcon, ResizeDirection as D};
const BORDER: f64 = 6.0;
match event {
WindowEvent::CursorMoved { position, .. } => {
let size = window.inner_size();
let b = BORDER * window.scale_factor();
let (l, r) = (position.x < b, position.x > f64::from(size.width) - b);
let (t, bo) = (position.y < b, position.y > f64::from(size.height) - b);
let next = match (l, r, t, bo) {
(true, _, true, _) => Some(D::NorthWest),
(_, true, true, _) => Some(D::NorthEast),
(true, _, _, true) => Some(D::SouthWest),
(_, true, _, true) => Some(D::SouthEast),
(true, ..) => Some(D::West),
(_, true, ..) => Some(D::East),
(_, _, true, _) => Some(D::North),
(.., true) => Some(D::South),
_ => None,
};
if next != *edge {
window.set_cursor(next.map_or(CursorIcon::Default, CursorIcon::from));
*edge = next;
}
false
}
WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => {
edge.is_some_and(|d| window.drag_resize_window(d).is_ok())
}
_ => false,
}
}
+1
View File
@@ -12,6 +12,7 @@ pub enum UiEvent {
TogglePalette,
Rename(NodeId),
ToggleUi,
TogglePanels,
ToggleChat,
ToggleHelp,
ToggleGrid,
+1
View File
@@ -2186,6 +2186,7 @@ impl Editor {
}
}
A::ToggleUi => self.ui_events.push(commands::UiEvent::ToggleUi),
A::TogglePanels => self.ui_events.push(commands::UiEvent::TogglePanels),
A::ToggleChat => self.ui_events.push(commands::UiEvent::ToggleChat),
A::ToggleHelp => self.ui_events.push(commands::UiEvent::ToggleHelp),
A::ToggleGrid => self.ui_events.push(commands::UiEvent::ToggleGrid),
+4
View File
@@ -59,6 +59,7 @@ pub enum Action {
Ask,
Rename,
ToggleUi,
TogglePanels,
ToggleChat,
ToggleHelp,
ToggleGrid,
@@ -168,6 +169,7 @@ fn bindings() -> Vec<(Key, Chord, Action, &'static str)> {
(Char('/'), CMD_SHIFT, A::CommandPalette, "Command palette"),
(Char('r'), CMD, A::Rename, "Rename layer"),
(Char('\\'), CMD, A::ToggleUi, "Show / hide UI"),
(Char('/'), PLAIN, A::TogglePanels, "Show / hide the side panels"),
(Char('/'), CMD, A::ToggleHelp, "Keyboard shortcuts"),
(Char('?'), SHIFT, A::ToggleHelp, "Keyboard shortcuts"),
(Char('\''), CMD_SHIFT, A::ToggleGrid, "Show / hide dot grid"),
@@ -434,6 +436,7 @@ pub fn category(a: Action) -> &'static str {
| A::ToggleAnnotations
| A::OpenVariables
| A::ToggleUi
| A::TogglePanels
| A::ToggleChat
| A::ToggleHelp
| A::NextPage
@@ -618,6 +621,7 @@ pub fn menus() -> Vec<(&'static str, Vec<MenuEntry>)> {
I("Dot Grid", A::ToggleGrid),
I("Annotations", A::ToggleAnnotations),
I("Show/Hide UI", A::ToggleUi),
I("Show/Hide Panels", A::TogglePanels),
I("Design / Chat Mode", A::ToggleChat),
I("Keyboard Shortcuts", A::ToggleHelp),
],
+29 -200
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,115 +921,42 @@ 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);
}
/// Narrowest a popup or menu gets, so short lists don't read as a sliver.
pub const POPUP_MIN_W: f32 = 180.0;
/// Space between a button and the menu it opens.
pub const POPUP_GAP: f32 = 6.0;
/// [`modal_backing`] for a popup, menu or context menu: called first in its closure, it lights
/// the popup's frame (its rect from the last frame) under acrylic.
pub fn popup_backing(ui: &egui::Ui) {
pub fn popup_backing(ui: &mut egui::Ui) {
ui.set_min_width(POPUP_MIN_W);
let v = ui.visuals();
modal_backing(ui, v.window_fill, v.menu_corner_radius.nw);
}
@@ -1049,61 +973,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 +1004,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 +1017,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
+1 -1
View File
@@ -80,7 +80,7 @@ pub fn menu(ui: &mut Ui, ed: &Editor) -> Option<Act> {
pub fn chip(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
let r = super::kit::chip(ui, "Actions", false).on_hover_text("Actions on the selection");
let mut act = None;
egui::Popup::menu(&r).width(200.0).show(|ui| {
egui::Popup::menu(&r).gap(crate::theme::POPUP_GAP).width(200.0).show(|ui| {
crate::theme::popup_backing(ui);
act = menu(ui, ed);
if act.is_some() {
+1 -1
View File
@@ -58,7 +58,7 @@ pub fn json_config(url: &str) -> String {
impl AgentUi {
/// The top-level "Agent" menu.
pub fn menu(&mut self, ui: &mut egui::Ui) {
ui.menu_button("Agent", |ui| {
super::menus::menu_button(ui, "Agent", |ui| {
crate::theme::popup_backing(ui);
if super::icon_menu_item(ui, crate::theme::Icon::Export, "Connect…").clicked() {
self.open = true;
+1 -1
View File
@@ -279,7 +279,7 @@ fn category_select(ui: &mut Ui, current: &str, cats: &[AnnotationCategory]) -> O
p.secondary,
);
let mut chosen = None;
egui::Popup::menu(&resp).width(rect.width().max(190.0)).show(|ui| {
egui::Popup::menu(&resp).gap(crate::theme::POPUP_GAP).width(rect.width().max(190.0)).show(|ui| {
crate::theme::popup_backing(ui);
ui.spacing_mut().item_spacing.y = 2.0;
for c in cats {
+1 -1
View File
@@ -162,7 +162,7 @@ fn picker(ui: &mut Ui, ed: &Editor, kind: Kind, current: Option<&str>) -> Option
}
fn open_picker(resp: &egui::Response, ed: &Editor, kind: Kind, current: Option<&str>) -> Option<String> {
egui::Popup::menu(resp)
egui::Popup::menu(resp).gap(crate::theme::POPUP_GAP)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.width(POPUP_W)
.show(|ui| {
+10 -5
View File
@@ -1612,9 +1612,8 @@ fn pan_to(ed: &mut Editor, key: &str) {
/// The left column: replaces the layers panel in Chat mode.
pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, frame: egui::Frame, floating: bool, fill: egui::Color32) {
let r = egui::Panel::left("chat")
.resizable(true)
.default_size(st.chat.column_width())
.size_range(COLUMN_MIN..=COLUMN_MAX)
.resizable(false)
.exact_size(st.chat.column_width().clamp(COLUMN_MIN, COLUMN_MAX))
.frame(frame)
.show(ui, |ui| {
if floating {
@@ -1623,7 +1622,13 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, frame: egui::Frame,
column(ui, ed, st);
}
});
let w = r.response.rect.width();
let gap = if floating { super::PANEL_GAP } else { 0.0 };
let grip = super::panel_handle::show(ui.ctx(), "chat-grip".into(), r.response.rect, super::panel_handle::Side::Left, gap);
let w = if grip.reset {
COLUMN_DEFAULT
} else {
super::panel_handle::resized(st.chat.column_width(), grip.delta, COLUMN_MIN..=COLUMN_MAX)
};
if (w - st.chat.column_width()).abs() > 0.5 {
st.chat.width = w;
st.chat.width_dirty = true;
@@ -1924,7 +1929,7 @@ fn chat_menu(anchor: &egui::Response, st: &mut UiState) {
const LOGO: f32 = 14.0;
const LIST_MAX_H: f32 = 360.0;
let running = st.chat.is_running();
egui::Popup::menu(anchor).width(300.0).show(|ui| {
egui::Popup::menu(anchor).gap(crate::theme::POPUP_GAP).width(300.0).show(|ui| {
crate::theme::popup_backing(ui);
let p = theme::pal(ui);
ui.spacing_mut().item_spacing.y = 2.0;
+44
View File
@@ -0,0 +1,44 @@
//! Pixel-grid helpers for thin icon strokes: a line centred between two device pixels is drawn
//! as a soft double-width smear, which reads as aliasing. Snapping the stroke to whole device
//! pixels and its centre to the grid keeps horizontal and vertical glyphs sharp.
use egui::{Pos2, Stroke};
/// A stroke of about `width` points, rounded to a whole number of device pixels (at least one),
/// and the number of device pixels it covers.
pub fn stroke(ppp: f32, width: f32, color: egui::Color32) -> (Stroke, u32) {
let px = (width * ppp).round().max(1.0);
(Stroke::new(px / ppp, color), px as u32)
}
/// Snaps `v` (points) so a line `px` device pixels wide centred on it covers whole pixels: odd
/// widths are centred on a pixel's middle, even widths on a pixel edge.
pub fn snap(v: f32, ppp: f32, px: u32) -> f32 {
let offset = if px % 2 == 1 { 0.5 } else { 0.0 };
((v * ppp - offset).round() + offset) / ppp
}
pub fn snap_pos(p: Pos2, ppp: f32, px: u32) -> Pos2 {
Pos2::new(snap(p.x, ppp, px), snap(p.y, ppp, px))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stroke_covers_whole_device_pixels() {
let (s, px) = stroke(1.5, 1.25, egui::Color32::WHITE);
assert_eq!(px, 2);
assert!((s.width * 1.5 - 2.0).abs() < 1e-5);
assert_eq!(stroke(1.0, 0.2, egui::Color32::WHITE).1, 1, "never thinner than a pixel");
}
#[test]
fn odd_widths_centre_on_pixel_middles_even_on_edges() {
let on_grid = |v: f32, ppp: f32, off: f32| ((v * ppp - off) - (v * ppp - off).round()).abs() < 1e-4;
assert!(on_grid(snap(10.3, 1.0, 1), 1.0, 0.5));
assert!(on_grid(snap(10.3, 1.5, 2), 1.5, 0.0));
assert_eq!(snap(10.0, 2.0, 2), 10.0);
}
}
+129 -62
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)
@@ -152,38 +184,73 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
| "type-scale"
| "touch-target"
);
ui.horizontal(|ui| {
ui.label(RichText::new("●").color(dot_color).size(theme::SECONDARY));
let r = ui.add(
egui::Label::new(
RichText::new(format!("{node_name}: {}", issue.message))
.size(theme::SECONDARY),
)
.truncate()
.sense(egui::Sense::click()),
);
if r.on_hover_text(&issue.fix).clicked() {
ed.selection = vec![issue.node];
ed.run(crate::editor::shortcuts::Action::ZoomSelection);
}
if fixable {
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")
.on_hover_text(&issue.fix)
.clicked()
{
let target = issue.node;
ed.edit("Fix lint issue", |tx| {
crate::mcp::lint::autofix_node(
tx,
target,
&crate::mcp::lint::DEFAULT_SCALE,
);
});
}
// The fix button is an icon at the row's end and takes its room first, so the
// text beside it truncates instead of running underneath.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if fixable
&& crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Zap), "")
.on_hover_text(format!("Fix: {}", issue.fix))
.clicked()
{
let target = issue.node;
ed.edit("Fix lint issue", |tx| {
crate::mcp::lint::autofix_node(tx, target, &crate::mcp::lint::DEFAULT_SCALE);
});
}
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.label(RichText::new("●").color(dot_color).size(theme::SECONDARY));
let r = ui.add(
egui::Label::new(
RichText::new(format!("{node_name}: {}", issue.message)).size(theme::SECONDARY),
)
.truncate()
.sense(egui::Sense::click()),
);
if r.on_hover_text(&issue.fix).clicked() {
ed.selection = vec![issue.node];
ed.run(crate::editor::shortcuts::Action::ZoomSelection);
}
});
});
}
});
}
#[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");
}
}
+1 -1
View File
@@ -614,7 +614,7 @@ fn project_card(ui: &mut Ui, hv: &mut HomeView, e: &Entry) {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let more =
theme::icon_button(ui, Icon::More, 20.0, false).on_hover_text("More");
egui::Popup::menu(&more).show(|ui| {
egui::Popup::menu(&more).gap(crate::theme::POPUP_GAP).show(|ui| {
crate::theme::popup_backing(ui);
card_menu(ui, hv, e)
});
+1
View File
@@ -901,6 +901,7 @@ fn export_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
};
if theme::lit(ui, |ui| ui.add(
egui::Button::new(format!("Export {what}…"))
.truncate()
.min_size(egui::vec2(ui.available_width(), 24.0)),
))
.clicked()
+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.
+104
View File
@@ -0,0 +1,104 @@
//! 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;
/// Reach of the arrow head's strokes, along and across the middle bar.
const ARROW_HEAD: f32 = 4.5;
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" });
}
/// Where the three bars end, as `[start, end]` offsets from the button's centre, with the strip
/// `open` (0 = hidden, 1 = shown). Hidden they are three bars; shown they have folded into a
/// back arrow, pointing the way the strip slides away. The middle bar never moves.
fn bars(open: f32) -> [[Vec2; 2]; 3] {
let lerp = |a: Vec2, b: Vec2| a + (b - a) * open;
let (h, g) = (BAR_HALF, BAR_GAP);
let tip = Vec2::new(-h, 0.0);
let head = Vec2::new(-h + ARROW_HEAD, ARROW_HEAD);
let head_up = Vec2::new(head.x, -head.y);
[
[lerp(Vec2::new(-h, -g), tip), lerp(Vec2::new(h, -g), head_up)],
[Vec2::new(-h, 0.0), Vec2::new(h, 0.0)],
[lerp(Vec2::new(-h, g), tip), lerp(Vec2::new(h, g), head)],
]
}
/// The menu button. Clicking toggles the strip: the caller flips and saves the choice. `open`
/// is the strip's eased state, which also drives the icon's morph.
pub fn button(ui: &mut Ui, open: f32) -> Response {
let tip = if open < 0.5 { "Show the menu bar" } else { "Hide the menu bar" };
icon(ui, open).on_hover_text(tip)
}
/// The same three bars as a plain menu button, for windows too narrow for the strip.
pub fn menu_button(ui: &mut Ui) -> Response {
icon(ui, 0.0).on_hover_text("Menu")
}
fn icon(ui: &mut Ui, open: f32) -> 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 [a, b] in bars(open) {
ui.painter().line_segment([c + a, c + b], stroke);
}
}
resp
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bars_are_flat_when_hidden_and_an_arrow_when_shown() {
let hidden = bars(0.0);
assert!(hidden.iter().all(|[a, b]| a.y == b.y), "three flat bars");
let shown = bars(1.0);
assert_eq!(shown[0][0], shown[2][0], "the head strokes meet at the tip");
assert_eq!(shown[0][0], shown[1][0], "and the tip is the shaft's end");
assert!(shown[0][1].y < 0.0 && shown[2][1].y > 0.0);
}
#[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");
}
}
+148
View File
@@ -0,0 +1,148 @@
//! The menu bar's building blocks: top-level menus that open a little below their button, and
//! items with a leading icon.
use crate::editor::Tool;
use crate::editor::shortcuts::Action;
use crate::editor::vector_ops::BoolOp;
use crate::theme::{self, Icon};
use egui::{Atom, Button, Id, InnerResponse, Popup, UiKind, UiStackInfo, Ui, Vec2};
use egui::containers::menu::MenuConfig;
/// Room kept for an item's icon, with or without one, so the labels line up.
const ICON_SLOT: f32 = 16.0;
/// A menu title that opens its menu [`theme::POPUP_GAP`] below the button, unlike egui's own
/// `menu_button`, which butts the menu up against it. Inside a menu use `ui.menu_button`.
pub fn menu_button<R>(
ui: &mut Ui,
title: &str,
content: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<Option<R>> {
let response = ui.add(Button::new(title));
let config = MenuConfig::find(ui);
let inner = Popup::menu(&response)
.gap(theme::POPUP_GAP)
.close_behavior(config.close_behavior)
.style(config.style.clone())
.info(UiStackInfo::new(UiKind::Menu).with_tag_value(MenuConfig::MENU_CONFIG_TAG, config))
.show(content);
InnerResponse::new(inner.map(|i| i.inner), response)
}
/// An item: its icon (if it has one), the label, and the shortcut on the right.
pub fn item(ui: &mut Ui, label: &str, shortcut: String, icon: Option<Icon>) -> egui::Response {
let slot = Id::new(("menu-icon", label));
let button = Button::new((Atom::custom(slot, Vec2::splat(ICON_SLOT)), label)).shortcut_text(shortcut);
let out = button.atom_ui(ui);
if let (Some(icon), Some(rect)) = (icon, out.rect(slot)) {
let ink = ui.style().interact(&out.response).fg_stroke.color;
theme::paint_icon(ui.painter(), rect, icon, ink);
}
out.response
}
/// The icon an action's menu item wears, where there is a fitting one.
pub fn icon_of(action: Action) -> Option<Icon> {
use Action as A;
Some(match action {
A::Tool(t) => tool_icon(t),
A::Undo => Icon::Restore,
A::Delete => Icon::Trash,
A::SelectAll => Icon::Pointer,
A::Group | A::Ungroup | A::WrapInFrame => Icon::Group,
A::CreateComponent => Icon::Component,
A::DetachInstance => Icon::Instance,
A::PlaceImage => Icon::Image,
A::ExportPng | A::ExportSvg | A::ExportPdf | A::CopyAsPng | A::CopyAsSvg => Icon::Export,
A::Home => Icon::Grid,
A::New => Icon::Plus,
A::Open | A::SaveAs => Icon::Page,
A::Align(0, v) => align_x(v),
A::Align(_, v) => align_y(v),
A::Distribute(0) => Icon::DistributeH,
A::Distribute(_) => Icon::DistributeV,
A::ToggleVisible => Icon::Eye,
A::ToggleLock => Icon::Lock,
A::AddAutoLayout | A::RemoveAutoLayout => Icon::Wrap,
A::Boolean(op) => bool_icon(op),
A::Bold | A::Italic | A::Underline | A::Strikethrough | A::Rename => Icon::Text,
A::ToggleSketch => Icon::Sketch,
A::CommandPalette | A::Ask => Icon::Search,
A::ToggleUi | A::TogglePanels => Icon::Sidebar,
A::ToggleGrid => Icon::Grid,
A::ToggleAnnotations => Icon::Note,
A::OpenVariables => Icon::Database,
A::ToggleHelp => Icon::Question,
A::ZoomIn => Icon::Plus,
A::ZoomOut => Icon::Minus,
A::Zoom100 | A::ZoomFit | A::ZoomSelection => Icon::Scale,
A::NextPage | A::PrevPage => Icon::Page,
A::ToggleChat => Icon::Zap,
_ => return None,
})
}
fn tool_icon(t: Tool) -> Icon {
match t {
Tool::Select => Icon::Pointer,
Tool::Hand => Icon::Hand,
Tool::Frame => Icon::Frame,
Tool::Rect => Icon::Rect,
Tool::Ellipse => Icon::Ellipse,
Tool::Polygon => Icon::Polygon,
Tool::Star => Icon::Star,
Tool::Diamond => Icon::Diamond,
Tool::Line => Icon::Line,
Tool::Arrow => Icon::Arrow,
Tool::Pen => Icon::Pen,
Tool::Text => Icon::Text,
Tool::Sticky => Icon::Sticky,
Tool::Pencil => Icon::Pencil,
Tool::Eraser => Icon::Eraser,
Tool::Laser => Icon::Laser,
Tool::Annotate => Icon::Note,
}
}
fn align_x(at: f64) -> Icon {
if at < 0.25 {
Icon::AlignLeft
} else if at > 0.75 {
Icon::AlignRight
} else {
Icon::AlignHCenter
}
}
fn align_y(at: f64) -> Icon {
if at < 0.25 {
Icon::AlignTop
} else if at > 0.75 {
Icon::AlignBottom
} else {
Icon::AlignVCenter
}
}
fn bool_icon(op: BoolOp) -> Icon {
match op {
BoolOp::Union => Icon::Group,
BoolOp::Subtract | BoolOp::Intersect | BoolOp::Exclude => Icon::Corners,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_tool_has_an_icon_and_alignments_follow_their_edge() {
for t in Tool::ALL {
assert!(icon_of(Action::Tool(t)).is_some(), "{t:?}");
}
assert_eq!(icon_of(Action::Align(0, 0.0)), Some(Icon::AlignLeft));
assert_eq!(icon_of(Action::Align(0, 1.0)), Some(Icon::AlignRight));
assert_eq!(icon_of(Action::Align(1, 0.5)), Some(Icon::AlignVCenter));
assert_eq!(icon_of(Action::Escape), None);
}
}
+222 -99
View File
@@ -17,6 +17,10 @@ pub mod props_modal;
pub mod kit;
pub mod library;
pub mod lottie_icon;
pub mod crisp;
pub mod menu_toggle;
pub mod menus;
pub mod panel_handle;
pub mod motion;
pub mod pages;
pub mod perf;
@@ -57,6 +61,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,
@@ -96,6 +102,12 @@ pub struct UiState {
pub libraries: libraries::LibrariesUi,
/// All panels hidden (⌘\\): just the canvas.
pub hide_ui: bool,
/// The side panels are tucked away (`/`); the toolbar and canvas stay.
pub panels_hidden: bool,
/// Dragged widths of the left and right panels; `None` keeps the default.
pub panel_w: [Option<f32>; 2],
/// Narrowest width each side panel's content needs, learned from how far it overflowed.
pub panel_need: [f32; 2],
/// Present mode: only the canvas, with a presenter bar to step through the page's frames.
pub present: bool,
/// Frame shown by the presenter (index into the page's top-level frames).
@@ -182,6 +194,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.
@@ -602,6 +616,8 @@ pub(crate) const PANEL_GAP: f32 = 12.0;
/// Canvas width under which the top bar drops the zoom pill (it is also on the keyboard).
const TIGHT_BAR_W: f32 = 520.0;
/// Room the hamburger, home and mode buttons take on top of the bar's old budget.
const TIGHT_EXTRA: f32 = 80.0;
/// Tallest the top bar's page menu grows before it scrolls.
const PAGE_MENU_MAX_H: f32 = 360.0;
@@ -610,6 +626,9 @@ const MENU_BAR_W: f32 = 1100.0;
/// Largest share of the window one side panel may take.
const SIDE_MAX_SHARE: f32 = 0.3;
/// The canvas, with the toolbar above it, needs at least this much room; below it the side
/// panels step aside.
const MIN_CANVAS_W: f32 = 600.0;
/// Bottom strip of the canvas taken by the presenter bar; clicks there never follow links.
const PRESENT_BAR_H: f32 = 80.0;
@@ -787,7 +806,48 @@ pub(crate) fn is_floating(translucent: bool) -> bool {
}
/// A rounded panel that floats on the backdrop and fills the space it is given.
fn floating_panel(ui: &mut Ui, fill: Color32, margin: egui::Margin, add: impl FnOnce(&mut Ui)) {
/// The panel width after a grip: `None` (the default) on a double click, else `current` moved by
/// the drag. Unmoved panels stay `None` so they keep following the default.
/// How much wider than the `room` it was given a panel's content came out this frame. (Widgets
/// that overflow grow `max_rect` too, so it is the width that has to be compared.)
fn overflow(ui: &Ui, room: f32) -> f32 {
(ui.min_rect().width() - room).max(0.0)
}
/// The width a panel has to be to hold its content: what it was plus what overflowed. Kept once
/// learned, so the panel does not shrink back and overflow again.
fn grown_need(need: f32, width: f32, overflow: f32) -> f32 {
if overflow > 0.5 { need.max(width + overflow + PANEL_BREATH) } else { need }
}
/// Spare room added to a learned panel width, so the content does not end on the card's edge.
const PANEL_BREATH: f32 = 8.0;
#[cfg(test)]
mod panel_need_tests {
use super::*;
#[test]
fn a_panel_learns_the_width_its_content_needs_and_keeps_it() {
let need = grown_need(0.0, 296.0, 7.0);
assert_eq!(need, 296.0 + 7.0 + PANEL_BREATH);
assert_eq!(grown_need(need, 320.0, 0.0), need, "no overflow: unchanged");
assert_eq!(grown_need(need, 296.0, 3.0), need, "never shrinks");
}
}
fn grip_width(saved: Option<f32>, grip: panel_handle::Grip, current: f32, range: std::ops::RangeInclusive<f32>) -> Option<f32> {
if grip.reset {
return None;
}
if grip.delta == 0.0 {
return saved;
}
Some(panel_handle::resized(current, grip.delta, range))
}
/// Returns how far the content ran past the card's right edge (0 when it fits).
fn floating_panel(ui: &mut Ui, fill: Color32, margin: egui::Margin, add: impl FnOnce(&mut Ui)) -> f32 {
let fill = if theme::is_acrylic() {
let dark = ui.visuals().dark_mode;
let rect = ui.available_rect_before_wrap();
@@ -803,8 +863,11 @@ fn floating_panel(ui: &mut Ui, fill: Color32, margin: egui::Margin, add: impl Fn
.show(ui, |ui| {
ui.set_min_height(ui.available_height());
ui.set_width(ui.available_width());
let room = ui.available_width();
add(ui);
});
overflow(ui, room)
})
.inner
}
/// The acrylic backdrop around the canvas (which the renderer draws underneath the UI, so it
@@ -893,13 +956,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 +969,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) {
@@ -928,6 +987,7 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
}
}
UiEvent::ToggleUi => st.hide_ui = !st.hide_ui,
UiEvent::TogglePanels => st.panels_hidden = !st.panels_hidden,
UiEvent::ToggleChat => st.set_chat_mode(!st.chat_mode),
UiEvent::ToggleHelp => st.show_help = !st.show_help,
UiEvent::ToggleGrid => st.show_grid_off = !st.show_grid_off,
@@ -961,6 +1021,8 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
// The acrylic backdrop goes under the panels, but needs the canvas rect from after them.
let backdrop_slot = ui.painter().add(egui::Shape::Noop);
let gap = PANEL_GAP;
// Empty margin between a floating panel's card and the canvas; none when they are flush.
let grip_gap = if floating { gap } else { 0.0 };
let side_frame = |left: i8, right: i8, top: i8, bottom: i8| {
if floating {
egui::Frame::NONE
@@ -978,7 +1040,9 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
// Narrow windows keep the canvas usable: the side panels never take more than 30% each.
let window_w = ui.max_rect().width();
let side_max = |min: f32| (window_w * SIDE_MAX_SHARE).clamp(min, 430.0);
if st.chat_mode {
if st.panels_hidden {
// Only the toolbar and the canvas.
} else if st.chat_mode {
chat::panel(
ui,
ed,
@@ -994,10 +1058,17 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
sidebar_fill,
);
} else {
egui::Panel::left("layers")
.resizable(true)
.default_size(252.0 + 2.0 * gap)
.size_range(190.0..=side_max(190.0))
let left_range = 190.0f32.max(st.panel_need[0])..=side_max(190.0).max(st.panel_need[0]);
let left_w = panel_handle::resized(st.panel_w[0].unwrap_or(252.0 + 2.0 * gap), 0.0, left_range.clone());
let right_range = 230.0f32.max(st.panel_need[1])..=side_max(230.0).max(st.panel_need[1]);
let right_w = panel_handle::resized(st.panel_w[1].unwrap_or(272.0 + 2.0 * gap), 0.0, right_range.clone());
// A window too narrow for the canvas and both panels gives up the right one, then the left.
let show_right = window_w - left_w - right_w >= MIN_CANVAS_W;
let show_left = show_right || window_w - left_w >= MIN_CANVAS_W;
if show_left {
let left = egui::Panel::left("layers")
.resizable(false)
.exact_size(left_w)
.frame(if floating {
side_frame(gap as i8, gap as i8, gap as i8, gap as i8)
} else {
@@ -1021,15 +1092,21 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
layers(ui, ed, st)
};
if floating {
floating_panel(ui, sidebar_fill, egui::Margin::symmetric(14, 12), body);
floating_panel(ui, sidebar_fill, egui::Margin::symmetric(14, 12), body)
} else {
let room = ui.available_width();
body(ui);
overflow(ui, room)
}
});
egui::Panel::right("design")
.resizable(true)
.default_size(272.0 + 2.0 * gap)
.size_range(230.0..=side_max(230.0))
st.panel_need[0] = grown_need(st.panel_need[0], left.response.rect.width(), left.inner);
let grip = panel_handle::show(ui.ctx(), "layers-grip".into(), left.response.rect, panel_handle::Side::Left, grip_gap);
st.panel_w[0] = grip_width(st.panel_w[0], grip, left_w, left_range);
}
if show_right {
let right = egui::Panel::right("design")
.resizable(false)
.exact_size(right_w)
.frame(if floating {
side_frame(gap as i8, gap as i8, gap as i8, gap as i8)
} else {
@@ -1041,19 +1118,23 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
if floating {
floating_panel(ui, p.inspector, inspector::MARGIN, |ui| {
inspector::panel(ui, ed, st)
});
})
} else {
inspector::panel(ui, ed, st)
let room = ui.available_width();
inspector::panel(ui, ed, st);
overflow(ui, room)
}
});
st.panel_need[1] = grown_need(st.panel_need[1], right.response.rect.width(), right.inner);
let grip = panel_handle::show(ui.ctx(), "design-grip".into(), right.response.rect, panel_handle::Side::Right, grip_gap);
st.panel_w[1] = grip_width(st.panel_w[1], grip, right_w, right_range);
}
}
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)
@@ -1129,10 +1210,7 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
}
fn menu_item(ui: &mut Ui, ed: &mut Editor, label: &str, action: Action) {
if ui
.add(egui::Button::new(label).shortcut_text(shortcuts::chord_text(action)))
.clicked()
{
if menus::item(ui, label, shortcuts::chord_text(action), menus::icon_of(action)).clicked() {
ed.run(action);
ui.close();
}
@@ -1155,27 +1233,53 @@ fn menu_entries(ui: &mut Ui, ed: &mut Editor, entries: &[shortcuts::MenuEntry])
}
}
/// The File / Edit strip. `open` is its eased 0..=1 state: while it moves, the strip is laid out
/// at its natural width but only `open` of it is shown (clipped and faded), and the toolbar
/// beside it follows that width, so it slides rather than pops.
fn menu_strip(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, open: f32) {
if open <= 0.0 {
return;
}
let width_id = egui::Id::new("menu-strip-width");
let natural: f32 = ui.ctx().data(|d| d.get_temp(width_id)).unwrap_or(MENU_STRIP_GUESS_W);
let (rect, _) = ui.allocate_exact_size(egui::vec2(natural * open, ui.available_height()), egui::Sense::hover());
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::left_to_right(egui::Align::Center)));
child.set_clip_rect(rect.intersect(ui.clip_rect()));
child.set_opacity(open);
menu_bar(&mut child, ed, &mut st.agent_link);
let measured = child.min_rect().width();
ui.ctx().data_mut(|d| d.insert_temp(width_id, measured));
}
/// The unified toolbar: document title on the left, zoom and help on the right. Without native
/// chrome (Linux) it also carries an egui menu bar.
fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
let p = theme::pal(ui);
// 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;
// The window buttons take 3 x 46 px, so the bar gives up its extras that much sooner.
let buttons_w = if st.custom_titlebar { 3.0 * WIN_BTN_W } else { 0.0 };
let compact = ui.max_rect().width() < 720.0 + buttons_w;
let tight = ui.max_rect().width() < TIGHT_BAR_W + buttons_w + TIGHT_EXTRA;
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| {
flat_menus(ui);
let menu = ui.menu_button("Menu", |ui| {
crate::theme::popup_backing(ui);
flat_menu(ui, ed)
});
let _ = menu;
// Not enough room for every menu: the button opens them all in one popup.
let menu = menu_toggle::menu_button(ui);
egui::Popup::menu(&menu).gap(theme::POPUP_GAP).show(|ui| {
theme::popup_backing(ui);
flat_menu(ui, ed)
});
} else {
ui.scope(|ui| menu_bar(ui, ed, &mut st.agent_link));
let open = motion::progress(ui.ctx(), "menu-strip", !st.menu_bar_hidden, MENU_STRIP_SECS, motion::ease_in_out_cubic);
if menu_toggle::button(ui, open).clicked() {
st.menu_bar_hidden = !st.menu_bar_hidden;
menu_toggle::save_hidden(st.menu_bar_hidden);
}
ui.add_space(4.0);
menu_strip(ui, ed, st, open);
}
ui.add_space(6.0);
ui.separator();
@@ -1241,7 +1345,7 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
.inner;
if many {
let page = page.on_hover_text("Switch page").on_hover_cursor(egui::CursorIcon::PointingHand);
egui::Popup::menu(&page).show(|ui| {
egui::Popup::menu(&page).gap(crate::theme::POPUP_GAP).show(|ui| {
crate::theme::popup_backing(ui);
ui.set_min_width(180.0);
egui::ScrollArea::vertical().max_height(PAGE_MENU_MAX_H).show(ui, |ui| {
@@ -1263,8 +1367,12 @@ 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| {
egui::Popup::menu(&share).gap(crate::theme::POPUP_GAP).show(|ui| {
crate::theme::popup_backing(ui);
ui.set_min_width(190.0);
for (icon, label, action) in [
@@ -1379,7 +1487,7 @@ const ZOOM_BTN: f32 = kit::SEGMENTED_H - 4.0;
fn menu_bar(ui: &mut Ui, ed: &mut Editor, agent: &mut Option<agent::AgentUi>) {
flat_menus(ui);
for (title, entries) in shortcuts::menus() {
ui.menu_button(title, |ui| {
menus::menu_button(ui, title, |ui| {
crate::theme::popup_backing(ui);
menu_entries(ui, ed, &entries);
});
@@ -1453,7 +1561,7 @@ fn flat_menus(ui: &mut Ui) {
fn theme_menu(ui: &mut Ui) {
flat_menus(ui);
ui.menu_button("Theme", |ui| {
menus::menu_button(ui, "Theme", |ui| {
crate::theme::popup_backing(ui);
for c in theme::Choice::ALL {
if ui
@@ -1473,74 +1581,89 @@ 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;
/// How long the File / Edit strip takes to slide open or shut.
const MENU_STRIP_SECS: f32 = 0.22;
/// The strip's width before it has been laid out once (it is measured every frame after).
const MENU_STRIP_GUESS_W: f32 = 300.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. 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 ppp = ui.ctx().pixels_per_point();
let cell = egui::emath::GuiRounding::round_to_pixels(r.shrink2(egui::vec2(2.0, 4.0)), ppp);
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);
}
// Whole-pixel strokes on the pixel grid, so the straight glyphs stay sharp.
let (stroke, px) = crisp::stroke(ppp, WIN_GLYPH_STROKE, ink);
let c = crisp::snap_pos(r.center(), ppp, 2);
let k = (5.0 * ppp).round() / ppp;
match cmd {
WinCmd::Minimize => {
let y = crisp::snap(c.y, ppp, px);
ui.painter().hline(c.x - k..=c.x + k, y, stroke);
}
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);
}
WinCmd::ToggleMaximize => {
ui.painter().rect_stroke(egui::Rect::from_center_size(c, egui::Vec2::splat(k * 2.0)), 0.0, stroke, egui::StrokeKind::Inside);
}
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.
@@ -2171,7 +2294,7 @@ fn layer_menu(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: NodeId) {
item(ui, lock, lock_label, "⇧⌘L", A::ToggleLock);
item(ui, Icon::Search, "Zoom to layer", "⇧2", A::ZoomSelection);
ui.separator();
ui.menu_button("Actions", |ui| {
menus::menu_button(ui, "Actions", |ui| {
crate::theme::popup_backing(ui);
if let Some(a) = actions::menu(ui, ed) {
actions::dispatch(ed, st, a);
+94
View File
@@ -0,0 +1,94 @@
//! The grip between a side panel and the canvas. The panels float with a gap around them, so the
//! grip sits in the middle of that gap: a slim rounded pill that is faint at rest, lengthens and
//! brightens under the pointer and takes the accent colour while dragging. A double click puts the
//! panel back to its default width.
use super::motion;
use crate::theme;
use egui::{Area, Context, CursorIcon, Id, Order, Rect, Sense, Vec2, pos2, vec2};
/// Which side of the canvas the panel is on.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Side {
Left,
Right,
}
/// Width of the grabbable strip, and the pill's thickness at rest and when active.
const STRIP_MIN_W: f32 = 14.0;
const PILL_W: f32 = 4.0;
/// Pill length at rest and when hovered or dragged.
const PILL_H: f32 = 36.0;
const PILL_H_ACTIVE: f32 = 56.0;
/// Opacity of the pill at rest.
const REST_ALPHA: f32 = 0.35;
/// What the grip asked for this frame.
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub struct Grip {
/// Points the panel should grow by (negative: shrink).
pub delta: f32,
/// A double click: back to the default width.
pub reset: bool,
}
/// Where the grip's centre line is, for a panel occupying `panel` with `gap` of empty margin on
/// its canvas side.
pub fn centre_x(panel: Rect, side: Side, gap: f32) -> f32 {
match side {
Side::Left => panel.right() - gap / 2.0,
Side::Right => panel.left() + gap / 2.0,
}
}
/// `width` plus `delta`, kept inside `range`.
pub fn resized(width: f32, delta: f32, range: std::ops::RangeInclusive<f32>) -> f32 {
(width + delta).clamp(*range.start(), (*range.end()).max(*range.start()))
}
/// Draws the grip and reads the drag. `id` must be unique per panel.
pub fn show(ctx: &Context, id: Id, panel: Rect, side: Side, gap: f32) -> Grip {
let strip_w = (gap + 8.0).max(STRIP_MIN_W);
let x = centre_x(panel, side, gap);
let strip = Rect::from_center_size(pos2(x, panel.center().y), vec2(strip_w, panel.height()));
let mut grip = Grip::default();
Area::new(id).order(Order::Foreground).fixed_pos(strip.min).show(ctx, |ui| {
let (rect, resp) = ui.allocate_exact_size(strip.size(), Sense::click_and_drag());
if resp.hovered() || resp.dragged() {
ctx.set_cursor_icon(CursorIcon::ResizeHorizontal);
}
let active = resp.hovered() || resp.dragged();
let t = motion::progress(ctx, id.with("grip"), active, motion::FAST, motion::ease_out_cubic);
let p = theme::pal(ui);
let ink = if resp.dragged() { theme::accent() } else { motion::mix(p.tertiary, p.secondary, t) };
let ink = ink.gamma_multiply(motion::lerp(REST_ALPHA, 1.0, t));
let h = motion::lerp(PILL_H, PILL_H_ACTIVE, t);
let pill = Rect::from_center_size(rect.center(), Vec2::new(PILL_W, h));
ui.painter().rect_filled(pill, PILL_W / 2.0, ink);
let sign = if side == Side::Left { 1.0 } else { -1.0 };
grip.delta = resp.drag_delta().x * sign;
grip.reset = resp.double_clicked();
});
grip
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_grip_sits_in_the_middle_of_the_gap() {
let panel = Rect::from_min_max(pos2(0.0, 0.0), pos2(264.0, 800.0));
assert_eq!(centre_x(panel, Side::Left, 12.0), 258.0);
let right = Rect::from_min_max(pos2(1000.0, 0.0), pos2(1272.0, 800.0));
assert_eq!(centre_x(right, Side::Right, 12.0), 1006.0);
}
#[test]
fn resizing_stays_inside_the_range() {
assert_eq!(resized(250.0, 30.0, 190.0..=400.0), 280.0);
assert_eq!(resized(250.0, -200.0, 190.0..=400.0), 190.0);
assert_eq!(resized(390.0, 50.0, 190.0..=400.0), 400.0);
assert_eq!(resized(250.0, 10.0, 190.0..=100.0), 190.0, "an inverted range doesn't panic");
}
}
+8 -4
View File
@@ -891,6 +891,10 @@ enum ReviewAct {
Undo(String, crate::mcp::diff::NodeDiff),
}
/// Space between a review row's hover tint and its badge and revert button, so they don't sit on
/// the tint's edge.
const DIFF_ROW_PAD: f32 = 10.0;
fn diff_row(
ui: &mut Ui,
page_id: &str,
@@ -910,16 +914,16 @@ fn diff_row(
ui.painter()
.rect_filled(rect, theme::radius_lg(), p.hover.gamma_multiply(hover));
let badge = egui::Rect::from_min_size(
rect.left_center() - egui::vec2(0.0, 14.0),
rect.left_center() + egui::vec2(DIFF_ROW_PAD, -14.0),
egui::vec2(28.0, 28.0),
);
ui.painter()
.rect_filled(badge, theme::radius_md(), tint.gamma_multiply(0.18));
theme::paint_icon(ui.painter(), badge.shrink(6.0), icon, tint);
let x = rect.left() + 40.0;
let x = rect.left() + DIFF_ROW_PAD + 40.0;
let clip = ui.painter().with_clip_rect(egui::Rect::from_min_max(
rect.min,
egui::pos2(rect.right() - 40.0, rect.bottom()),
egui::pos2(rect.right() - DIFF_ROW_PAD - 40.0, rect.bottom()),
));
clip.text(
egui::pos2(x, rect.center().y - 9.0),
@@ -938,7 +942,7 @@ fn diff_row(
// A deleted page has no live node to revert into; "Revert run" re-creates it.
if !deleted {
let btn = egui::Rect::from_min_size(
egui::pos2(rect.right() - 32.0, rect.center().y - 16.0),
egui::pos2(rect.right() - DIFF_ROW_PAD - 32.0, rect.center().y - 16.0),
egui::vec2(32.0, 32.0),
);
let undo = ui
+160 -13
View File
@@ -6,9 +6,16 @@ use crate::theme::{self, Icon, Surface as _};
use egui::{RichText, Ui};
use std::collections::VecDeque;
/// Frame-rate floors to choose from in Settings.
pub const THRESHOLDS: [u32; 5] = [30, 45, 60, 90, 120];
const DEFAULT_MIN_FPS: u32 = 60;
/// Frame-rate caps to choose from in Settings, up to the fastest gaming monitors.
pub const THRESHOLDS: [u32; 9] = [30, 45, 60, 90, 120, 144, 165, 240, 360];
/// The stored rate that means "follow the display": no cap, frames paced by vsync.
pub const AUTO: u32 = 0;
/// 60 Hz, except on macOS where many screens run 120 Hz and the display decides.
const DEFAULT_RATE: u32 = if cfg!(target_os = "macos") { AUTO } else { 60 };
/// What "slow" is measured against when the rate is Auto.
const AUTO_JUDGE_FPS: u32 = 60;
/// A frame this much early still draws: timers wake late and vsync snaps the rest.
const CAP_SLACK: std::time::Duration = std::time::Duration::from_millis(1);
/// Frames kept for the averages (about two seconds of steady animation).
const WINDOW: usize = 120;
/// Frames that must run slow before the flag shows, so one hitch doesn't raise it.
@@ -19,16 +26,28 @@ const MB: f64 = 1024.0 * 1024.0;
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct PerfPrefs {
pub overlay: bool,
/// Most frames drawn per second, [`AUTO`] (0) for the display's own rate. Slow frames
/// are judged against it too.
pub min_fps: u32,
}
impl Default for PerfPrefs {
fn default() -> Self {
PerfPrefs { overlay: false, min_fps: DEFAULT_MIN_FPS }
PerfPrefs { overlay: false, min_fps: DEFAULT_RATE }
}
}
impl PerfPrefs {
/// The rate frames are judged against: the cap, or 60 when there is none.
pub fn judge_fps(self) -> u32 {
if self.min_fps == AUTO { AUTO_JUDGE_FPS } else { self.min_fps }
}
/// The shortest time between two frames, or `None` to draw as fast as vsync allows.
pub fn frame_interval(self) -> Option<std::time::Duration> {
(self.min_fps != AUTO).then(|| std::time::Duration::from_secs_f64(1.0 / f64::from(self.min_fps)))
}
fn path() -> Option<std::path::PathBuf> {
Some(dirs::config_dir()?.join("papercraft").join("performance"))
}
@@ -36,6 +55,10 @@ impl PerfPrefs {
/// `"<overlay 0|1> <min fps>"`.
pub fn load() -> PerfPrefs {
let s = Self::path().and_then(|p| crate::vfs::read_to_string(p).ok()).unwrap_or_default();
Self::parse(&s)
}
fn parse(s: &str) -> PerfPrefs {
let mut it = s.split_whitespace();
let d = PerfPrefs::default();
PerfPrefs {
@@ -78,7 +101,7 @@ impl Perf {
self.ram = rss_bytes().map(|b| b as f64 / MB);
let cpu_now = cpu_seconds();
if let (Some((t, c)), Some(c2)) = (self.last_sample, cpu_now) {
self.cpu = Some(((c2 - c) / (now - t) * 100.0) as f32);
self.cpu = Some(cpu_percent(c2 - c, now - t, cpu_share()));
}
self.last_sample = cpu_now.map(|c| (now, c));
}
@@ -102,7 +125,7 @@ impl Perf {
/// longer than one frame at `min_fps`. Measured on work, not on the frame rate, since the
/// app draws nothing while idle.
pub fn is_slow(&self) -> bool {
let budget = 1000.0 / self.prefs.min_fps as f32;
let budget = 1000.0 / self.prefs.judge_fps() as f32;
self.frames.len() >= SLOW_STREAK && self.frames.iter().rev().take(SLOW_STREAK).all(|f| f.1 > budget)
}
@@ -116,7 +139,7 @@ impl Perf {
pub fn stats(&self, ui: &mut Ui) {
let p = theme::pal(ui);
let (avg, worst) = self.frame_ms();
let budget = 1000.0 / self.prefs.min_fps as f32;
let budget = 1000.0 / self.prefs.judge_fps() as f32;
let warn = |bad: bool| if bad { p.danger } else { p.text };
let na = || "n/a".to_string();
let rows = [
@@ -143,17 +166,20 @@ pub fn settings_page(ui: &mut Ui, perf: &mut Perf) {
kit::toggle(ui, &mut perf.prefs.overlay);
});
ui.add_space(12.0);
kit::section_header(ui, "Warn below", None);
kit::section_header(ui, "Refresh rate", None);
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(6.0, 8.0);
if kit::chip(ui, "Auto", perf.prefs.min_fps == AUTO).on_hover_text("Follow the display: no cap").clicked() {
perf.prefs.min_fps = AUTO;
}
for fps in THRESHOLDS {
if kit::chip(ui, &format!("{fps} FPS"), perf.prefs.min_fps == fps).clicked() {
perf.prefs.min_fps = fps;
}
}
});
ui.label(RichText::new("A badge shows on the canvas while frames take longer than this rate allows.").size(theme::SECONDARY).color(theme::pal(ui).tertiary));
ui.label(RichText::new("The most frames PaperCraft draws per second, so a fast monitor does not spend power on frames you cannot use. Auto follows the display. A badge shows on the canvas while frames take longer than this rate allows.").size(theme::SECONDARY).color(theme::pal(ui).tertiary));
ui.add_space(16.0);
kit::section_header(ui, "Now", None);
ui.add_space(4.0);
@@ -191,7 +217,7 @@ pub fn overlay(ctx: &egui::Context, perf: &mut Perf, canvas: egui::Rect) {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover());
theme::paint_icon(ui.painter(), r, Icon::Zap, p.danger);
ui.label(RichText::new(format!("Slow: {:.0} FPS, below {}", perf.capacity(), perf.prefs.min_fps)).size(theme::SECONDARY).color(p.danger));
ui.label(RichText::new(format!("Slow: {:.0} FPS, below {}", perf.capacity(), perf.prefs.judge_fps())).size(theme::SECONDARY).color(p.danger));
});
})
.response
@@ -209,6 +235,31 @@ pub fn overlay(ctx: &egui::Context, perf: &mut Perf, canvas: egui::Rect) {
}
}
/// How long to wait before the next frame may be drawn, or `None` to draw now: the frame cap
/// `interval` against the time since the last frame started.
pub fn cap_wait(interval: Option<std::time::Duration>, since_last: std::time::Duration) -> Option<std::time::Duration> {
let interval = interval?;
(since_last < interval.saturating_sub(CAP_SLACK)).then(|| interval - since_last)
}
/// CPU use in percent: `cpu_secs` of processor time over `wall_secs`, scaled by `share`.
fn cpu_percent(cpu_secs: f64, wall_secs: f64, share: f64) -> f32 {
if wall_secs <= 0.0 {
return 0.0;
}
(cpu_secs / wall_secs * share * 100.0) as f32
}
/// What one busy core counts for. Task Manager shows the share of the whole CPU (a thread
/// pegging one core of 16 reads 6%); Activity Monitor and top count per core (100% per core).
fn cpu_share() -> f64 {
if cfg!(windows) {
std::thread::available_parallelism().map_or(1.0, |n| 1.0 / n.get() as f64)
} else {
1.0
}
}
/// Resident memory of this process, in bytes: the footprint Activity Monitor shows on macOS.
#[cfg(target_os = "macos")]
fn rss_bytes() -> Option<u64> {
@@ -226,7 +277,31 @@ fn rss_bytes() -> Option<u64> {
Some(pages * unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as u64)
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
/// The private working set: the "Memory" column of Task Manager's Processes tab. The plain
/// working set also counts pages shared with other processes (DLLs), which reads higher.
#[cfg(windows)]
fn rss_bytes() -> Option<u64> {
use windows_sys::Win32::System::ProcessStatus::{
GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX2,
};
use windows_sys::Win32::System::Threading::GetCurrentProcess;
let mut info: PROCESS_MEMORY_COUNTERS_EX2 = unsafe { std::mem::zeroed() };
let cb = std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX2>() as u32;
// SAFETY: the current-process pseudo handle is always valid; `cb` is the size of the
// struct behind the pointer, whose first fields are a PROCESS_MEMORY_COUNTERS.
let ok = unsafe { GetProcessMemoryInfo(GetCurrentProcess(), (&raw mut info).cast::<PROCESS_MEMORY_COUNTERS>(), cb) } != 0;
if ok {
return Some(info.PrivateWorkingSetSize as u64);
}
// Before Windows 10 1809 the larger struct is refused: the plain working set is close.
let mut basic: PROCESS_MEMORY_COUNTERS = unsafe { std::mem::zeroed() };
basic.cb = std::mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32;
// SAFETY: as above, with the basic struct and its own size.
let ok = unsafe { GetProcessMemoryInfo(GetCurrentProcess(), &mut basic, basic.cb) } != 0;
ok.then_some(basic.WorkingSetSize as u64)
}
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
fn rss_bytes() -> Option<u64> {
None
}
@@ -243,7 +318,20 @@ fn cpu_seconds() -> Option<f64> {
Some(secs(u.ru_utime) + secs(u.ru_stime))
}
#[cfg(not(all(unix, not(target_arch = "wasm32"))))]
#[cfg(windows)]
fn cpu_seconds() -> Option<f64> {
use windows_sys::Win32::Foundation::FILETIME;
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetProcessTimes};
let zero = FILETIME { dwLowDateTime: 0, dwHighDateTime: 0 };
let (mut creation, mut exit, mut kernel, mut user) = (zero, zero, zero, zero);
// SAFETY: the current-process pseudo handle is always valid; the four out-pointers are live.
let ok = unsafe { GetProcessTimes(GetCurrentProcess(), &mut creation, &mut exit, &mut kernel, &mut user) } != 0;
// FILETIME counts 100 ns ticks.
let secs = |t: FILETIME| ((u64::from(t.dwHighDateTime) << 32) | u64::from(t.dwLowDateTime)) as f64 / 1e7;
ok.then(|| secs(kernel) + secs(user))
}
#[cfg(not(any(all(unix, not(target_arch = "wasm32")), windows)))]
fn cpu_seconds() -> Option<f64> {
None
}
@@ -270,11 +358,70 @@ mod tests {
assert!(!p.is_slow(), "30 ms fits a 30 FPS floor");
}
#[test]
fn refresh_rate_defaults_to_60_and_offers_high_rate_monitors() {
assert_eq!(PerfPrefs::default().min_fps, DEFAULT_RATE);
if !cfg!(target_os = "macos") {
assert_eq!(DEFAULT_RATE, 60);
}
assert_eq!(PerfPrefs::parse("").min_fps, DEFAULT_RATE, "nothing saved yet");
assert!(THRESHOLDS.contains(&60) && THRESHOLDS.contains(&360));
assert!(THRESHOLDS.windows(2).all(|w| w[0] < w[1]), "ascending, so the chips read in order");
}
#[test]
fn saved_prefs_round_trip_the_chosen_rate() {
let p = PerfPrefs { overlay: true, min_fps: 360 };
let saved = format!("{} {}", u8::from(p.overlay), p.min_fps);
assert!(PerfPrefs::parse(&saved) == p);
assert_eq!(PerfPrefs::parse("1 garbage").min_fps, DEFAULT_RATE, "an unreadable rate falls back to the default");
assert_eq!(PerfPrefs::parse("0 0").min_fps, AUTO, "Auto is a saved choice, not a missing one");
}
#[test]
fn auto_has_no_cap_and_is_judged_at_60() {
let auto = PerfPrefs { overlay: false, min_fps: AUTO };
assert_eq!(auto.frame_interval(), None);
assert_eq!(auto.judge_fps(), 60);
let p = PerfPrefs { overlay: false, min_fps: 240 };
assert_eq!(p.judge_fps(), 240);
let ms = p.frame_interval().unwrap().as_secs_f64() * 1000.0;
assert!((ms - 1000.0 / 240.0).abs() < 1e-6, "{ms}");
}
#[test]
fn cap_waits_out_the_rest_of_the_interval() {
use std::time::Duration as D;
let sixty = Some(D::from_micros(16_667));
assert_eq!(cap_wait(None, D::ZERO), None, "no cap, draw now");
assert_eq!(cap_wait(sixty, D::from_millis(20)), None, "a late frame draws at once");
assert_eq!(cap_wait(sixty, D::from_micros(16_000)), None, "within the slack of the beat");
assert_eq!(cap_wait(sixty, D::from_millis(5)), Some(D::from_micros(11_667)), "too soon: wait for the remainder");
assert_eq!(cap_wait(sixty, D::ZERO), Some(D::from_micros(16_667)));
}
#[test]
fn cpu_percent_is_scaled_to_the_share_one_core_counts_for() {
assert_eq!(cpu_percent(0.5, 1.0, 1.0), 50.0, "half a core, per-core accounting");
assert_eq!(cpu_percent(1.0, 1.0, 0.0625), 6.25, "one core busy of 16 reads 6.25%");
assert_eq!(cpu_percent(1.0, 0.0, 1.0), 0.0, "no elapsed time yields 0, not infinity");
}
#[test]
fn samples_memory() {
let mut p = Perf::default();
p.record(0.0, 1.0);
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[cfg(any(target_os = "macos", target_os = "linux", windows))]
assert!(p.ram.is_some_and(|m| m > 1.0));
}
#[test]
fn cpu_seconds_grow_with_work() {
let Some(before) = cpu_seconds() else { return };
let mut x = 0u64;
while cpu_seconds().is_some_and(|now| now - before < 0.05) {
x = std::hint::black_box(x.wrapping_add(1));
}
assert!(cpu_seconds().is_some_and(|now| now > before));
}
}
+6 -1
View File
@@ -345,9 +345,14 @@ pub fn show(ctx: &egui::Context, st: &mut UiState) {
if st.settings.page == Page::Chat {
content(ui, ctx, st);
} else {
// Inside a horizontal row the area would shrink to the row's
// height: it takes the dialog's.
let height = modal_height(ctx) - 24.0;
egui::ScrollArea::vertical()
.id_salt(("settings-page", st.settings.page as u8))
.max_height(modal_height(ctx) - 24.0)
.auto_shrink([false, false])
.max_height(height)
.min_scrolled_height(height)
.show(ui, |ui| content(ui, ctx, st));
}
});
+3 -3
View File
@@ -715,7 +715,7 @@ fn main_column(
ops.push(Op::Mode(c.id.clone()));
}
let r = kit::button(ui, kit::ButtonKind::Primary, Some(Icon::Plus), "Variable");
egui::Popup::menu(&r).show(|ui| {
egui::Popup::menu(&r).gap(crate::theme::POPUP_GAP).show(|ui| {
crate::theme::popup_backing(ui);
for k in [Kind::Color, Kind::Number, Kind::String, Kind::Boolean] {
if super::icon_menu_item(ui, kind_icon(k), kind_label(k)).clicked() {
@@ -831,7 +831,7 @@ fn table_header(ui: &mut Ui, c: &Collection, mode_w: f32, st: &mut UiState, ops:
return;
}
let r = theme::icon_button(ui, Icon::More, 22.0, false).on_hover_text("Mode options");
egui::Popup::menu(&r).show(|ui| {
egui::Popup::menu(&r).gap(crate::theme::POPUP_GAP).show(|ui| {
crate::theme::popup_backing(ui);
if super::icon_menu_item(ui, Icon::Pen, "Rename").clicked() {
st.variables.mode_edit = Some((c.id.clone(), m.id.clone(), m.name.clone()));
@@ -1021,7 +1021,7 @@ fn value_cell(
if ro {
return;
}
egui::Popup::menu(&anchor)
egui::Popup::menu(&anchor).gap(crate::theme::POPUP_GAP)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.width(280.0)
.show(|ui| {