2 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
27 changed files with 773 additions and 115 deletions
Generated
+1 -1
View File
@@ -3128,7 +3128,7 @@ dependencies = [
[[package]]
name = "papercraft"
version = "0.8.1"
version = "0.8.3"
dependencies = [
"arboard",
"axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "papercraft"
version = "0.8.1"
version = "0.8.3"
edition = "2024"
rust-version = "1.95"
description = "A native infinite-canvas app for planning, working and sketching"
+13
View File
@@ -76,6 +76,19 @@ 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.
+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

+9
View File
@@ -143,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 {
@@ -1168,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;
}
+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),
],
+7 -1
View File
@@ -948,9 +948,15 @@ pub fn modal_backing(ui: &egui::Ui, fill: Color32, radius: u8) {
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);
}
+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);
}
}
+25 -29
View File
@@ -184,37 +184,33 @@ 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);
}
});
});
}
});
+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()
+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);
}
}
+170 -48
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).
@@ -604,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;
@@ -612,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;
@@ -789,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();
@@ -805,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
@@ -926,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,
@@ -959,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
@@ -976,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,
@@ -992,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 {
@@ -1019,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 {
@@ -1039,11 +1118,17 @@ 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 {
@@ -1125,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();
}
@@ -1151,30 +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 {
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();
@@ -1240,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| {
@@ -1267,7 +1372,7 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
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 [
@@ -1382,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);
});
@@ -1456,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
@@ -1477,6 +1582,12 @@ fn theme_menu(ui: &mut Ui) {
}
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;
/// 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.
@@ -1489,27 +1600,38 @@ fn drag_area(ui: &mut Ui, rect: egui::Rect, st: &mut UiState) {
}
}
/// Minimize, maximize and close (right to left from the cursor), `h` tall.
/// 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, hover, tip) in [
(WinCmd::Close, Color32::from_rgb(0xe8, 0x11, 0x23), "Close"),
(WinCmd::ToggleMaximize, p.hover, "Maximize"),
(WinCmd::Minimize, p.hover, "Minimize"),
] {
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 hot = resp.hovered();
if hot {
ui.painter().rect_filled(r.shrink2(egui::vec2(2.0, 4.0)), theme::radius_md(), hover);
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);
}
let ink = if hot && matches!(cmd, WinCmd::Close) { Color32::WHITE } else { p.text };
let stroke = egui::Stroke::new(1.0, ink);
let c = r.center();
let k = 5.0;
// 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 => _ = ui.painter().hline(c.x - k..=c.x + k, c.y, stroke),
WinCmd::Minimize => {
let y = crisp::snap(c.y, ppp, px);
ui.painter().hline(c.x - k..=c.x + k, 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);
}
@@ -2172,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
+110 -18
View File
@@ -6,9 +6,16 @@ use crate::theme::{self, Icon, Surface as _};
use egui::{RichText, Ui};
use std::collections::VecDeque;
/// Refresh rates to choose from in Settings, up to the fastest gaming monitors.
/// 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];
const DEFAULT_MIN_FPS: u32 = 60;
/// 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"))
}
@@ -82,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));
}
@@ -106,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)
}
@@ -120,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 = [
@@ -151,13 +170,16 @@ pub fn settings_page(ui: &mut Ui, perf: &mut Perf) {
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("Frames are judged against this rate: a badge shows on the canvas while they take longer than it allows. Set it to your monitor refresh rate.").size(theme::SECONDARY).color(theme::pal(ui).tertiary));
ui.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);
@@ -195,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
@@ -213,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> {
@@ -230,16 +277,28 @@ fn rss_bytes() -> Option<u64> {
Some(pages * unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as u64)
}
/// The working set, the "Memory" column of Task Manager's Details tab.
/// 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};
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 = unsafe { std::mem::zeroed() };
info.cb = std::mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32;
// SAFETY: the current-process pseudo handle is always valid; `info.cb` is its size.
let ok = unsafe { GetProcessMemoryInfo(GetCurrentProcess(), &mut info, info.cb) } != 0;
ok.then_some(info.WorkingSetSize as u64)
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)))]
@@ -301,8 +360,11 @@ mod tests {
#[test]
fn refresh_rate_defaults_to_60_and_offers_high_rate_monitors() {
assert_eq!(PerfPrefs::default().min_fps, 60);
assert_eq!(PerfPrefs::parse("").min_fps, 60, "nothing saved yet");
assert_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");
}
@@ -312,7 +374,37 @@ mod tests {
let p = PerfPrefs { overlay: true, min_fps: 360 };
let saved = format!("{} {}", u8::from(p.overlay), p.min_fps);
assert!(PerfPrefs::parse(&saved) == p);
assert_eq!(PerfPrefs::parse("1 garbage").min_fps, 60, "an unreadable rate falls back to 60");
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]
+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| {