Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d46a01a10 | |||
| 7a6d664c42 | |||
| 70830478d9 | |||
| c575b7e7a7 | |||
| ec7f1f8a14 |
@@ -3128,7 +3128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "papercraft"
|
||||
version = "0.8.1"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"axum",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "papercraft"
|
||||
version = "0.8.1"
|
||||
version = "0.8.4"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
description = "A native infinite-canvas app for planning, working and sketching"
|
||||
|
||||
@@ -76,6 +76,24 @@ 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.4 (demo video, fresh screenshots)
|
||||
|
||||
* README opens with a time-lapse of designing a habit-tracker app (`docs/demo.gif`, full video `docs/demo.mp4`), and every screenshot is retaken from that design.
|
||||
* Fix: the Performance overlay reads its settings from the project config folder, so `PAPERCRAFT_HOME` isolates it too.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -12,9 +12,17 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/readme/hero.png" alt="PaperCraft with the Redesign page of its own design file open: layers on the left, the canvas in the middle, the inspector on the right" width="100%">
|
||||
<a href="docs/demo.mp4">
|
||||
<img src="docs/demo.gif" alt="PaperCraft's editor breaking apart into its panels on a 3D stage, then chat mode with an agent, annotations pinned to a sign-in screen, the variables and libraries modals, and the home screen" width="100%">
|
||||
</a>
|
||||
<br>
|
||||
<sub>PaperCraft editing its own design file (the Redesign page).</sub>
|
||||
<sub>A 40-second tour: the editor, chat mode, annotations and design systems. <a href="docs/demo.mp4">Watch it in full quality</a> (MP4, 1080p60). Rendered from the app's own design with <a href="tools/showcase/README.md">tools/showcase</a>, not screen-recorded.</sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/readme/hero.png" alt="PaperCraft with the Pulse habit-tracker design open: layers on the left, three annotated screens on the canvas, the inspector on the right" width="100%">
|
||||
<br>
|
||||
<sub>The finished design: three screens, a component library and annotations.</sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -54,11 +62,11 @@ It is an alpha. It is good for building screens, design systems and sketches, an
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/screenshots/readme/variables.png" alt="The Variables modal listing the Colors collection with four Catppuccin modes" width="100%">
|
||||
<br><sub>Variables modal: collections, modes, groups and types.</sub>
|
||||
<img src="docs/screenshots/readme/variables.png" alt="The Variables modal listing the Colors collection with Light and Dark values for brand, foreground and background" width="100%">
|
||||
<br><sub>Variables modal: collections, modes (Light and Dark here), groups and types.</sub>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/screenshots/readme/assets.png" alt="The Assets side view with components grouped into Banner, Button, Checkbox and Chip" width="100%">
|
||||
<img src="docs/screenshots/readme/assets.png" alt="The Assets side view with the Habit row component and the shadcn Button kit" width="100%">
|
||||
<br><sub>Assets view: components grouped by name, from this file and enabled libraries.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -68,12 +76,32 @@ It is an alpha. It is good for building screens, design systems and sketches, an
|
||||
<br><sub>Libraries modal: publish this file, enable libraries from the store.</sub>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/screenshots/readme/agent-review.png" alt="Agent run review: a list of changes on the left and green, yellow and red outlines on the canvas" width="100%">
|
||||
<br><sub>Review of an agent run, with the changes outlined on the canvas.</sub>
|
||||
<img src="docs/screenshots/readme/agent-review.png" alt="The Agent side view: a finished run with Review and Revert, above the list of earlier runs" width="100%">
|
||||
<br><sub>The Agent view: runs, with Review and Revert for the latest.</sub>
|
||||
</td>
|
||||
</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>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/screenshots/readme/annotations.png" alt="The Annotations side view listing five notes grouped by screen, with their category chips" width="100%">
|
||||
<br><sub>Annotations: rule, behaviour, content and data notes, kept with the layers they describe.</sub>
|
||||
</td>
|
||||
<td width="50%" valign="top"></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.
|
||||
@@ -141,7 +169,7 @@ EOF
|
||||
Chat mode (⌘J, or `Design | Chat` in the top bar) puts a conversation next to the canvas. It runs Claude Code and Codex through their CLIs, any agent that speaks the Agent Client Protocol (ACP, such as OpenCode, Gemini CLI or Cursor) found on your PATH, or the Anthropic API with your own key. It shows tool calls, checkpoints and run review in the thread. Chats are saved in the project's `chats/` folder. See [docs/chat.md](docs/chat.md).
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/readme/chat.png" alt="Chat mode: the chat column on the left shows a finished agent run with its Review, Revert and Accept actions, and the canvas on the right" width="100%">
|
||||
<img src="docs/screenshots/readme/chat.png" alt="Chat mode: the chat column on the left with its starter prompts and input, and the annotated habit-tracker screens on the canvas" width="100%">
|
||||
<br>
|
||||
<sub>Chat mode with a finished agent run: review, revert or accept from the thread.</sub>
|
||||
</p>
|
||||
@@ -228,6 +256,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.
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 14 MiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 385 KiB |
|
After Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 355 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 303 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 416 KiB |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub enum UiEvent {
|
||||
TogglePalette,
|
||||
Rename(NodeId),
|
||||
ToggleUi,
|
||||
TogglePanels,
|
||||
ToggleChat,
|
||||
ToggleHelp,
|
||||
ToggleGrid,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,18 +26,30 @@ 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"))
|
||||
Some(crate::project::config_dir()?.join("performance"))
|
||||
}
|
||||
|
||||
/// `"<overlay 0|1> <min fps>"`.
|
||||
@@ -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]
|
||||
|
||||
@@ -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| {
|
||||
|
||||