Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c575b7e7a7 | |||
| ec7f1f8a14 | |||
| 24f6c797f2 | |||
| a2885a3283 | |||
| 0143f1e465 | |||
| dc57f9af20 |
Generated
+3
-2
@@ -784,7 +784,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.marcospaulo.dev.br/masi/craft-libs?rev=22544b6d619d4f16779b8bdfa3f0b642a86fc065#22544b6d619d4f16779b8bdfa3f0b642a86fc065"
|
||||
source = "git+https://git.marcospaulo.dev.br/masi/craft-libs?rev=9c470aa935c4ce48f2ba4f0a17f9410c0c8660fb#9c470aa935c4ce48f2ba4f0a17f9410c0c8660fb"
|
||||
dependencies = [
|
||||
"egui",
|
||||
]
|
||||
@@ -3128,7 +3128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "papercraft"
|
||||
version = "0.8.0"
|
||||
version = "0.8.3"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"axum",
|
||||
@@ -3175,6 +3175,7 @@ dependencies = [
|
||||
"web-sys",
|
||||
"web-time",
|
||||
"wgpu",
|
||||
"windows-sys 0.61.2",
|
||||
"winit",
|
||||
"winresource",
|
||||
"zstd",
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "papercraft"
|
||||
version = "0.8.0"
|
||||
version = "0.8.3"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
description = "A native infinite-canvas app for planning, working and sketching"
|
||||
@@ -44,7 +44,7 @@ env_logger = "0.11"
|
||||
rfd = "0.17"
|
||||
fastrand = "2"
|
||||
dirs = "6"
|
||||
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "22544b6d619d4f16779b8bdfa3f0b642a86fc065" }
|
||||
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "9c470aa935c4ce48f2ba4f0a17f9410c0c8660fb" }
|
||||
i_overlay = "9.0.1"
|
||||
miniz_oxide = "0.9.1"
|
||||
usvg = "0.48"
|
||||
@@ -101,6 +101,10 @@ libc = "0.2"
|
||||
zstd = "0.13"
|
||||
|
||||
# Browser build (`src/web.rs`): canvas + WebGPU/WebGL surface, DOM events instead of winit.
|
||||
# Process RAM and CPU readings for the performance overlay (`ui::perf`).
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_ProcessStatus", "Win32_System_Threading"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wgpu = { version = "30", features = ["webgl"] }
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
+19
@@ -76,6 +76,25 @@ are credited in `THIRD_PARTY.md`.
|
||||
* **Not carried over**: comments, design tokens, AI image generation, video export, teams;
|
||||
moving layers between pages; remote image URLs (see README Known gaps).
|
||||
|
||||
### 2026-10-28: 0.8.3 (menus, panel grips, narrow windows)
|
||||
|
||||
* Menus open 6 px below their button, are at least 180 px wide and show an icon beside each item. Below 1100 px the hamburger opens every menu in one popup.
|
||||
* Side panels: a slim rounded grip in the gap resizes them (double click resets) and `/` hides or shows both. Panels size to their content and step aside when the window is too narrow for the canvas.
|
||||
* Sharper window buttons (pixel-snapped glyphs); the hamburger morphs into a back arrow while the File / Edit strip slides open and shut.
|
||||
* Fixes: lint Fix buttons no longer cover the issue text; review rows keep their badge and revert button off the hover edge; the Export button stays on one line.
|
||||
|
||||
### 2026-10-28: 0.8.2 (refresh-rate cap, accurate RAM/CPU, toolbar)
|
||||
|
||||
* Settings > Performance > Refresh rate is now a real frame cap applied live (30 to 360 Hz, 60 by default); **Auto** draws as fast as the display allows and is the default on macOS.
|
||||
* Performance Monitor: RAM is the private working set and CPU is divided by logical cores on Windows, so both match Task Manager.
|
||||
* Windows and Linux toolbar: a hamburger button shows or hides the File / Edit strip (remembered); the close button uses the theme's danger colour on the acrylic surface, with hover animation and a pointing-hand cursor.
|
||||
|
||||
### 2026-10-28: 0.8.1 (Performance Monitor on Windows, Design tab speed)
|
||||
|
||||
* Performance Monitor: RAM (working set) and CPU now show on Windows; before they were `n/a` there. The overlay and slow-frame badge judge frames against a refresh rate you pick in Settings > Performance: 30 to 360 Hz (144, 165 and 240 included), 60 by default.
|
||||
* Design tab: the lint and the open-feedback scan are kept until the document changes instead of walking every node each frame; a 12.6k-node page no longer drops to a few FPS while the tab is open.
|
||||
* Windows and Linux: window buttons in the toolbar; controls and acrylic come from craft-ui.
|
||||
|
||||
### 2026-10-27: 0.8.0 (screens, packed pages, canvas performance)
|
||||
|
||||
* Screens (`specs/screens`): a frame can be declared a screen or an overlay (drawer, modal, sheet) with `set_screens`, inspector toggles, or Declare N screens on the page. Agent edits to layers hidden behind an overlay or opaque layer in their screen are refused unless `allowHidden`; partly covered ones are reported with their visible share. `get_basic_info` lists screens, `get_node_info` tells a layer's visibility, `lint_design` skips hidden layers, On click lists screens, nested screens get canvas labels.
|
||||
|
||||
@@ -74,6 +74,19 @@ It is an alpha. It is good for building screens, design systems and sketches, an
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/screenshots/readme/toolbar-panels.png" alt="The toolbar with the menu button folded into a back arrow, the File to Theme strip, and the floating left panel with a slim grip beside it" width="100%">
|
||||
<br><sub>Toolbar and panels: the menu button shows or hides the File / Edit strip, and the slim grip between a panel and the canvas resizes it (double click resets). <kbd>/</kbd> hides both side panels.</sub>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/screenshots/readme/menus.png" alt="The Tools menu open below its button, each tool with an icon and its shortcut" width="100%">
|
||||
<br><sub>Menus open a little below their button, are never narrower than 180 px, and carry an icon next to each item.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
**Prototyping and review**
|
||||
* Prototype links between frames and a **Present** mode that follows them with a camera glide.
|
||||
* **Version history:** named and automatic saved versions per page; rename, restore, delete.
|
||||
@@ -228,6 +241,7 @@ The scenario tests drive the editor with synthetic pointer and keyboard events;
|
||||
| PageUp / PageDown | Previous / next page | ⌘\\ | Show / hide UI |
|
||||
| ⌘J | Design / Chat mode | ⌘L | Add selection to chat (Chat mode) |
|
||||
| N | Annotate tool | ⌘⇧A | Annotations: full, badges, hidden |
|
||||
| / | Show / hide the side panels | | |
|
||||
|
||||
Draw a frame around existing shapes and it adopts them; dragging a layer into or out of a frame re-parents it. Hold ⌘ while dragging to suspend snapping.
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
+59
-1
@@ -60,6 +60,9 @@ const AGENT_FRAME: Duration = Duration::from_millis(50);
|
||||
|
||||
pub struct App {
|
||||
gfx: Option<Gfx>,
|
||||
/// The undecorated window edge under the pointer (Linux), which a press resizes.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
resize_edge: Option<winit::window::ResizeDirection>,
|
||||
pub ed: Editor,
|
||||
session: Session,
|
||||
st: UiState,
|
||||
@@ -119,6 +122,8 @@ impl App {
|
||||
let library = crate::library::Library::load(&crate::library::Library::path_in(&store.root));
|
||||
let mut app = App {
|
||||
gfx: None,
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
resize_edge: None,
|
||||
ed: Editor::default(),
|
||||
session: Session::new(store),
|
||||
st: UiState::default(),
|
||||
@@ -138,6 +143,7 @@ impl App {
|
||||
};
|
||||
app.st.library.library = library;
|
||||
app.st.perf.prefs = crate::ui::perf::PerfPrefs::load();
|
||||
app.st.menu_bar_hidden = crate::ui::menu_toggle::load_hidden();
|
||||
app.ed.clipboard_io = crate::io::clipboard::System::new()
|
||||
.map(|c| Box::new(c) as Box<dyn crate::io::clipboard::Provider>);
|
||||
match start {
|
||||
@@ -1038,8 +1044,12 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
.with_corner_preference(CornerPreference::Round)
|
||||
.with_taskbar_icon(icon)
|
||||
};
|
||||
// Linux: no native frame either; the toolbar carries the window buttons and the window
|
||||
// edges resize (`edge_resize`).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let attrs = attrs.with_decorations(false);
|
||||
let window = Arc::new(event_loop.create_window(attrs).expect("create window"));
|
||||
self.st.custom_titlebar = cfg!(windows);
|
||||
self.st.custom_titlebar = cfg!(any(windows, all(unix, not(target_os = "macos"))));
|
||||
window.set_ime_allowed(true);
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
@@ -1119,6 +1129,10 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
log::trace!("event {event:?} -> egui repaint {}", resp.repaint);
|
||||
}
|
||||
let scale = g.window.scale_factor();
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if self.st.custom_titlebar && edge_resize(&g.window, &event, &mut self.resize_edge) {
|
||||
return;
|
||||
}
|
||||
let mut redraw = resp.repaint;
|
||||
let now = self.now();
|
||||
match event {
|
||||
@@ -1155,6 +1169,14 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
// egui-winit reports `repaint: true` for RedrawRequested itself; honouring it
|
||||
// re-requested a redraw every frame (a 100% CPU busy loop). Whether another
|
||||
// frame is needed is decided inside redraw() from egui's repaint delay.
|
||||
// The frame-rate cap (Settings > Performance) holds a frame that comes too soon
|
||||
// until its turn; every redraw request ends up here, so one check covers them all.
|
||||
let since_last = self.last_redraw.map_or(Duration::MAX, |t| t.elapsed());
|
||||
if let Some(wait) = ui::perf::cap_wait(self.st.perf.prefs.frame_interval(), since_last) {
|
||||
let due = Instant::now() + wait;
|
||||
self.next_repaint = Some(self.next_repaint.map_or(due, |r| r.min(due)));
|
||||
return;
|
||||
}
|
||||
self.redraw();
|
||||
return;
|
||||
}
|
||||
@@ -1743,3 +1765,39 @@ mod tests {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// An undecorated window has no resize border (Linux): the pointer within a few pixels of an
|
||||
/// edge shows a resize cursor and a press resizes. True when the event was consumed.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn edge_resize(window: &Window, event: &WindowEvent, edge: &mut Option<winit::window::ResizeDirection>) -> bool {
|
||||
use winit::window::{CursorIcon, ResizeDirection as D};
|
||||
const BORDER: f64 = 6.0;
|
||||
match event {
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
let size = window.inner_size();
|
||||
let b = BORDER * window.scale_factor();
|
||||
let (l, r) = (position.x < b, position.x > f64::from(size.width) - b);
|
||||
let (t, bo) = (position.y < b, position.y > f64::from(size.height) - b);
|
||||
let next = match (l, r, t, bo) {
|
||||
(true, _, true, _) => Some(D::NorthWest),
|
||||
(_, true, true, _) => Some(D::NorthEast),
|
||||
(true, _, _, true) => Some(D::SouthWest),
|
||||
(_, true, _, true) => Some(D::SouthEast),
|
||||
(true, ..) => Some(D::West),
|
||||
(_, true, ..) => Some(D::East),
|
||||
(_, _, true, _) => Some(D::North),
|
||||
(.., true) => Some(D::South),
|
||||
_ => None,
|
||||
};
|
||||
if next != *edge {
|
||||
window.set_cursor(next.map_or(CursorIcon::Default, CursorIcon::from));
|
||||
*edge = next;
|
||||
}
|
||||
false
|
||||
}
|
||||
WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => {
|
||||
edge.is_some_and(|d| window.drag_resize_window(d).is_ok())
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
+29
-200
@@ -469,9 +469,9 @@ pub fn lit(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> egui::Response)
|
||||
let r = add(ui);
|
||||
let dark = ui.visuals().dark_mode;
|
||||
let radius = f32::from(ui.visuals().widgets.inactive.corner_radius.nw);
|
||||
let (top, bottom) = lit_fill(pal(ui).field, dark);
|
||||
let (top, bottom) = craft_ui::material::lit(pal(ui).field, dark);
|
||||
ui.painter()
|
||||
.set(slot, vertical_gradient(r.rect, radius, top, bottom));
|
||||
.set(slot, craft_ui::material::vertical_gradient(r.rect, radius, top, bottom));
|
||||
r
|
||||
}
|
||||
|
||||
@@ -592,10 +592,7 @@ fn style_for(s: &mut egui::Style, dark: bool, p: Palette) {
|
||||
|
||||
/// `over` composited on `base`.
|
||||
pub fn blend(base: Color32, over: Color32) -> Color32 {
|
||||
let a = over.a() as f32 / 255.0;
|
||||
let [or, og, ob, _] = over.to_srgba_unmultiplied();
|
||||
let mix = |b: u8, o: u8| (b as f32 * (1.0 - a) + o as f32 * a).round() as u8;
|
||||
Color32::from_rgb(mix(base.r(), or), mix(base.g(), og), mix(base.b(), ob))
|
||||
craft_ui::material::blend(base, over)
|
||||
}
|
||||
|
||||
/// The appearance setting: follow the system, force light or dark, or a Catppuccin flavour.
|
||||
@@ -924,115 +921,42 @@ pub fn backdrop_fill(p: Palette) -> Color32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared material for the chosen [`Material`] (`craft_ui::material` paints it).
|
||||
fn shared() -> craft_ui::material::Material {
|
||||
if is_acrylic() {
|
||||
craft_ui::material::Material::Acrylic
|
||||
} else {
|
||||
craft_ui::material::Material::Solid
|
||||
}
|
||||
}
|
||||
|
||||
/// The acrylic backdrop at `pos`: the backdrop colour lit by a soft accent glow that falls off
|
||||
/// from the top of the window, a third of the way across.
|
||||
pub fn backdrop_at(p: Palette, screen: Rect, pos: Pos2) -> Color32 {
|
||||
const GLOW_ALPHA: f32 = 46.0;
|
||||
const GLOW_REACH: f32 = 0.8;
|
||||
let centre = pos2(screen.left() + screen.width() * 0.3, screen.top());
|
||||
let reach = screen.width().max(screen.height()) * GLOW_REACH;
|
||||
let d = ((pos - centre).length() / reach).min(1.0);
|
||||
let a = accent();
|
||||
let glow = Color32::from_rgba_unmultiplied(
|
||||
a.r(),
|
||||
a.g(),
|
||||
a.b(),
|
||||
(GLOW_ALPHA * (1.0 - d) * (1.0 - d)) as u8,
|
||||
);
|
||||
blend(p.backdrop, glow)
|
||||
craft_ui::material::backdrop_at(p.backdrop, accent(), screen, pos)
|
||||
}
|
||||
|
||||
/// `rect` filled with the acrylic backdrop: a grid of vertex colours, so the glow is smooth.
|
||||
pub fn backdrop_mesh(p: Palette, screen: Rect, rect: Rect) -> egui::Mesh {
|
||||
const CELLS: u32 = 12;
|
||||
let mut mesh = egui::Mesh::default();
|
||||
for j in 0..=CELLS {
|
||||
for i in 0..=CELLS {
|
||||
let t = vec2(i as f32, j as f32) / CELLS as f32;
|
||||
let pos = rect.min + rect.size() * t;
|
||||
mesh.colored_vertex(pos, backdrop_at(p, screen, pos));
|
||||
}
|
||||
}
|
||||
let row = CELLS + 1;
|
||||
for j in 0..CELLS {
|
||||
for i in 0..CELLS {
|
||||
let k = j * row + i;
|
||||
mesh.add_triangle(k, k + 1, k + row);
|
||||
mesh.add_triangle(k + 1, k + row + 1, k + row);
|
||||
}
|
||||
}
|
||||
mesh
|
||||
}
|
||||
|
||||
/// A rounded rectangle filled with a vertical gradient. A fan from the centre interpolates a
|
||||
/// colour that is linear in y exactly; the caller's edge stroke covers the unsmoothed outline.
|
||||
fn vertical_gradient(rect: Rect, radius: f32, top: Color32, bottom: Color32) -> egui::Mesh {
|
||||
let mut outline = Vec::new();
|
||||
egui::epaint::tessellator::path::rounded_rectangle(&mut outline, rect, radius.into());
|
||||
let at = |y: f32| top.lerp_to_gamma(bottom, ((y - rect.top()) / rect.height()).clamp(0.0, 1.0));
|
||||
let mut mesh = egui::Mesh::default();
|
||||
mesh.colored_vertex(rect.center(), at(rect.center().y));
|
||||
for q in &outline {
|
||||
mesh.colored_vertex(*q, at(q.y));
|
||||
}
|
||||
let n = outline.len() as u32;
|
||||
for i in 0..n {
|
||||
mesh.add_triangle(0, 1 + i, 1 + (i + 1) % n);
|
||||
}
|
||||
mesh
|
||||
}
|
||||
|
||||
fn paint_vertical_gradient(
|
||||
painter: &egui::Painter,
|
||||
rect: Rect,
|
||||
radius: f32,
|
||||
top: Color32,
|
||||
bottom: Color32,
|
||||
) {
|
||||
painter.add(vertical_gradient(rect, radius, top, bottom));
|
||||
}
|
||||
|
||||
/// The fill of an acrylic surface lit from above: `fill` lightened at the top, shaded at the bottom.
|
||||
fn lit_fill(fill: Color32, dark: bool) -> (Color32, Color32) {
|
||||
// A translucent tint (the inspector's fields): brighter at the top, fainter at the bottom.
|
||||
if fill.a() < 255 {
|
||||
let [r, g, b, a] = fill.to_srgba_unmultiplied();
|
||||
let with = |k: f32| Color32::from_rgba_unmultiplied(r, g, b, (f32::from(a) * k).min(255.0) as u8);
|
||||
return (with(1.6), with(0.8));
|
||||
}
|
||||
if dark {
|
||||
(
|
||||
blend(fill, Color32::from_white_alpha(26)),
|
||||
blend(fill, Color32::from_black_alpha(22)),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
blend(fill, Color32::from_white_alpha(140)),
|
||||
blend(fill, Color32::from_black_alpha(4)),
|
||||
)
|
||||
}
|
||||
craft_ui::material::backdrop_mesh(p.backdrop, accent(), screen, rect)
|
||||
}
|
||||
|
||||
/// Under acrylic, lights a modal's own frame (`fill`, `radius`): call first thing in the
|
||||
/// modal's content. Paints over the frame's flat fill at the window's rect from the last frame,
|
||||
/// so nothing has to run after the content.
|
||||
pub fn modal_backing(ui: &egui::Ui, fill: Color32, radius: u8) {
|
||||
if !is_acrylic() {
|
||||
return;
|
||||
}
|
||||
let Some(rect) = ui.ctx().memory(|m| m.area_rect(ui.layer_id().id)) else {
|
||||
return;
|
||||
};
|
||||
let dark = ui.visuals().dark_mode;
|
||||
let painter = ui.painter().with_clip_rect(rect);
|
||||
let (top, bottom) = lit_fill(fill, dark);
|
||||
paint_vertical_gradient(&painter, rect, f32::from(radius), top, bottom);
|
||||
painter.rect_stroke(rect, radius, acrylic_edge(dark), egui::StrokeKind::Inside);
|
||||
craft_ui::material::paint_backing(ui, shared(), fill, radius);
|
||||
}
|
||||
|
||||
/// Narrowest a popup or menu gets, so short lists don't read as a sliver.
|
||||
pub const POPUP_MIN_W: f32 = 180.0;
|
||||
/// Space between a button and the menu it opens.
|
||||
pub const POPUP_GAP: f32 = 6.0;
|
||||
|
||||
/// [`modal_backing`] for a popup, menu or context menu: called first in its closure, it lights
|
||||
/// the popup's frame (its rect from the last frame) under acrylic.
|
||||
pub fn popup_backing(ui: &egui::Ui) {
|
||||
pub fn popup_backing(ui: &mut egui::Ui) {
|
||||
ui.set_min_width(POPUP_MIN_W);
|
||||
let v = ui.visuals();
|
||||
modal_backing(ui, v.window_fill, v.menu_corner_radius.nw);
|
||||
}
|
||||
@@ -1049,61 +973,19 @@ pub fn well_fill(p: Palette, dark: bool) -> Color32 {
|
||||
/// A control's surface (field, chip, select, secondary button, segmented track): lit from above
|
||||
/// with a fine edge under acrylic, flat when solid.
|
||||
pub fn fill_control(ui: &egui::Ui, rect: Rect, radius: u8, fill: Color32) {
|
||||
if !is_acrylic() {
|
||||
ui.painter().rect_filled(rect, radius, fill);
|
||||
return;
|
||||
}
|
||||
let dark = ui.visuals().dark_mode;
|
||||
let (top, bottom) = lit_fill(fill, dark);
|
||||
paint_vertical_gradient(ui.painter(), rect, f32::from(radius), top, bottom);
|
||||
ui.painter()
|
||||
.rect_stroke(rect, radius, acrylic_edge(dark), egui::StrokeKind::Inside);
|
||||
craft_ui::material::paint_control(ui.painter(), shared(), rect, radius, fill, ui.visuals().dark_mode);
|
||||
}
|
||||
|
||||
/// A selected row or item (nav, menu option, layer): an accent wash with an accent edge under
|
||||
/// acrylic, `fill` when solid.
|
||||
pub fn fill_selected(ui: &egui::Ui, rect: Rect, radius: u8, fill: Color32) {
|
||||
if !is_acrylic() {
|
||||
ui.painter().rect_filled(rect, radius, fill);
|
||||
return;
|
||||
}
|
||||
let a = accent();
|
||||
let wash = |alpha| Color32::from_rgba_unmultiplied(a.r(), a.g(), a.b(), alpha);
|
||||
paint_vertical_gradient(
|
||||
ui.painter(),
|
||||
rect,
|
||||
f32::from(radius),
|
||||
blend(fill, wash(56)),
|
||||
blend(fill, wash(20)),
|
||||
);
|
||||
ui.painter().rect_stroke(
|
||||
rect,
|
||||
radius,
|
||||
Stroke::new(1.0, wash(80)),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
craft_ui::material::paint_selected(ui.painter(), shared(), rect, radius, fill, accent());
|
||||
}
|
||||
|
||||
/// An accent surface that is not the primary button (toggle on, active segment, checked box):
|
||||
/// glossy with a light edge under acrylic, no glow, flat when solid.
|
||||
pub fn fill_accent(ui: &egui::Ui, rect: Rect, radius: u8, fill: Color32) {
|
||||
if !is_acrylic() {
|
||||
ui.painter().rect_filled(rect, radius, fill);
|
||||
return;
|
||||
}
|
||||
paint_vertical_gradient(
|
||||
ui.painter(),
|
||||
rect,
|
||||
f32::from(radius),
|
||||
blend(fill, Color32::from_white_alpha(70)),
|
||||
blend(fill, Color32::from_black_alpha(40)),
|
||||
);
|
||||
ui.painter().rect_stroke(
|
||||
rect,
|
||||
radius,
|
||||
Stroke::new(1.0, Color32::from_white_alpha(90)),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
craft_ui::material::paint_accent(ui.painter(), shared(), rect, radius, fill);
|
||||
}
|
||||
|
||||
/// `Frame::show` that, under acrylic, paints the frame's fill lit from above with a fine edge.
|
||||
@@ -1122,25 +1004,7 @@ impl Surface for egui::Frame {
|
||||
ui: &mut egui::Ui,
|
||||
add: impl FnOnce(&mut egui::Ui) -> R,
|
||||
) -> egui::InnerResponse<R> {
|
||||
if !is_acrylic() || self.fill.a() == 0 {
|
||||
return self.show(ui, add);
|
||||
}
|
||||
let dark = ui.visuals().dark_mode;
|
||||
let (fill, radius) = (self.fill, f32::from(self.corner_radius.nw));
|
||||
let slot = ui.painter().add(egui::Shape::Noop);
|
||||
let edge = if self.stroke.width > 0.0 {
|
||||
self.stroke
|
||||
} else {
|
||||
acrylic_edge(dark)
|
||||
};
|
||||
let r = self
|
||||
.fill(Color32::TRANSPARENT)
|
||||
.stroke(edge)
|
||||
.show(ui, add);
|
||||
let (top, bottom) = lit_fill(fill, dark);
|
||||
ui.painter()
|
||||
.set(slot, vertical_gradient(r.response.rect, radius, top, bottom));
|
||||
r
|
||||
craft_ui::material::show_surface(self, ui, shared(), add)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1153,52 +1017,17 @@ pub fn paint_acrylic_panel(
|
||||
fill: Color32,
|
||||
dark: bool,
|
||||
) {
|
||||
let shadow = Shadow {
|
||||
offset: [0, 4],
|
||||
blur: 12,
|
||||
spread: 0,
|
||||
color: Color32::from_black_alpha(if dark { 80 } else { 24 }),
|
||||
};
|
||||
painter.add(shadow.as_shape(rect, CornerRadius::same(radius)));
|
||||
let (top, bottom) = lit_fill(fill, dark);
|
||||
paint_vertical_gradient(painter, rect, f32::from(radius), top, bottom);
|
||||
painter.rect_stroke(rect, radius, acrylic_edge(dark), egui::StrokeKind::Inside);
|
||||
craft_ui::material::paint_panel(painter, rect, radius, fill, dark);
|
||||
}
|
||||
|
||||
/// The fine edge of acrylic surfaces.
|
||||
pub fn acrylic_edge(dark: bool) -> Stroke {
|
||||
Stroke::new(
|
||||
1.0,
|
||||
if dark {
|
||||
Color32::from_white_alpha(22)
|
||||
} else {
|
||||
Color32::from_black_alpha(22)
|
||||
},
|
||||
)
|
||||
craft_ui::material::edge(dark)
|
||||
}
|
||||
|
||||
/// The acrylic primary button: a soft accent glow, a glossy accent gradient and a light edge.
|
||||
pub fn paint_gloss(painter: &egui::Painter, rect: Rect, radius: u8, fill: Color32) {
|
||||
let glow = Shadow {
|
||||
offset: [0, 0],
|
||||
blur: 16,
|
||||
spread: 0,
|
||||
color: Color32::from_rgba_unmultiplied(fill.r(), fill.g(), fill.b(), 70),
|
||||
};
|
||||
painter.add(glow.as_shape(rect, CornerRadius::same(radius)));
|
||||
paint_vertical_gradient(
|
||||
painter,
|
||||
rect,
|
||||
f32::from(radius),
|
||||
blend(fill, Color32::from_white_alpha(70)),
|
||||
blend(fill, Color32::from_black_alpha(40)),
|
||||
);
|
||||
painter.rect_stroke(
|
||||
rect,
|
||||
radius,
|
||||
Stroke::new(1.0, Color32::from_white_alpha(90)),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
craft_ui::material::paint_gloss(painter, rect, radius, fill);
|
||||
}
|
||||
|
||||
/// Fonts plus the light and dark styles. egui picks the one matching the system appearance
|
||||
|
||||
+1
-1
@@ -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
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
+129
-62
@@ -3,10 +3,65 @@
|
||||
|
||||
use super::UiState;
|
||||
use crate::editor::Editor;
|
||||
use crate::mcp::lint::Issue;
|
||||
use crate::model::NodeId;
|
||||
use crate::theme;
|
||||
use egui::{RichText, Ui};
|
||||
|
||||
/// Issues listed in the tab; the rest are only counted.
|
||||
const MAX_ISSUES_SHOWN: usize = 30;
|
||||
/// How often the open-feedback count of the other pages is refreshed, in seconds.
|
||||
const ELSEWHERE_REFRESH_SECS: f64 = 1.0;
|
||||
|
||||
/// What the tab derives from the page. The lint and the comment scan visit every node, far too
|
||||
/// much to redo each frame, so they are kept until the document changes.
|
||||
#[derive(Default)]
|
||||
pub struct Cache {
|
||||
key: Option<(u64, usize)>,
|
||||
issues: Vec<Issue>,
|
||||
open: Vec<(NodeId, String, String)>,
|
||||
elsewhere: Option<(f64, usize)>,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
/// Bring the cache up to date at time `now` (seconds). True when the page was walked again.
|
||||
fn refresh(&mut self, ed: &Editor, now: f64) -> bool {
|
||||
let key = (ed.doc_stamp, ed.active_page);
|
||||
let walked = self.key != Some(key);
|
||||
if walked {
|
||||
self.issues = crate::mcp::lint::lint(&ed.doc, &ed.doc.root, &crate::mcp::lint::DEFAULT_SCALE);
|
||||
let mut open: Vec<(NodeId, String, String)> = ed
|
||||
.doc
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| n.open_comments() > 0)
|
||||
.flat_map(|n| {
|
||||
n.comments
|
||||
.iter()
|
||||
.filter(|c| !c.resolved)
|
||||
.map(|c| (n.id, n.name.clone(), c.text.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
open.sort_by_key(|o| o.0);
|
||||
self.open = open;
|
||||
self.key = Some(key);
|
||||
}
|
||||
if self.elsewhere.is_none_or(|(t, _)| now - t >= ELSEWHERE_REFRESH_SECS) {
|
||||
let count: usize = (0..ed.pages.len())
|
||||
.filter(|i| *i != ed.active_page)
|
||||
.map(|i| ed.page_doc(i).nodes.values().map(|n| n.open_comments()).sum::<usize>())
|
||||
.sum();
|
||||
self.elsewhere = Some((now, count));
|
||||
}
|
||||
walked
|
||||
}
|
||||
|
||||
fn elsewhere(&self) -> usize {
|
||||
self.elsewhere.map_or(0, |(_, n)| n)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
let p = theme::pal(ui);
|
||||
let heading = |ui: &mut Ui, t: &str| {
|
||||
@@ -24,30 +79,10 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
.show(ui, |ui| {
|
||||
crate::theme::figma_fields(ui);
|
||||
// ---- open feedback on this page (and a pointer to other pages)
|
||||
let mut open: Vec<(NodeId, String, String)> = ed
|
||||
.doc
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| n.open_comments() > 0)
|
||||
.flat_map(|n| {
|
||||
n.comments
|
||||
.iter()
|
||||
.filter(|c| !c.resolved)
|
||||
.map(|c| (n.id, n.name.clone(), c.text.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
open.sort_by_key(|o| o.0);
|
||||
let elsewhere: usize = (0..ed.pages.len())
|
||||
.filter(|i| *i != ed.active_page)
|
||||
.map(|i| {
|
||||
ed.page_doc(i)
|
||||
.nodes
|
||||
.values()
|
||||
.map(|n| n.open_comments())
|
||||
.sum::<usize>()
|
||||
})
|
||||
.sum();
|
||||
let now = ui.input(|i| i.time);
|
||||
st.design_cache.refresh(ed, now);
|
||||
let open = st.design_cache.open.clone();
|
||||
let elsewhere = st.design_cache.elsewhere();
|
||||
if !open.is_empty() || elsewhere > 0 {
|
||||
heading(ui, &format!("Feedback · {} open", open.len() + elsewhere));
|
||||
for (id, name, note) in open {
|
||||
@@ -103,19 +138,16 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
st.variables.open = true;
|
||||
}
|
||||
// ---- design lint
|
||||
let issues = crate::mcp::lint::lint(
|
||||
&ed.doc,
|
||||
&ed.doc.root,
|
||||
&crate::mcp::lint::DEFAULT_SCALE,
|
||||
);
|
||||
let total = st.design_cache.issues.len();
|
||||
let shown: Vec<Issue> = st.design_cache.issues.iter().take(MAX_ISSUES_SHOWN).cloned().collect();
|
||||
ui.horizontal(|ui| {
|
||||
let title = if issues.is_empty() {
|
||||
let title = if total == 0 {
|
||||
"Lint · Clean".to_string()
|
||||
} else {
|
||||
format!("Lint · {} issues", issues.len())
|
||||
format!("Lint · {total} issues")
|
||||
};
|
||||
heading(ui, &title);
|
||||
if !issues.is_empty() {
|
||||
if total > 0 {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Zap), "Fix all")
|
||||
.on_hover_text("Auto-fix grid, color variables, text scale, and touch targets")
|
||||
@@ -133,7 +165,7 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
});
|
||||
}
|
||||
});
|
||||
for issue in issues.iter().take(30) {
|
||||
for issue in &shown {
|
||||
let node_name = ed
|
||||
.doc
|
||||
.get(issue.node)
|
||||
@@ -152,38 +184,73 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
| "type-scale"
|
||||
| "touch-target"
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("●").color(dot_color).size(theme::SECONDARY));
|
||||
let r = ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(format!("{node_name}: {}", issue.message))
|
||||
.size(theme::SECONDARY),
|
||||
)
|
||||
.truncate()
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if r.on_hover_text(&issue.fix).clicked() {
|
||||
ed.selection = vec![issue.node];
|
||||
ed.run(crate::editor::shortcuts::Action::ZoomSelection);
|
||||
}
|
||||
if fixable {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Zap), "Fix")
|
||||
.on_hover_text(&issue.fix)
|
||||
.clicked()
|
||||
{
|
||||
let target = issue.node;
|
||||
ed.edit("Fix lint issue", |tx| {
|
||||
crate::mcp::lint::autofix_node(
|
||||
tx,
|
||||
target,
|
||||
&crate::mcp::lint::DEFAULT_SCALE,
|
||||
);
|
||||
});
|
||||
}
|
||||
// The fix button is an icon at the row's end and takes its room first, so the
|
||||
// text beside it truncates instead of running underneath.
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if fixable
|
||||
&& crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Zap), "")
|
||||
.on_hover_text(format!("Fix: {}", issue.fix))
|
||||
.clicked()
|
||||
{
|
||||
let target = issue.node;
|
||||
ed.edit("Fix lint issue", |tx| {
|
||||
crate::mcp::lint::autofix_node(tx, target, &crate::mcp::lint::DEFAULT_SCALE);
|
||||
});
|
||||
}
|
||||
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.label(RichText::new("●").color(dot_color).size(theme::SECONDARY));
|
||||
let r = ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(format!("{node_name}: {}", issue.message)).size(theme::SECONDARY),
|
||||
)
|
||||
.truncate()
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if r.on_hover_text(&issue.fix).clicked() {
|
||||
ed.selection = vec![issue.node];
|
||||
ed.run(crate::editor::shortcuts::Action::ZoomSelection);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::geom::Rect;
|
||||
use crate::model::{Node, NodeKind};
|
||||
|
||||
fn add_rect(ed: &mut Editor) {
|
||||
ed.edit("Add", |tx| {
|
||||
let mut n = Node::new(NodeId(0), NodeKind::Rect, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
n.id = tx.alloc_id();
|
||||
tx.insert(n, None, None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walks_the_page_only_when_the_document_changes() {
|
||||
let mut ed = Editor::default();
|
||||
let mut c = Cache::default();
|
||||
assert!(c.refresh(&ed, 0.0), "first frame walks");
|
||||
assert!(!c.refresh(&ed, 0.1), "an unchanged page is not walked again");
|
||||
assert!(!c.refresh(&ed, 5.0), "time alone does not walk it");
|
||||
add_rect(&mut ed);
|
||||
assert!(c.refresh(&ed, 5.1), "an edit walks it once");
|
||||
assert!(!c.refresh(&ed, 5.2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_pages_are_recounted_on_a_timer_not_a_change() {
|
||||
let ed = Editor::default();
|
||||
let mut c = Cache::default();
|
||||
c.refresh(&ed, 0.0);
|
||||
let first = c.elsewhere.map(|e| e.0);
|
||||
c.refresh(&ed, ELSEWHERE_REFRESH_SECS / 2.0);
|
||||
assert_eq!(c.elsewhere.map(|e| e.0), first, "too soon to recount");
|
||||
c.refresh(&ed, ELSEWHERE_REFRESH_SECS);
|
||||
assert_ne!(c.elsewhere.map(|e| e.0), first, "recounted after the interval");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+29
-191
@@ -394,30 +394,7 @@ pub fn close_button(ui: &mut Ui) -> Response {
|
||||
|
||||
/// A pill toggle switch.
|
||||
pub fn toggle(ui: &mut Ui, on: &mut bool) -> Response {
|
||||
let p = theme::pal(ui);
|
||||
let (rect, mut resp) = ui.allocate_exact_size(vec2(42.0, 24.0), Sense::click());
|
||||
if resp.clicked() {
|
||||
*on = !*on;
|
||||
resp.mark_changed();
|
||||
}
|
||||
let t = crate::ui::motion::progress(
|
||||
ui.ctx(),
|
||||
resp.id.with("toggle"),
|
||||
*on,
|
||||
crate::ui::motion::FAST,
|
||||
crate::ui::motion::ease_out_cubic,
|
||||
);
|
||||
let fill = theme::blend(p.field, theme::accent().gamma_multiply(t));
|
||||
if t > 0.5 {
|
||||
theme::fill_accent(ui, rect, PILL, fill);
|
||||
} else {
|
||||
theme::fill_control(ui, rect, PILL, fill);
|
||||
}
|
||||
let knob_x = egui::lerp((rect.left() + 12.0)..=(rect.right() - 12.0), t);
|
||||
let knob = if t > 0.5 { on_accent(p) } else { p.secondary };
|
||||
ui.painter()
|
||||
.circle_filled(egui::pos2(knob_x, rect.center().y), 8.0, knob);
|
||||
resp
|
||||
craft_ui::controls::toggle(ui, &control_style(ui), on)
|
||||
}
|
||||
|
||||
/// A sidebar-style navigation row: icon, label, optional selected state. Returns whether it was clicked.
|
||||
@@ -482,52 +459,29 @@ pub fn lottie_button(ui: &mut Ui, name: &str, tip: &str, size: f32) -> Response
|
||||
|
||||
/// A rounded checkbox with a label.
|
||||
pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response {
|
||||
craft_ui::controls::checkbox(ui, &control_style(ui), on, label)
|
||||
}
|
||||
|
||||
/// The shared controls' style from the theme (`craft_ui::controls`).
|
||||
pub fn control_style(ui: &Ui) -> craft_ui::controls::ControlStyle {
|
||||
let p = theme::pal(ui);
|
||||
let font = FontId::proportional(theme::BODY);
|
||||
let w = 26.0 + galley_width(ui, label, font.clone()) + 4.0;
|
||||
let (rect, mut resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click());
|
||||
if resp.clicked() {
|
||||
*on = !*on;
|
||||
resp.mark_changed();
|
||||
craft_ui::controls::ControlStyle {
|
||||
material: if theme::is_acrylic() { craft_ui::material::Material::Acrylic } else { craft_ui::material::Material::Solid },
|
||||
field: p.field,
|
||||
hover: p.hover,
|
||||
text: p.text,
|
||||
secondary: p.secondary,
|
||||
accent: theme::accent(),
|
||||
on_accent: on_accent(p),
|
||||
selected: p.field,
|
||||
radius: theme::radius_md(),
|
||||
box_radius: theme::ru(6),
|
||||
field_height: theme::FIELD_H,
|
||||
font: FontId::proportional(theme::BODY),
|
||||
font_strong: theme::semibold(theme::BODY),
|
||||
menu_max: vec2(320.0, 420.0),
|
||||
animation_time: crate::ui::motion::FAST,
|
||||
}
|
||||
let t = crate::ui::motion::progress(
|
||||
ui.ctx(),
|
||||
resp.id.with("check"),
|
||||
*on,
|
||||
crate::ui::motion::FAST,
|
||||
crate::ui::motion::ease_out_cubic,
|
||||
);
|
||||
let hover = crate::ui::motion::hover(ui.ctx(), resp.id.with("check-hover"), &resp);
|
||||
let bx = Rect::from_center_size(
|
||||
egui::pos2(rect.left() + 9.0, rect.center().y),
|
||||
vec2(18.0, 18.0),
|
||||
);
|
||||
let fill = theme::blend(
|
||||
theme::blend(p.field, p.hover.gamma_multiply(hover)),
|
||||
theme::accent().gamma_multiply(t),
|
||||
);
|
||||
if t > 0.5 {
|
||||
theme::fill_accent(ui, bx, crate::theme::ru(6), fill);
|
||||
} else {
|
||||
theme::fill_control(ui, bx, crate::theme::ru(6), fill);
|
||||
}
|
||||
if t > 0.0 {
|
||||
let c = bx.center();
|
||||
let ink = on_accent(p).gamma_multiply(t);
|
||||
let stroke = Stroke::new(2.0, ink);
|
||||
ui.painter()
|
||||
.line_segment([c + vec2(-4.0, 0.5), c + vec2(-1.0, 3.5)], stroke);
|
||||
ui.painter()
|
||||
.line_segment([c + vec2(-1.0, 3.5), c + vec2(4.5, -3.0)], stroke);
|
||||
}
|
||||
ui.painter().text(
|
||||
egui::pos2(rect.left() + 26.0, rect.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
label,
|
||||
font,
|
||||
p.text,
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
/// A rounded dropdown: the current value in a field, a rounded menu of options below it.
|
||||
@@ -538,7 +492,7 @@ pub fn select<T: PartialEq + Copy>(
|
||||
options: &[(T, &str)],
|
||||
width: f32,
|
||||
) -> Option<T> {
|
||||
select_icons(ui, current, options, &[], width)
|
||||
craft_ui::controls::select(ui, &control_style(ui), current, options, width)
|
||||
}
|
||||
|
||||
/// [`select`] with `icons[i]` before option `i`, in the trigger and the menu.
|
||||
@@ -549,129 +503,13 @@ pub fn select_icons<T: PartialEq + Copy>(
|
||||
icons: &[Icon],
|
||||
width: f32,
|
||||
) -> Option<T> {
|
||||
let p = theme::pal(ui);
|
||||
let icon_of = |i: usize| icons.get(i).copied();
|
||||
let indent = if icons.is_empty() { 0.0 } else { 22.0 };
|
||||
let cur_i = options.iter().position(|(o, _)| *o == current);
|
||||
let label = options
|
||||
.iter()
|
||||
.find(|(o, _)| *o == current)
|
||||
.map_or("—", |(_, l)| *l);
|
||||
let w = if width > 0.0 {
|
||||
width
|
||||
} else {
|
||||
ui.available_width()
|
||||
let paint = |painter: &egui::Painter, r: Rect, i: usize, ink: Color32| {
|
||||
if let Some(icon) = icons.get(i) {
|
||||
theme::paint_icon(painter, r, *icon, ink);
|
||||
}
|
||||
};
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w, theme::FIELD_H), Sense::click());
|
||||
let hover = crate::ui::motion::hover(ui.ctx(), resp.id.with("select"), &resp);
|
||||
theme::fill_control(
|
||||
ui,
|
||||
rect,
|
||||
theme::radius_md(),
|
||||
theme::blend(p.field, p.hover.gamma_multiply(hover)),
|
||||
);
|
||||
if let Some(i) = cur_i.and_then(icon_of) {
|
||||
let r = Rect::from_center_size(egui::pos2(rect.left() + 19.0, rect.center().y), vec2(14.0, 14.0));
|
||||
theme::paint_icon(ui.painter(), r, i, p.secondary);
|
||||
}
|
||||
let g = option_label(ui, label, FontId::proportional(theme::BODY), p.text, rect.width() - 12.0 - indent - 28.0);
|
||||
ui.painter().galley(egui::pos2(rect.left() + 12.0 + indent, rect.center().y - g.size().y / 2.0), g, p.text);
|
||||
let ch = Rect::from_center_size(
|
||||
egui::pos2(rect.right() - 14.0, rect.center().y),
|
||||
vec2(14.0, 14.0),
|
||||
);
|
||||
theme::paint_icon(ui.painter(), ch, Icon::ChevronDown, p.secondary);
|
||||
let mut chosen = None;
|
||||
// The menu grows to fit its longest option, up to a cap; longer ones are shortened.
|
||||
let min_w = rect.width().max(150.0);
|
||||
let menu_w = if egui::Popup::is_id_open(ui.ctx(), egui::Popup::default_response_id(&resp)) {
|
||||
let font = theme::semibold(theme::BODY);
|
||||
let widest = options
|
||||
.iter()
|
||||
.map(|(_, l)| ui.painter().layout_no_wrap((*l).to_owned(), font.clone(), p.text).size().x)
|
||||
.fold(0.0, f32::max);
|
||||
(widest + 12.0 + indent + 40.0).clamp(min_w, SELECT_MENU_MAX_W)
|
||||
} else {
|
||||
min_w
|
||||
};
|
||||
egui::Popup::menu(&resp)
|
||||
.width(menu_w)
|
||||
.show(|ui| {
|
||||
crate::theme::popup_backing(ui);
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
egui::ScrollArea::vertical().max_height(SELECT_MENU_MAX_H).show(ui, |ui| {
|
||||
for (k, (o, l)) in options.iter().enumerate() {
|
||||
let (row, r) =
|
||||
ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
|
||||
let on = *o == current;
|
||||
let h = crate::ui::motion::hover(ui.ctx(), r.id.with("opt"), &r);
|
||||
if on {
|
||||
theme::fill_selected(ui, row, theme::radius_md(), p.field);
|
||||
} else if h > 0.0 {
|
||||
ui.painter()
|
||||
.rect_filled(row, theme::radius_md(), p.hover.gamma_multiply(h));
|
||||
}
|
||||
if let Some(i) = icon_of(k) {
|
||||
let ir = Rect::from_center_size(egui::pos2(row.left() + 19.0, row.center().y), vec2(14.0, 14.0));
|
||||
theme::paint_icon(ui.painter(), ir, i, if on { p.text } else { p.secondary });
|
||||
}
|
||||
let font = if on { theme::semibold(theme::BODY) } else { FontId::proportional(theme::BODY) };
|
||||
let g = option_label(ui, l, font, p.text, row.width() - 12.0 - indent - 32.0);
|
||||
let cut = g.text() != *l;
|
||||
ui.painter().galley(egui::pos2(row.left() + 12.0 + indent, row.center().y - g.size().y / 2.0), g, p.text);
|
||||
let r = if cut { r.on_hover_text(*l) } else { r };
|
||||
if on {
|
||||
let c = egui::pos2(row.right() - 16.0, row.center().y);
|
||||
let s = Stroke::new(2.0, theme::accent());
|
||||
ui.painter()
|
||||
.line_segment([c + vec2(-4.0, 0.5), c + vec2(-1.0, 3.5)], s);
|
||||
ui.painter()
|
||||
.line_segment([c + vec2(-1.0, 3.5), c + vec2(4.5, -3.0)], s);
|
||||
}
|
||||
if r.clicked() {
|
||||
chosen = Some(*o);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
chosen.filter(|c| *c != current)
|
||||
}
|
||||
|
||||
const SELECT_MENU_MAX_W: f32 = 320.0;
|
||||
const SELECT_MENU_MAX_H: f32 = 420.0;
|
||||
|
||||
/// One line of `text` in `max_w`. A `Group / Sub / Name` path keeps its last parts and drops
|
||||
/// leading ones (`… / Name`), since the end tells options apart; the path before the name is
|
||||
/// dimmed. Anything still too long ends in "…".
|
||||
fn option_label(ui: &Ui, text: &str, font: FontId, ink: Color32, max_w: f32) -> std::sync::Arc<egui::Galley> {
|
||||
let dim = theme::pal(ui).secondary;
|
||||
let parts: Vec<&str> = text.split(" / ").collect();
|
||||
let job = |skip: usize, cut: bool| {
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
let (prefix, name) = parts.split_at(parts.len() - 1);
|
||||
let mut head = prefix[skip..].join(" / ");
|
||||
if skip > 0 {
|
||||
head = if head.is_empty() { "\u{2026}".into() } else { format!("\u{2026} / {head}") };
|
||||
}
|
||||
if !head.is_empty() {
|
||||
job.append(&format!("{head} / "), 0.0, egui::TextFormat::simple(font.clone(), dim));
|
||||
}
|
||||
job.append(name[0], 0.0, egui::TextFormat::simple(font.clone(), ink));
|
||||
if cut {
|
||||
job.wrap.max_width = max_w;
|
||||
job.wrap.max_rows = 1;
|
||||
job.wrap.overflow_character = Some('\u{2026}');
|
||||
}
|
||||
job
|
||||
};
|
||||
for skip in 0..parts.len() {
|
||||
let g = ui.painter().layout_job(job(skip, false));
|
||||
if g.size().x <= max_w {
|
||||
return g;
|
||||
}
|
||||
}
|
||||
ui.painter().layout_job(job(parts.len() - 1, true))
|
||||
let icon: Option<craft_ui::controls::PaintOptionIcon<'_>> = (!icons.is_empty()).then_some(&paint);
|
||||
craft_ui::controls::select_with_icons(ui, &control_style(ui), current, options, width, icon)
|
||||
}
|
||||
|
||||
/// A rounded slider: rail, accent fill, round knob. Returns whether the value changed.
|
||||
|
||||
@@ -0,0 +1,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
@@ -0,0 +1,148 @@
|
||||
//! The menu bar's building blocks: top-level menus that open a little below their button, and
|
||||
//! items with a leading icon.
|
||||
|
||||
use crate::editor::Tool;
|
||||
use crate::editor::shortcuts::Action;
|
||||
use crate::editor::vector_ops::BoolOp;
|
||||
use crate::theme::{self, Icon};
|
||||
use egui::{Atom, Button, Id, InnerResponse, Popup, UiKind, UiStackInfo, Ui, Vec2};
|
||||
use egui::containers::menu::MenuConfig;
|
||||
|
||||
/// Room kept for an item's icon, with or without one, so the labels line up.
|
||||
const ICON_SLOT: f32 = 16.0;
|
||||
|
||||
/// A menu title that opens its menu [`theme::POPUP_GAP`] below the button, unlike egui's own
|
||||
/// `menu_button`, which butts the menu up against it. Inside a menu use `ui.menu_button`.
|
||||
pub fn menu_button<R>(
|
||||
ui: &mut Ui,
|
||||
title: &str,
|
||||
content: impl FnOnce(&mut Ui) -> R,
|
||||
) -> InnerResponse<Option<R>> {
|
||||
let response = ui.add(Button::new(title));
|
||||
let config = MenuConfig::find(ui);
|
||||
let inner = Popup::menu(&response)
|
||||
.gap(theme::POPUP_GAP)
|
||||
.close_behavior(config.close_behavior)
|
||||
.style(config.style.clone())
|
||||
.info(UiStackInfo::new(UiKind::Menu).with_tag_value(MenuConfig::MENU_CONFIG_TAG, config))
|
||||
.show(content);
|
||||
InnerResponse::new(inner.map(|i| i.inner), response)
|
||||
}
|
||||
|
||||
/// An item: its icon (if it has one), the label, and the shortcut on the right.
|
||||
pub fn item(ui: &mut Ui, label: &str, shortcut: String, icon: Option<Icon>) -> egui::Response {
|
||||
let slot = Id::new(("menu-icon", label));
|
||||
let button = Button::new((Atom::custom(slot, Vec2::splat(ICON_SLOT)), label)).shortcut_text(shortcut);
|
||||
let out = button.atom_ui(ui);
|
||||
if let (Some(icon), Some(rect)) = (icon, out.rect(slot)) {
|
||||
let ink = ui.style().interact(&out.response).fg_stroke.color;
|
||||
theme::paint_icon(ui.painter(), rect, icon, ink);
|
||||
}
|
||||
out.response
|
||||
}
|
||||
|
||||
/// The icon an action's menu item wears, where there is a fitting one.
|
||||
pub fn icon_of(action: Action) -> Option<Icon> {
|
||||
use Action as A;
|
||||
Some(match action {
|
||||
A::Tool(t) => tool_icon(t),
|
||||
A::Undo => Icon::Restore,
|
||||
A::Delete => Icon::Trash,
|
||||
A::SelectAll => Icon::Pointer,
|
||||
A::Group | A::Ungroup | A::WrapInFrame => Icon::Group,
|
||||
A::CreateComponent => Icon::Component,
|
||||
A::DetachInstance => Icon::Instance,
|
||||
A::PlaceImage => Icon::Image,
|
||||
A::ExportPng | A::ExportSvg | A::ExportPdf | A::CopyAsPng | A::CopyAsSvg => Icon::Export,
|
||||
A::Home => Icon::Grid,
|
||||
A::New => Icon::Plus,
|
||||
A::Open | A::SaveAs => Icon::Page,
|
||||
A::Align(0, v) => align_x(v),
|
||||
A::Align(_, v) => align_y(v),
|
||||
A::Distribute(0) => Icon::DistributeH,
|
||||
A::Distribute(_) => Icon::DistributeV,
|
||||
A::ToggleVisible => Icon::Eye,
|
||||
A::ToggleLock => Icon::Lock,
|
||||
A::AddAutoLayout | A::RemoveAutoLayout => Icon::Wrap,
|
||||
A::Boolean(op) => bool_icon(op),
|
||||
A::Bold | A::Italic | A::Underline | A::Strikethrough | A::Rename => Icon::Text,
|
||||
A::ToggleSketch => Icon::Sketch,
|
||||
A::CommandPalette | A::Ask => Icon::Search,
|
||||
A::ToggleUi | A::TogglePanels => Icon::Sidebar,
|
||||
A::ToggleGrid => Icon::Grid,
|
||||
A::ToggleAnnotations => Icon::Note,
|
||||
A::OpenVariables => Icon::Database,
|
||||
A::ToggleHelp => Icon::Question,
|
||||
A::ZoomIn => Icon::Plus,
|
||||
A::ZoomOut => Icon::Minus,
|
||||
A::Zoom100 | A::ZoomFit | A::ZoomSelection => Icon::Scale,
|
||||
A::NextPage | A::PrevPage => Icon::Page,
|
||||
A::ToggleChat => Icon::Zap,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_icon(t: Tool) -> Icon {
|
||||
match t {
|
||||
Tool::Select => Icon::Pointer,
|
||||
Tool::Hand => Icon::Hand,
|
||||
Tool::Frame => Icon::Frame,
|
||||
Tool::Rect => Icon::Rect,
|
||||
Tool::Ellipse => Icon::Ellipse,
|
||||
Tool::Polygon => Icon::Polygon,
|
||||
Tool::Star => Icon::Star,
|
||||
Tool::Diamond => Icon::Diamond,
|
||||
Tool::Line => Icon::Line,
|
||||
Tool::Arrow => Icon::Arrow,
|
||||
Tool::Pen => Icon::Pen,
|
||||
Tool::Text => Icon::Text,
|
||||
Tool::Sticky => Icon::Sticky,
|
||||
Tool::Pencil => Icon::Pencil,
|
||||
Tool::Eraser => Icon::Eraser,
|
||||
Tool::Laser => Icon::Laser,
|
||||
Tool::Annotate => Icon::Note,
|
||||
}
|
||||
}
|
||||
|
||||
fn align_x(at: f64) -> Icon {
|
||||
if at < 0.25 {
|
||||
Icon::AlignLeft
|
||||
} else if at > 0.75 {
|
||||
Icon::AlignRight
|
||||
} else {
|
||||
Icon::AlignHCenter
|
||||
}
|
||||
}
|
||||
|
||||
fn align_y(at: f64) -> Icon {
|
||||
if at < 0.25 {
|
||||
Icon::AlignTop
|
||||
} else if at > 0.75 {
|
||||
Icon::AlignBottom
|
||||
} else {
|
||||
Icon::AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
fn bool_icon(op: BoolOp) -> Icon {
|
||||
match op {
|
||||
BoolOp::Union => Icon::Group,
|
||||
BoolOp::Subtract | BoolOp::Intersect | BoolOp::Exclude => Icon::Corners,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_tool_has_an_icon_and_alignments_follow_their_edge() {
|
||||
for t in Tool::ALL {
|
||||
assert!(icon_of(Action::Tool(t)).is_some(), "{t:?}");
|
||||
}
|
||||
assert_eq!(icon_of(Action::Align(0, 0.0)), Some(Icon::AlignLeft));
|
||||
assert_eq!(icon_of(Action::Align(0, 1.0)), Some(Icon::AlignRight));
|
||||
assert_eq!(icon_of(Action::Align(1, 0.5)), Some(Icon::AlignVCenter));
|
||||
assert_eq!(icon_of(Action::Escape), None);
|
||||
}
|
||||
}
|
||||
+222
-99
@@ -17,6 +17,10 @@ pub mod props_modal;
|
||||
pub mod kit;
|
||||
pub mod library;
|
||||
pub mod lottie_icon;
|
||||
pub mod crisp;
|
||||
pub mod menu_toggle;
|
||||
pub mod menus;
|
||||
pub mod panel_handle;
|
||||
pub mod motion;
|
||||
pub mod pages;
|
||||
pub mod perf;
|
||||
@@ -57,6 +61,8 @@ pub struct UiState {
|
||||
/// The window has native chrome: a system menu bar (so no egui menu) and traffic lights
|
||||
/// inset into the toolbar / sidebar.
|
||||
pub native_chrome: bool,
|
||||
/// The File / Edit / ... strip is folded away (the menu button in the toolbar toggles it).
|
||||
pub menu_bar_hidden: bool,
|
||||
/// Windows: native decorations are off and `title_bar` draws the menu, drag area and
|
||||
/// window buttons.
|
||||
pub custom_titlebar: bool,
|
||||
@@ -96,6 +102,12 @@ pub struct UiState {
|
||||
pub libraries: libraries::LibrariesUi,
|
||||
/// All panels hidden (⌘\\): just the canvas.
|
||||
pub hide_ui: bool,
|
||||
/// The side panels are tucked away (`/`); the toolbar and canvas stay.
|
||||
pub panels_hidden: bool,
|
||||
/// Dragged widths of the left and right panels; `None` keeps the default.
|
||||
pub panel_w: [Option<f32>; 2],
|
||||
/// Narrowest width each side panel's content needs, learned from how far it overflowed.
|
||||
pub panel_need: [f32; 2],
|
||||
/// Present mode: only the canvas, with a presenter bar to step through the page's frames.
|
||||
pub present: bool,
|
||||
/// Frame shown by the presenter (index into the page's top-level frames).
|
||||
@@ -182,6 +194,8 @@ pub struct UiState {
|
||||
pub ann_ui: annotations::AnnotationsUi,
|
||||
/// Frame times, RAM, CPU and GPU for Settings > Performance and its overlay.
|
||||
pub perf: perf::Perf,
|
||||
/// The Design tab's lint and feedback, kept until the document changes.
|
||||
pub design_cache: design::Cache,
|
||||
}
|
||||
|
||||
/// Where a dragged layer lands relative to a row.
|
||||
@@ -602,6 +616,8 @@ pub(crate) const PANEL_GAP: f32 = 12.0;
|
||||
|
||||
/// Canvas width under which the top bar drops the zoom pill (it is also on the keyboard).
|
||||
const TIGHT_BAR_W: f32 = 520.0;
|
||||
/// Room the hamburger, home and mode buttons take on top of the bar's old budget.
|
||||
const TIGHT_EXTRA: f32 = 80.0;
|
||||
/// Tallest the top bar's page menu grows before it scrolls.
|
||||
const PAGE_MENU_MAX_H: f32 = 360.0;
|
||||
|
||||
@@ -610,6 +626,9 @@ const MENU_BAR_W: f32 = 1100.0;
|
||||
|
||||
/// Largest share of the window one side panel may take.
|
||||
const SIDE_MAX_SHARE: f32 = 0.3;
|
||||
/// The canvas, with the toolbar above it, needs at least this much room; below it the side
|
||||
/// panels step aside.
|
||||
const MIN_CANVAS_W: f32 = 600.0;
|
||||
|
||||
/// Bottom strip of the canvas taken by the presenter bar; clicks there never follow links.
|
||||
const PRESENT_BAR_H: f32 = 80.0;
|
||||
@@ -787,7 +806,48 @@ pub(crate) fn is_floating(translucent: bool) -> bool {
|
||||
}
|
||||
|
||||
/// A rounded panel that floats on the backdrop and fills the space it is given.
|
||||
fn floating_panel(ui: &mut Ui, fill: Color32, margin: egui::Margin, add: impl FnOnce(&mut Ui)) {
|
||||
/// The panel width after a grip: `None` (the default) on a double click, else `current` moved by
|
||||
/// the drag. Unmoved panels stay `None` so they keep following the default.
|
||||
/// How much wider than the `room` it was given a panel's content came out this frame. (Widgets
|
||||
/// that overflow grow `max_rect` too, so it is the width that has to be compared.)
|
||||
fn overflow(ui: &Ui, room: f32) -> f32 {
|
||||
(ui.min_rect().width() - room).max(0.0)
|
||||
}
|
||||
|
||||
/// The width a panel has to be to hold its content: what it was plus what overflowed. Kept once
|
||||
/// learned, so the panel does not shrink back and overflow again.
|
||||
fn grown_need(need: f32, width: f32, overflow: f32) -> f32 {
|
||||
if overflow > 0.5 { need.max(width + overflow + PANEL_BREATH) } else { need }
|
||||
}
|
||||
|
||||
/// Spare room added to a learned panel width, so the content does not end on the card's edge.
|
||||
const PANEL_BREATH: f32 = 8.0;
|
||||
|
||||
#[cfg(test)]
|
||||
mod panel_need_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_panel_learns_the_width_its_content_needs_and_keeps_it() {
|
||||
let need = grown_need(0.0, 296.0, 7.0);
|
||||
assert_eq!(need, 296.0 + 7.0 + PANEL_BREATH);
|
||||
assert_eq!(grown_need(need, 320.0, 0.0), need, "no overflow: unchanged");
|
||||
assert_eq!(grown_need(need, 296.0, 3.0), need, "never shrinks");
|
||||
}
|
||||
}
|
||||
|
||||
fn grip_width(saved: Option<f32>, grip: panel_handle::Grip, current: f32, range: std::ops::RangeInclusive<f32>) -> Option<f32> {
|
||||
if grip.reset {
|
||||
return None;
|
||||
}
|
||||
if grip.delta == 0.0 {
|
||||
return saved;
|
||||
}
|
||||
Some(panel_handle::resized(current, grip.delta, range))
|
||||
}
|
||||
|
||||
/// Returns how far the content ran past the card's right edge (0 when it fits).
|
||||
fn floating_panel(ui: &mut Ui, fill: Color32, margin: egui::Margin, add: impl FnOnce(&mut Ui)) -> f32 {
|
||||
let fill = if theme::is_acrylic() {
|
||||
let dark = ui.visuals().dark_mode;
|
||||
let rect = ui.available_rect_before_wrap();
|
||||
@@ -803,8 +863,11 @@ fn floating_panel(ui: &mut Ui, fill: Color32, margin: egui::Margin, add: impl Fn
|
||||
.show(ui, |ui| {
|
||||
ui.set_min_height(ui.available_height());
|
||||
ui.set_width(ui.available_width());
|
||||
let room = ui.available_width();
|
||||
add(ui);
|
||||
});
|
||||
overflow(ui, room)
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
/// The acrylic backdrop around the canvas (which the renderer draws underneath the UI, so it
|
||||
@@ -893,13 +956,6 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
|
||||
// The turn is over: the "Working" pill goes with it, not 6 s after its last tool call.
|
||||
st.agent = None;
|
||||
}
|
||||
if st.custom_titlebar {
|
||||
let p = theme::pal(ui);
|
||||
egui::Panel::top("titlebar")
|
||||
.exact_size(TITLEBAR_H)
|
||||
.frame(egui::Frame::NONE.fill(p.window))
|
||||
.show(ui, |ui| title_bar(ui, ed, st));
|
||||
}
|
||||
if let Some(a) = &mut st.agent_link {
|
||||
a.window(ui.ctx());
|
||||
}
|
||||
@@ -913,6 +969,9 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
|
||||
st.updater.open |= std::mem::take(&mut hv.show_updates);
|
||||
st.updater.show(ui.ctx());
|
||||
}
|
||||
if st.custom_titlebar {
|
||||
home_window_chrome(ui, st);
|
||||
}
|
||||
return egui::Rect::NOTHING;
|
||||
}
|
||||
for ev in std::mem::take(&mut ed.ui_events) {
|
||||
@@ -928,6 +987,7 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
|
||||
}
|
||||
}
|
||||
UiEvent::ToggleUi => st.hide_ui = !st.hide_ui,
|
||||
UiEvent::TogglePanels => st.panels_hidden = !st.panels_hidden,
|
||||
UiEvent::ToggleChat => st.set_chat_mode(!st.chat_mode),
|
||||
UiEvent::ToggleHelp => st.show_help = !st.show_help,
|
||||
UiEvent::ToggleGrid => st.show_grid_off = !st.show_grid_off,
|
||||
@@ -961,6 +1021,8 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
|
||||
// The acrylic backdrop goes under the panels, but needs the canvas rect from after them.
|
||||
let backdrop_slot = ui.painter().add(egui::Shape::Noop);
|
||||
let gap = PANEL_GAP;
|
||||
// Empty margin between a floating panel's card and the canvas; none when they are flush.
|
||||
let grip_gap = if floating { gap } else { 0.0 };
|
||||
let side_frame = |left: i8, right: i8, top: i8, bottom: i8| {
|
||||
if floating {
|
||||
egui::Frame::NONE
|
||||
@@ -978,7 +1040,9 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
|
||||
// Narrow windows keep the canvas usable: the side panels never take more than 30% each.
|
||||
let window_w = ui.max_rect().width();
|
||||
let side_max = |min: f32| (window_w * SIDE_MAX_SHARE).clamp(min, 430.0);
|
||||
if st.chat_mode {
|
||||
if st.panels_hidden {
|
||||
// Only the toolbar and the canvas.
|
||||
} else if st.chat_mode {
|
||||
chat::panel(
|
||||
ui,
|
||||
ed,
|
||||
@@ -994,10 +1058,17 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
|
||||
sidebar_fill,
|
||||
);
|
||||
} else {
|
||||
egui::Panel::left("layers")
|
||||
.resizable(true)
|
||||
.default_size(252.0 + 2.0 * gap)
|
||||
.size_range(190.0..=side_max(190.0))
|
||||
let left_range = 190.0f32.max(st.panel_need[0])..=side_max(190.0).max(st.panel_need[0]);
|
||||
let left_w = panel_handle::resized(st.panel_w[0].unwrap_or(252.0 + 2.0 * gap), 0.0, left_range.clone());
|
||||
let right_range = 230.0f32.max(st.panel_need[1])..=side_max(230.0).max(st.panel_need[1]);
|
||||
let right_w = panel_handle::resized(st.panel_w[1].unwrap_or(272.0 + 2.0 * gap), 0.0, right_range.clone());
|
||||
// A window too narrow for the canvas and both panels gives up the right one, then the left.
|
||||
let show_right = window_w - left_w - right_w >= MIN_CANVAS_W;
|
||||
let show_left = show_right || window_w - left_w >= MIN_CANVAS_W;
|
||||
if show_left {
|
||||
let left = egui::Panel::left("layers")
|
||||
.resizable(false)
|
||||
.exact_size(left_w)
|
||||
.frame(if floating {
|
||||
side_frame(gap as i8, gap as i8, gap as i8, gap as i8)
|
||||
} else {
|
||||
@@ -1021,15 +1092,21 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
|
||||
layers(ui, ed, st)
|
||||
};
|
||||
if floating {
|
||||
floating_panel(ui, sidebar_fill, egui::Margin::symmetric(14, 12), body);
|
||||
floating_panel(ui, sidebar_fill, egui::Margin::symmetric(14, 12), body)
|
||||
} else {
|
||||
let room = ui.available_width();
|
||||
body(ui);
|
||||
overflow(ui, room)
|
||||
}
|
||||
});
|
||||
egui::Panel::right("design")
|
||||
.resizable(true)
|
||||
.default_size(272.0 + 2.0 * gap)
|
||||
.size_range(230.0..=side_max(230.0))
|
||||
st.panel_need[0] = grown_need(st.panel_need[0], left.response.rect.width(), left.inner);
|
||||
let grip = panel_handle::show(ui.ctx(), "layers-grip".into(), left.response.rect, panel_handle::Side::Left, grip_gap);
|
||||
st.panel_w[0] = grip_width(st.panel_w[0], grip, left_w, left_range);
|
||||
}
|
||||
if show_right {
|
||||
let right = egui::Panel::right("design")
|
||||
.resizable(false)
|
||||
.exact_size(right_w)
|
||||
.frame(if floating {
|
||||
side_frame(gap as i8, gap as i8, gap as i8, gap as i8)
|
||||
} else {
|
||||
@@ -1041,19 +1118,23 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
|
||||
if floating {
|
||||
floating_panel(ui, p.inspector, inspector::MARGIN, |ui| {
|
||||
inspector::panel(ui, ed, st)
|
||||
});
|
||||
})
|
||||
} else {
|
||||
inspector::panel(ui, ed, st)
|
||||
let room = ui.available_width();
|
||||
inspector::panel(ui, ed, st);
|
||||
overflow(ui, room)
|
||||
}
|
||||
});
|
||||
st.panel_need[1] = grown_need(st.panel_need[1], right.response.rect.width(), right.inner);
|
||||
let grip = panel_handle::show(ui.ctx(), "design-grip".into(), right.response.rect, panel_handle::Side::Right, grip_gap);
|
||||
st.panel_w[1] = grip_width(st.panel_w[1], grip, right_w, right_range);
|
||||
}
|
||||
}
|
||||
egui::Panel::top("toolbar")
|
||||
.exact_size(if floating {
|
||||
theme::TOOLBAR_H + gap * 2.0
|
||||
} else if st.native_chrome {
|
||||
theme::TOOLBAR_H
|
||||
} else {
|
||||
34.0
|
||||
theme::TOOLBAR_H
|
||||
})
|
||||
.frame(if floating {
|
||||
side_frame(0, 0, gap as i8, gap as i8)
|
||||
@@ -1129,10 +1210,7 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
|
||||
}
|
||||
|
||||
fn menu_item(ui: &mut Ui, ed: &mut Editor, label: &str, action: Action) {
|
||||
if ui
|
||||
.add(egui::Button::new(label).shortcut_text(shortcuts::chord_text(action)))
|
||||
.clicked()
|
||||
{
|
||||
if menus::item(ui, label, shortcuts::chord_text(action), menus::icon_of(action)).clicked() {
|
||||
ed.run(action);
|
||||
ui.close();
|
||||
}
|
||||
@@ -1155,27 +1233,53 @@ fn menu_entries(ui: &mut Ui, ed: &mut Editor, entries: &[shortcuts::MenuEntry])
|
||||
}
|
||||
}
|
||||
|
||||
/// The File / Edit strip. `open` is its eased 0..=1 state: while it moves, the strip is laid out
|
||||
/// at its natural width but only `open` of it is shown (clipped and faded), and the toolbar
|
||||
/// beside it follows that width, so it slides rather than pops.
|
||||
fn menu_strip(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, open: f32) {
|
||||
if open <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let width_id = egui::Id::new("menu-strip-width");
|
||||
let natural: f32 = ui.ctx().data(|d| d.get_temp(width_id)).unwrap_or(MENU_STRIP_GUESS_W);
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(natural * open, ui.available_height()), egui::Sense::hover());
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
child.set_clip_rect(rect.intersect(ui.clip_rect()));
|
||||
child.set_opacity(open);
|
||||
menu_bar(&mut child, ed, &mut st.agent_link);
|
||||
let measured = child.min_rect().width();
|
||||
ui.ctx().data_mut(|d| d.insert_temp(width_id, measured));
|
||||
}
|
||||
|
||||
/// The unified toolbar: document title on the left, zoom and help on the right. Without native
|
||||
/// chrome (Linux) it also carries an egui menu bar.
|
||||
fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
let p = theme::pal(ui);
|
||||
// In a narrow window only the essentials stay: zoom, present and share.
|
||||
let compact = ui.max_rect().width() < 720.0;
|
||||
let tight = ui.max_rect().width() < TIGHT_BAR_W;
|
||||
// The window buttons take 3 x 46 px, so the bar gives up its extras that much sooner.
|
||||
let buttons_w = if st.custom_titlebar { 3.0 * WIN_BTN_W } else { 0.0 };
|
||||
let compact = ui.max_rect().width() < 720.0 + buttons_w;
|
||||
let tight = ui.max_rect().width() < TIGHT_BAR_W + buttons_w + TIGHT_EXTRA;
|
||||
if st.custom_titlebar {
|
||||
drag_area(ui, ui.max_rect(), st);
|
||||
}
|
||||
ui.horizontal_centered(|ui| {
|
||||
if !st.native_chrome && !st.custom_titlebar {
|
||||
if !st.native_chrome {
|
||||
if ui.max_rect().width() < MENU_BAR_W {
|
||||
// Not enough room for every menu: they fold into one.
|
||||
ui.scope(|ui| {
|
||||
flat_menus(ui);
|
||||
let menu = ui.menu_button("Menu", |ui| {
|
||||
crate::theme::popup_backing(ui);
|
||||
flat_menu(ui, ed)
|
||||
});
|
||||
let _ = menu;
|
||||
// Not enough room for every menu: the button opens them all in one popup.
|
||||
let menu = menu_toggle::menu_button(ui);
|
||||
egui::Popup::menu(&menu).gap(theme::POPUP_GAP).show(|ui| {
|
||||
theme::popup_backing(ui);
|
||||
flat_menu(ui, ed)
|
||||
});
|
||||
} else {
|
||||
ui.scope(|ui| menu_bar(ui, ed, &mut st.agent_link));
|
||||
let open = motion::progress(ui.ctx(), "menu-strip", !st.menu_bar_hidden, MENU_STRIP_SECS, motion::ease_in_out_cubic);
|
||||
if menu_toggle::button(ui, open).clicked() {
|
||||
st.menu_bar_hidden = !st.menu_bar_hidden;
|
||||
menu_toggle::save_hidden(st.menu_bar_hidden);
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
menu_strip(ui, ed, st, open);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.separator();
|
||||
@@ -1241,7 +1345,7 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
.inner;
|
||||
if many {
|
||||
let page = page.on_hover_text("Switch page").on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
egui::Popup::menu(&page).show(|ui| {
|
||||
egui::Popup::menu(&page).gap(crate::theme::POPUP_GAP).show(|ui| {
|
||||
crate::theme::popup_backing(ui);
|
||||
ui.set_min_width(180.0);
|
||||
egui::ScrollArea::vertical().max_height(PAGE_MENU_MAX_H).show(ui, |ui| {
|
||||
@@ -1263,8 +1367,12 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
agent_pill(ui, st, !tight, right_w);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
if st.custom_titlebar {
|
||||
window_buttons(ui, st, theme::TOOLBAR_H);
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
let share = kit::button_compact(ui, kit::ButtonKind::Primary, Some(Icon::Export), "Share");
|
||||
egui::Popup::menu(&share).show(|ui| {
|
||||
egui::Popup::menu(&share).gap(crate::theme::POPUP_GAP).show(|ui| {
|
||||
crate::theme::popup_backing(ui);
|
||||
ui.set_min_width(190.0);
|
||||
for (icon, label, action) in [
|
||||
@@ -1379,7 +1487,7 @@ const ZOOM_BTN: f32 = kit::SEGMENTED_H - 4.0;
|
||||
fn menu_bar(ui: &mut Ui, ed: &mut Editor, agent: &mut Option<agent::AgentUi>) {
|
||||
flat_menus(ui);
|
||||
for (title, entries) in shortcuts::menus() {
|
||||
ui.menu_button(title, |ui| {
|
||||
menus::menu_button(ui, title, |ui| {
|
||||
crate::theme::popup_backing(ui);
|
||||
menu_entries(ui, ed, &entries);
|
||||
});
|
||||
@@ -1453,7 +1561,7 @@ fn flat_menus(ui: &mut Ui) {
|
||||
|
||||
fn theme_menu(ui: &mut Ui) {
|
||||
flat_menus(ui);
|
||||
ui.menu_button("Theme", |ui| {
|
||||
menus::menu_button(ui, "Theme", |ui| {
|
||||
crate::theme::popup_backing(ui);
|
||||
for c in theme::Choice::ALL {
|
||||
if ui
|
||||
@@ -1473,74 +1581,89 @@ fn theme_menu(ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
const TITLEBAR_H: f32 = 32.0;
|
||||
const WIN_BTN_W: f32 = 46.0;
|
||||
/// Stroke of the minimize, maximize and close glyphs: the weight of the toolbar's icons.
|
||||
const WIN_GLYPH_STROKE: f32 = 1.25;
|
||||
/// How long the File / Edit strip takes to slide open or shut.
|
||||
const MENU_STRIP_SECS: f32 = 0.22;
|
||||
/// The strip's width before it has been laid out once (it is measured every frame after).
|
||||
const MENU_STRIP_GUESS_W: f32 = 300.0;
|
||||
|
||||
/// Custom window chrome: menus on the left, empty space drags the window, buttons on the right.
|
||||
fn title_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
let p = theme::pal(ui);
|
||||
let rect = ui.max_rect();
|
||||
let drag = ui.interact(rect, ui.id().with("drag"), egui::Sense::click_and_drag());
|
||||
/// Without native chrome (Windows, Linux) the toolbar is the title bar: its empty space drags
|
||||
/// the window and a double click maximizes. Widgets added after this take the clicks.
|
||||
fn drag_area(ui: &mut Ui, rect: egui::Rect, st: &mut UiState) {
|
||||
let drag = ui.interact(rect, ui.id().with("window-drag"), egui::Sense::click_and_drag());
|
||||
if drag.double_clicked() {
|
||||
st.win_cmd = Some(WinCmd::ToggleMaximize);
|
||||
} else if drag.drag_started() {
|
||||
st.win_cmd = Some(WinCmd::Drag);
|
||||
}
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.add_space(8.0);
|
||||
if st.home.is_some() {
|
||||
theme_menu(ui);
|
||||
} else {
|
||||
menu_bar(ui, ed, &mut st.agent_link);
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
for (cmd, hover) in [
|
||||
(WinCmd::Close, Color32::from_rgb(0xe8, 0x11, 0x23)),
|
||||
(WinCmd::ToggleMaximize, p.hover),
|
||||
(WinCmd::Minimize, p.hover),
|
||||
] {
|
||||
let (r, resp) =
|
||||
ui.allocate_exact_size(egui::vec2(WIN_BTN_W, TITLEBAR_H), egui::Sense::click());
|
||||
let hot = resp.hovered();
|
||||
if hot {
|
||||
ui.painter().rect_filled(r, 0.0, hover);
|
||||
}
|
||||
|
||||
/// Minimize, maximize and close (right to left from the cursor), `h` tall. They follow the
|
||||
/// toolbar's other buttons: the animated hover tint and the pointing hand come from
|
||||
/// `motion::hover`, and Close lights up in the theme's danger colour on the accent surface.
|
||||
fn window_buttons(ui: &mut Ui, st: &mut UiState, h: f32) {
|
||||
let p = theme::pal(ui);
|
||||
ui.scope(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
for (cmd, tip) in [(WinCmd::Close, "Close"), (WinCmd::ToggleMaximize, "Maximize"), (WinCmd::Minimize, "Minimize")] {
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(WIN_BTN_W, h), egui::Sense::click());
|
||||
let hover = motion::hover(ui.ctx(), resp.id.with("win-btn"), &resp);
|
||||
let down = resp.is_pointer_button_down_on();
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let cell = egui::emath::GuiRounding::round_to_pixels(r.shrink2(egui::vec2(2.0, 4.0)), ppp);
|
||||
let closing = matches!(cmd, WinCmd::Close);
|
||||
let mut ink = p.text;
|
||||
if closing && (hover > 0.0 || down) {
|
||||
let fill = if down { p.danger.gamma_multiply(0.8) } else { p.danger.gamma_multiply(hover) };
|
||||
theme::fill_accent(ui, cell, theme::radius_md(), fill);
|
||||
ink = kit::ink_on(p.danger);
|
||||
} else if !closing {
|
||||
let fill = if down { p.pressed } else { p.hover.gamma_multiply(hover) };
|
||||
ui.painter().rect_filled(cell, theme::radius_md(), fill);
|
||||
}
|
||||
// Whole-pixel strokes on the pixel grid, so the straight glyphs stay sharp.
|
||||
let (stroke, px) = crisp::stroke(ppp, WIN_GLYPH_STROKE, ink);
|
||||
let c = crisp::snap_pos(r.center(), ppp, 2);
|
||||
let k = (5.0 * ppp).round() / ppp;
|
||||
match cmd {
|
||||
WinCmd::Minimize => {
|
||||
let y = crisp::snap(c.y, ppp, px);
|
||||
ui.painter().hline(c.x - k..=c.x + k, y, stroke);
|
||||
}
|
||||
let ink = if hot && matches!(cmd, WinCmd::Close) {
|
||||
Color32::WHITE
|
||||
} else {
|
||||
p.text
|
||||
};
|
||||
let stroke = egui::Stroke::new(1.0, ink);
|
||||
let c = r.center();
|
||||
let h = 5.0;
|
||||
match cmd {
|
||||
WinCmd::Minimize => {
|
||||
ui.painter().hline(c.x - h..=c.x + h, c.y, stroke);
|
||||
}
|
||||
WinCmd::ToggleMaximize => {
|
||||
ui.painter().rect_stroke(
|
||||
egui::Rect::from_center_size(c, egui::Vec2::splat(h * 2.0)),
|
||||
0.0,
|
||||
stroke,
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
let d = egui::vec2(h, h);
|
||||
let e = egui::vec2(h, -h);
|
||||
ui.painter().line_segment([c - d, c + d], stroke);
|
||||
ui.painter().line_segment([c - e, c + e], stroke);
|
||||
}
|
||||
WinCmd::ToggleMaximize => {
|
||||
ui.painter().rect_stroke(egui::Rect::from_center_size(c, egui::Vec2::splat(k * 2.0)), 0.0, stroke, egui::StrokeKind::Inside);
|
||||
}
|
||||
if resp.clicked() {
|
||||
st.win_cmd = Some(cmd);
|
||||
_ => {
|
||||
let (d, e) = (egui::vec2(k, k), egui::vec2(k, -k));
|
||||
ui.painter().line_segment([c - d, c + d], stroke);
|
||||
ui.painter().line_segment([c - e, c + e], stroke);
|
||||
}
|
||||
}
|
||||
});
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
st.win_cmd = Some(cmd);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The home screen has no toolbar: its top strip drags the window, the buttons sit top right.
|
||||
fn home_window_chrome(ui: &mut Ui, st: &mut UiState) {
|
||||
let screen = ui.ctx().content_rect();
|
||||
let strip = egui::Rect::from_min_size(screen.min, egui::vec2(screen.width(), theme::TOOLBAR_H));
|
||||
egui::Area::new("home-window-drag".into()).order(egui::Order::Background).fixed_pos(strip.min).show(ui.ctx(), |ui| {
|
||||
drag_area(ui, strip, st);
|
||||
});
|
||||
egui::Area::new("home-window-buttons".into())
|
||||
.order(egui::Order::Foreground)
|
||||
.pivot(egui::Align2::RIGHT_TOP)
|
||||
.fixed_pos(screen.right_top())
|
||||
.show(ui.ctx(), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| window_buttons(ui, st, theme::TOOLBAR_H));
|
||||
});
|
||||
}
|
||||
|
||||
/// The sidebar views as a strip that fills the sidebar: layers, library, design (variables, modes,
|
||||
/// components), history, comments and the agent log. Each tab grows with the width; when there is
|
||||
/// room it also shows its name.
|
||||
@@ -2171,7 +2294,7 @@ fn layer_menu(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: NodeId) {
|
||||
item(ui, lock, lock_label, "⇧⌘L", A::ToggleLock);
|
||||
item(ui, Icon::Search, "Zoom to layer", "⇧2", A::ZoomSelection);
|
||||
ui.separator();
|
||||
ui.menu_button("Actions", |ui| {
|
||||
menus::menu_button(ui, "Actions", |ui| {
|
||||
crate::theme::popup_backing(ui);
|
||||
if let Some(a) = actions::menu(ui, ed) {
|
||||
actions::dispatch(ed, st, a);
|
||||
|
||||
@@ -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
@@ -891,6 +891,10 @@ enum ReviewAct {
|
||||
Undo(String, crate::mcp::diff::NodeDiff),
|
||||
}
|
||||
|
||||
/// Space between a review row's hover tint and its badge and revert button, so they don't sit on
|
||||
/// the tint's edge.
|
||||
const DIFF_ROW_PAD: f32 = 10.0;
|
||||
|
||||
fn diff_row(
|
||||
ui: &mut Ui,
|
||||
page_id: &str,
|
||||
@@ -910,16 +914,16 @@ fn diff_row(
|
||||
ui.painter()
|
||||
.rect_filled(rect, theme::radius_lg(), p.hover.gamma_multiply(hover));
|
||||
let badge = egui::Rect::from_min_size(
|
||||
rect.left_center() - egui::vec2(0.0, 14.0),
|
||||
rect.left_center() + egui::vec2(DIFF_ROW_PAD, -14.0),
|
||||
egui::vec2(28.0, 28.0),
|
||||
);
|
||||
ui.painter()
|
||||
.rect_filled(badge, theme::radius_md(), tint.gamma_multiply(0.18));
|
||||
theme::paint_icon(ui.painter(), badge.shrink(6.0), icon, tint);
|
||||
let x = rect.left() + 40.0;
|
||||
let x = rect.left() + DIFF_ROW_PAD + 40.0;
|
||||
let clip = ui.painter().with_clip_rect(egui::Rect::from_min_max(
|
||||
rect.min,
|
||||
egui::pos2(rect.right() - 40.0, rect.bottom()),
|
||||
egui::pos2(rect.right() - DIFF_ROW_PAD - 40.0, rect.bottom()),
|
||||
));
|
||||
clip.text(
|
||||
egui::pos2(x, rect.center().y - 9.0),
|
||||
@@ -938,7 +942,7 @@ fn diff_row(
|
||||
// A deleted page has no live node to revert into; "Revert run" re-creates it.
|
||||
if !deleted {
|
||||
let btn = egui::Rect::from_min_size(
|
||||
egui::pos2(rect.right() - 32.0, rect.center().y - 16.0),
|
||||
egui::pos2(rect.right() - DIFF_ROW_PAD - 32.0, rect.center().y - 16.0),
|
||||
egui::vec2(32.0, 32.0),
|
||||
);
|
||||
let undo = ui
|
||||
|
||||
+160
-13
@@ -6,9 +6,16 @@ use crate::theme::{self, Icon, Surface as _};
|
||||
use egui::{RichText, Ui};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Frame-rate floors to choose from in Settings.
|
||||
pub const THRESHOLDS: [u32; 5] = [30, 45, 60, 90, 120];
|
||||
const DEFAULT_MIN_FPS: u32 = 60;
|
||||
/// Frame-rate caps to choose from in Settings, up to the fastest gaming monitors.
|
||||
pub const THRESHOLDS: [u32; 9] = [30, 45, 60, 90, 120, 144, 165, 240, 360];
|
||||
/// The stored rate that means "follow the display": no cap, frames paced by vsync.
|
||||
pub const AUTO: u32 = 0;
|
||||
/// 60 Hz, except on macOS where many screens run 120 Hz and the display decides.
|
||||
const DEFAULT_RATE: u32 = if cfg!(target_os = "macos") { AUTO } else { 60 };
|
||||
/// What "slow" is measured against when the rate is Auto.
|
||||
const AUTO_JUDGE_FPS: u32 = 60;
|
||||
/// A frame this much early still draws: timers wake late and vsync snaps the rest.
|
||||
const CAP_SLACK: std::time::Duration = std::time::Duration::from_millis(1);
|
||||
/// Frames kept for the averages (about two seconds of steady animation).
|
||||
const WINDOW: usize = 120;
|
||||
/// Frames that must run slow before the flag shows, so one hitch doesn't raise it.
|
||||
@@ -19,16 +26,28 @@ const MB: f64 = 1024.0 * 1024.0;
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PerfPrefs {
|
||||
pub overlay: bool,
|
||||
/// Most frames drawn per second, [`AUTO`] (0) for the display's own rate. Slow frames
|
||||
/// are judged against it too.
|
||||
pub min_fps: u32,
|
||||
}
|
||||
|
||||
impl Default for PerfPrefs {
|
||||
fn default() -> Self {
|
||||
PerfPrefs { overlay: false, min_fps: DEFAULT_MIN_FPS }
|
||||
PerfPrefs { overlay: false, min_fps: DEFAULT_RATE }
|
||||
}
|
||||
}
|
||||
|
||||
impl PerfPrefs {
|
||||
/// The rate frames are judged against: the cap, or 60 when there is none.
|
||||
pub fn judge_fps(self) -> u32 {
|
||||
if self.min_fps == AUTO { AUTO_JUDGE_FPS } else { self.min_fps }
|
||||
}
|
||||
|
||||
/// The shortest time between two frames, or `None` to draw as fast as vsync allows.
|
||||
pub fn frame_interval(self) -> Option<std::time::Duration> {
|
||||
(self.min_fps != AUTO).then(|| std::time::Duration::from_secs_f64(1.0 / f64::from(self.min_fps)))
|
||||
}
|
||||
|
||||
fn path() -> Option<std::path::PathBuf> {
|
||||
Some(dirs::config_dir()?.join("papercraft").join("performance"))
|
||||
}
|
||||
@@ -36,6 +55,10 @@ impl PerfPrefs {
|
||||
/// `"<overlay 0|1> <min fps>"`.
|
||||
pub fn load() -> PerfPrefs {
|
||||
let s = Self::path().and_then(|p| crate::vfs::read_to_string(p).ok()).unwrap_or_default();
|
||||
Self::parse(&s)
|
||||
}
|
||||
|
||||
fn parse(s: &str) -> PerfPrefs {
|
||||
let mut it = s.split_whitespace();
|
||||
let d = PerfPrefs::default();
|
||||
PerfPrefs {
|
||||
@@ -78,7 +101,7 @@ impl Perf {
|
||||
self.ram = rss_bytes().map(|b| b as f64 / MB);
|
||||
let cpu_now = cpu_seconds();
|
||||
if let (Some((t, c)), Some(c2)) = (self.last_sample, cpu_now) {
|
||||
self.cpu = Some(((c2 - c) / (now - t) * 100.0) as f32);
|
||||
self.cpu = Some(cpu_percent(c2 - c, now - t, cpu_share()));
|
||||
}
|
||||
self.last_sample = cpu_now.map(|c| (now, c));
|
||||
}
|
||||
@@ -102,7 +125,7 @@ impl Perf {
|
||||
/// longer than one frame at `min_fps`. Measured on work, not on the frame rate, since the
|
||||
/// app draws nothing while idle.
|
||||
pub fn is_slow(&self) -> bool {
|
||||
let budget = 1000.0 / self.prefs.min_fps as f32;
|
||||
let budget = 1000.0 / self.prefs.judge_fps() as f32;
|
||||
self.frames.len() >= SLOW_STREAK && self.frames.iter().rev().take(SLOW_STREAK).all(|f| f.1 > budget)
|
||||
}
|
||||
|
||||
@@ -116,7 +139,7 @@ impl Perf {
|
||||
pub fn stats(&self, ui: &mut Ui) {
|
||||
let p = theme::pal(ui);
|
||||
let (avg, worst) = self.frame_ms();
|
||||
let budget = 1000.0 / self.prefs.min_fps as f32;
|
||||
let budget = 1000.0 / self.prefs.judge_fps() as f32;
|
||||
let warn = |bad: bool| if bad { p.danger } else { p.text };
|
||||
let na = || "n/a".to_string();
|
||||
let rows = [
|
||||
@@ -143,17 +166,20 @@ pub fn settings_page(ui: &mut Ui, perf: &mut Perf) {
|
||||
kit::toggle(ui, &mut perf.prefs.overlay);
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
kit::section_header(ui, "Warn below", None);
|
||||
kit::section_header(ui, "Refresh rate", None);
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(6.0, 8.0);
|
||||
if kit::chip(ui, "Auto", perf.prefs.min_fps == AUTO).on_hover_text("Follow the display: no cap").clicked() {
|
||||
perf.prefs.min_fps = AUTO;
|
||||
}
|
||||
for fps in THRESHOLDS {
|
||||
if kit::chip(ui, &format!("{fps} FPS"), perf.prefs.min_fps == fps).clicked() {
|
||||
perf.prefs.min_fps = fps;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.label(RichText::new("A badge shows on the canvas while frames take longer than this rate allows.").size(theme::SECONDARY).color(theme::pal(ui).tertiary));
|
||||
ui.label(RichText::new("The most frames PaperCraft draws per second, so a fast monitor does not spend power on frames you cannot use. Auto follows the display. A badge shows on the canvas while frames take longer than this rate allows.").size(theme::SECONDARY).color(theme::pal(ui).tertiary));
|
||||
ui.add_space(16.0);
|
||||
kit::section_header(ui, "Now", None);
|
||||
ui.add_space(4.0);
|
||||
@@ -191,7 +217,7 @@ pub fn overlay(ctx: &egui::Context, perf: &mut Perf, canvas: egui::Rect) {
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover());
|
||||
theme::paint_icon(ui.painter(), r, Icon::Zap, p.danger);
|
||||
ui.label(RichText::new(format!("Slow: {:.0} FPS, below {}", perf.capacity(), perf.prefs.min_fps)).size(theme::SECONDARY).color(p.danger));
|
||||
ui.label(RichText::new(format!("Slow: {:.0} FPS, below {}", perf.capacity(), perf.prefs.judge_fps())).size(theme::SECONDARY).color(p.danger));
|
||||
});
|
||||
})
|
||||
.response
|
||||
@@ -209,6 +235,31 @@ pub fn overlay(ctx: &egui::Context, perf: &mut Perf, canvas: egui::Rect) {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long to wait before the next frame may be drawn, or `None` to draw now: the frame cap
|
||||
/// `interval` against the time since the last frame started.
|
||||
pub fn cap_wait(interval: Option<std::time::Duration>, since_last: std::time::Duration) -> Option<std::time::Duration> {
|
||||
let interval = interval?;
|
||||
(since_last < interval.saturating_sub(CAP_SLACK)).then(|| interval - since_last)
|
||||
}
|
||||
|
||||
/// CPU use in percent: `cpu_secs` of processor time over `wall_secs`, scaled by `share`.
|
||||
fn cpu_percent(cpu_secs: f64, wall_secs: f64, share: f64) -> f32 {
|
||||
if wall_secs <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
(cpu_secs / wall_secs * share * 100.0) as f32
|
||||
}
|
||||
|
||||
/// What one busy core counts for. Task Manager shows the share of the whole CPU (a thread
|
||||
/// pegging one core of 16 reads 6%); Activity Monitor and top count per core (100% per core).
|
||||
fn cpu_share() -> f64 {
|
||||
if cfg!(windows) {
|
||||
std::thread::available_parallelism().map_or(1.0, |n| 1.0 / n.get() as f64)
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Resident memory of this process, in bytes: the footprint Activity Monitor shows on macOS.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn rss_bytes() -> Option<u64> {
|
||||
@@ -226,7 +277,31 @@ fn rss_bytes() -> Option<u64> {
|
||||
Some(pages * unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as u64)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
/// The private working set: the "Memory" column of Task Manager's Processes tab. The plain
|
||||
/// working set also counts pages shared with other processes (DLLs), which reads higher.
|
||||
#[cfg(windows)]
|
||||
fn rss_bytes() -> Option<u64> {
|
||||
use windows_sys::Win32::System::ProcessStatus::{
|
||||
GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX2,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::GetCurrentProcess;
|
||||
let mut info: PROCESS_MEMORY_COUNTERS_EX2 = unsafe { std::mem::zeroed() };
|
||||
let cb = std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX2>() as u32;
|
||||
// SAFETY: the current-process pseudo handle is always valid; `cb` is the size of the
|
||||
// struct behind the pointer, whose first fields are a PROCESS_MEMORY_COUNTERS.
|
||||
let ok = unsafe { GetProcessMemoryInfo(GetCurrentProcess(), (&raw mut info).cast::<PROCESS_MEMORY_COUNTERS>(), cb) } != 0;
|
||||
if ok {
|
||||
return Some(info.PrivateWorkingSetSize as u64);
|
||||
}
|
||||
// Before Windows 10 1809 the larger struct is refused: the plain working set is close.
|
||||
let mut basic: PROCESS_MEMORY_COUNTERS = unsafe { std::mem::zeroed() };
|
||||
basic.cb = std::mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32;
|
||||
// SAFETY: as above, with the basic struct and its own size.
|
||||
let ok = unsafe { GetProcessMemoryInfo(GetCurrentProcess(), &mut basic, basic.cb) } != 0;
|
||||
ok.then_some(basic.WorkingSetSize as u64)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
fn rss_bytes() -> Option<u64> {
|
||||
None
|
||||
}
|
||||
@@ -243,7 +318,20 @@ fn cpu_seconds() -> Option<f64> {
|
||||
Some(secs(u.ru_utime) + secs(u.ru_stime))
|
||||
}
|
||||
|
||||
#[cfg(not(all(unix, not(target_arch = "wasm32"))))]
|
||||
#[cfg(windows)]
|
||||
fn cpu_seconds() -> Option<f64> {
|
||||
use windows_sys::Win32::Foundation::FILETIME;
|
||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetProcessTimes};
|
||||
let zero = FILETIME { dwLowDateTime: 0, dwHighDateTime: 0 };
|
||||
let (mut creation, mut exit, mut kernel, mut user) = (zero, zero, zero, zero);
|
||||
// SAFETY: the current-process pseudo handle is always valid; the four out-pointers are live.
|
||||
let ok = unsafe { GetProcessTimes(GetCurrentProcess(), &mut creation, &mut exit, &mut kernel, &mut user) } != 0;
|
||||
// FILETIME counts 100 ns ticks.
|
||||
let secs = |t: FILETIME| ((u64::from(t.dwHighDateTime) << 32) | u64::from(t.dwLowDateTime)) as f64 / 1e7;
|
||||
ok.then(|| secs(kernel) + secs(user))
|
||||
}
|
||||
|
||||
#[cfg(not(any(all(unix, not(target_arch = "wasm32")), windows)))]
|
||||
fn cpu_seconds() -> Option<f64> {
|
||||
None
|
||||
}
|
||||
@@ -270,11 +358,70 @@ mod tests {
|
||||
assert!(!p.is_slow(), "30 ms fits a 30 FPS floor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_rate_defaults_to_60_and_offers_high_rate_monitors() {
|
||||
assert_eq!(PerfPrefs::default().min_fps, DEFAULT_RATE);
|
||||
if !cfg!(target_os = "macos") {
|
||||
assert_eq!(DEFAULT_RATE, 60);
|
||||
}
|
||||
assert_eq!(PerfPrefs::parse("").min_fps, DEFAULT_RATE, "nothing saved yet");
|
||||
assert!(THRESHOLDS.contains(&60) && THRESHOLDS.contains(&360));
|
||||
assert!(THRESHOLDS.windows(2).all(|w| w[0] < w[1]), "ascending, so the chips read in order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_prefs_round_trip_the_chosen_rate() {
|
||||
let p = PerfPrefs { overlay: true, min_fps: 360 };
|
||||
let saved = format!("{} {}", u8::from(p.overlay), p.min_fps);
|
||||
assert!(PerfPrefs::parse(&saved) == p);
|
||||
assert_eq!(PerfPrefs::parse("1 garbage").min_fps, DEFAULT_RATE, "an unreadable rate falls back to the default");
|
||||
assert_eq!(PerfPrefs::parse("0 0").min_fps, AUTO, "Auto is a saved choice, not a missing one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_has_no_cap_and_is_judged_at_60() {
|
||||
let auto = PerfPrefs { overlay: false, min_fps: AUTO };
|
||||
assert_eq!(auto.frame_interval(), None);
|
||||
assert_eq!(auto.judge_fps(), 60);
|
||||
let p = PerfPrefs { overlay: false, min_fps: 240 };
|
||||
assert_eq!(p.judge_fps(), 240);
|
||||
let ms = p.frame_interval().unwrap().as_secs_f64() * 1000.0;
|
||||
assert!((ms - 1000.0 / 240.0).abs() < 1e-6, "{ms}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_waits_out_the_rest_of_the_interval() {
|
||||
use std::time::Duration as D;
|
||||
let sixty = Some(D::from_micros(16_667));
|
||||
assert_eq!(cap_wait(None, D::ZERO), None, "no cap, draw now");
|
||||
assert_eq!(cap_wait(sixty, D::from_millis(20)), None, "a late frame draws at once");
|
||||
assert_eq!(cap_wait(sixty, D::from_micros(16_000)), None, "within the slack of the beat");
|
||||
assert_eq!(cap_wait(sixty, D::from_millis(5)), Some(D::from_micros(11_667)), "too soon: wait for the remainder");
|
||||
assert_eq!(cap_wait(sixty, D::ZERO), Some(D::from_micros(16_667)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_percent_is_scaled_to_the_share_one_core_counts_for() {
|
||||
assert_eq!(cpu_percent(0.5, 1.0, 1.0), 50.0, "half a core, per-core accounting");
|
||||
assert_eq!(cpu_percent(1.0, 1.0, 0.0625), 6.25, "one core busy of 16 reads 6.25%");
|
||||
assert_eq!(cpu_percent(1.0, 0.0, 1.0), 0.0, "no elapsed time yields 0, not infinity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_memory() {
|
||||
let mut p = Perf::default();
|
||||
p.record(0.0, 1.0);
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", windows))]
|
||||
assert!(p.ram.is_some_and(|m| m > 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_seconds_grow_with_work() {
|
||||
let Some(before) = cpu_seconds() else { return };
|
||||
let mut x = 0u64;
|
||||
while cpu_seconds().is_some_and(|now| now - before < 0.05) {
|
||||
x = std::hint::black_box(x.wrapping_add(1));
|
||||
}
|
||||
assert!(cpu_seconds().is_some_and(|now| now > before));
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -345,9 +345,14 @@ pub fn show(ctx: &egui::Context, st: &mut UiState) {
|
||||
if st.settings.page == Page::Chat {
|
||||
content(ui, ctx, st);
|
||||
} else {
|
||||
// Inside a horizontal row the area would shrink to the row's
|
||||
// height: it takes the dialog's.
|
||||
let height = modal_height(ctx) - 24.0;
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt(("settings-page", st.settings.page as u8))
|
||||
.max_height(modal_height(ctx) - 24.0)
|
||||
.auto_shrink([false, false])
|
||||
.max_height(height)
|
||||
.min_scrolled_height(height)
|
||||
.show(ui, |ui| content(ui, ctx, st));
|
||||
}
|
||||
});
|
||||
|
||||
+3
-3
@@ -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| {
|
||||
|
||||
Reference in New Issue
Block a user