Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d46a01a10 | |||
| 7a6d664c42 | |||
| 70830478d9 | |||
| c575b7e7a7 |
@@ -3128,7 +3128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "papercraft"
|
||||
version = "0.8.2"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"axum",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "papercraft"
|
||||
version = "0.8.2"
|
||||
version = "0.8.4"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
description = "A native infinite-canvas app for planning, working and sketching"
|
||||
|
||||
@@ -76,6 +76,18 @@ are credited in `THIRD_PARTY.md`.
|
||||
* **Not carried over**: comments, design tokens, AI image generation, video export, teams;
|
||||
moving layers between pages; remote image URLs (see README Known gaps).
|
||||
|
||||
### 2026-10-28: 0.8.4 (demo video, fresh screenshots)
|
||||
|
||||
* README opens with a time-lapse of designing a habit-tracker app (`docs/demo.gif`, full video `docs/demo.mp4`), and every screenshot is retaken from that design.
|
||||
* Fix: the Performance overlay reads its settings from the project config folder, so `PAPERCRAFT_HOME` isolates it too.
|
||||
|
||||
### 2026-10-28: 0.8.3 (menus, panel grips, narrow windows)
|
||||
|
||||
* Menus open 6 px below their button, are at least 180 px wide and show an icon beside each item. Below 1100 px the hamburger opens every menu in one popup.
|
||||
* Side panels: a slim rounded grip in the gap resizes them (double click resets) and `/` hides or shows both. Panels size to their content and step aside when the window is too narrow for the canvas.
|
||||
* Sharper window buttons (pixel-snapped glyphs); the hamburger morphs into a back arrow while the File / Edit strip slides open and shut.
|
||||
* Fixes: lint Fix buttons no longer cover the issue text; review rows keep their badge and revert button off the hover edge; the Export button stays on one line.
|
||||
|
||||
### 2026-10-28: 0.8.2 (refresh-rate cap, accurate RAM/CPU, toolbar)
|
||||
|
||||
* Settings > Performance > Refresh rate is now a real frame cap applied live (30 to 360 Hz, 60 by default); **Auto** draws as fast as the display allows and is the default on macOS.
|
||||
|
||||
@@ -12,9 +12,17 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/readme/hero.png" alt="PaperCraft with the Redesign page of its own design file open: layers on the left, the canvas in the middle, the inspector on the right" width="100%">
|
||||
<a href="docs/demo.mp4">
|
||||
<img src="docs/demo.gif" alt="PaperCraft's editor breaking apart into its panels on a 3D stage, then chat mode with an agent, annotations pinned to a sign-in screen, the variables and libraries modals, and the home screen" width="100%">
|
||||
</a>
|
||||
<br>
|
||||
<sub>PaperCraft editing its own design file (the Redesign page).</sub>
|
||||
<sub>A 40-second tour: the editor, chat mode, annotations and design systems. <a href="docs/demo.mp4">Watch it in full quality</a> (MP4, 1080p60). Rendered from the app's own design with <a href="tools/showcase/README.md">tools/showcase</a>, not screen-recorded.</sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/readme/hero.png" alt="PaperCraft with the Pulse habit-tracker design open: layers on the left, three annotated screens on the canvas, the inspector on the right" width="100%">
|
||||
<br>
|
||||
<sub>The finished design: three screens, a component library and annotations.</sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -54,11 +62,11 @@ It is an alpha. It is good for building screens, design systems and sketches, an
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/screenshots/readme/variables.png" alt="The Variables modal listing the Colors collection with four Catppuccin modes" width="100%">
|
||||
<br><sub>Variables modal: collections, modes, groups and types.</sub>
|
||||
<img src="docs/screenshots/readme/variables.png" alt="The Variables modal listing the Colors collection with Light and Dark values for brand, foreground and background" width="100%">
|
||||
<br><sub>Variables modal: collections, modes (Light and Dark here), groups and types.</sub>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/screenshots/readme/assets.png" alt="The Assets side view with components grouped into Banner, Button, Checkbox and Chip" width="100%">
|
||||
<img src="docs/screenshots/readme/assets.png" alt="The Assets side view with the Habit row component and the shadcn Button kit" width="100%">
|
||||
<br><sub>Assets view: components grouped by name, from this file and enabled libraries.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -68,12 +76,32 @@ It is an alpha. It is good for building screens, design systems and sketches, an
|
||||
<br><sub>Libraries modal: publish this file, enable libraries from the store.</sub>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/screenshots/readme/agent-review.png" alt="Agent run review: a list of changes on the left and green, yellow and red outlines on the canvas" width="100%">
|
||||
<br><sub>Review of an agent run, with the changes outlined on the canvas.</sub>
|
||||
<img src="docs/screenshots/readme/agent-review.png" alt="The Agent side view: a finished run with Review and Revert, above the list of earlier runs" width="100%">
|
||||
<br><sub>The Agent view: runs, with Review and Revert for the latest.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/screenshots/readme/toolbar-panels.png" alt="The toolbar with the menu button folded into a back arrow, the File to Theme strip, and the floating left panel with a slim grip beside it" width="100%">
|
||||
<br><sub>Toolbar and panels: the menu button shows or hides the File / Edit strip, and the slim grip between a panel and the canvas resizes it (double click resets). <kbd>/</kbd> hides both side panels.</sub>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/screenshots/readme/menus.png" alt="The Tools menu open below its button, each tool with an icon and its shortcut" width="100%">
|
||||
<br><sub>Menus open a little below their button, are never narrower than 180 px, and carry an icon next to each item.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/screenshots/readme/annotations.png" alt="The Annotations side view listing five notes grouped by screen, with their category chips" width="100%">
|
||||
<br><sub>Annotations: rule, behaviour, content and data notes, kept with the layers they describe.</sub>
|
||||
</td>
|
||||
<td width="50%" valign="top"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
**Prototyping and review**
|
||||
* Prototype links between frames and a **Present** mode that follows them with a camera glide.
|
||||
* **Version history:** named and automatic saved versions per page; rename, restore, delete.
|
||||
@@ -141,7 +169,7 @@ EOF
|
||||
Chat mode (⌘J, or `Design | Chat` in the top bar) puts a conversation next to the canvas. It runs Claude Code and Codex through their CLIs, any agent that speaks the Agent Client Protocol (ACP, such as OpenCode, Gemini CLI or Cursor) found on your PATH, or the Anthropic API with your own key. It shows tool calls, checkpoints and run review in the thread. Chats are saved in the project's `chats/` folder. See [docs/chat.md](docs/chat.md).
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/readme/chat.png" alt="Chat mode: the chat column on the left shows a finished agent run with its Review, Revert and Accept actions, and the canvas on the right" width="100%">
|
||||
<img src="docs/screenshots/readme/chat.png" alt="Chat mode: the chat column on the left with its starter prompts and input, and the annotated habit-tracker screens on the canvas" width="100%">
|
||||
<br>
|
||||
<sub>Chat mode with a finished agent run: review, revert or accept from the thread.</sub>
|
||||
</p>
|
||||
@@ -228,6 +256,7 @@ The scenario tests drive the editor with synthetic pointer and keyboard events;
|
||||
| PageUp / PageDown | Previous / next page | ⌘\\ | Show / hide UI |
|
||||
| ⌘J | Design / Chat mode | ⌘L | Add selection to chat (Chat mode) |
|
||||
| N | Annotate tool | ⌘⇧A | Annotations: full, badges, hidden |
|
||||
| / | Show / hide the side panels | | |
|
||||
|
||||
Draw a frame around existing shapes and it adopts them; dragging a layer into or out of a frame re-parents it. Hold ⌘ while dragging to suspend snapping.
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 14 MiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 385 KiB |
|
After Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 355 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 303 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 416 KiB |
@@ -12,6 +12,7 @@ pub enum UiEvent {
|
||||
TogglePalette,
|
||||
Rename(NodeId),
|
||||
ToggleUi,
|
||||
TogglePanels,
|
||||
ToggleChat,
|
||||
ToggleHelp,
|
||||
ToggleGrid,
|
||||
|
||||
@@ -2186,6 +2186,7 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
A::ToggleUi => self.ui_events.push(commands::UiEvent::ToggleUi),
|
||||
A::TogglePanels => self.ui_events.push(commands::UiEvent::TogglePanels),
|
||||
A::ToggleChat => self.ui_events.push(commands::UiEvent::ToggleChat),
|
||||
A::ToggleHelp => self.ui_events.push(commands::UiEvent::ToggleHelp),
|
||||
A::ToggleGrid => self.ui_events.push(commands::UiEvent::ToggleGrid),
|
||||
|
||||
@@ -59,6 +59,7 @@ pub enum Action {
|
||||
Ask,
|
||||
Rename,
|
||||
ToggleUi,
|
||||
TogglePanels,
|
||||
ToggleChat,
|
||||
ToggleHelp,
|
||||
ToggleGrid,
|
||||
@@ -168,6 +169,7 @@ fn bindings() -> Vec<(Key, Chord, Action, &'static str)> {
|
||||
(Char('/'), CMD_SHIFT, A::CommandPalette, "Command palette"),
|
||||
(Char('r'), CMD, A::Rename, "Rename layer"),
|
||||
(Char('\\'), CMD, A::ToggleUi, "Show / hide UI"),
|
||||
(Char('/'), PLAIN, A::TogglePanels, "Show / hide the side panels"),
|
||||
(Char('/'), CMD, A::ToggleHelp, "Keyboard shortcuts"),
|
||||
(Char('?'), SHIFT, A::ToggleHelp, "Keyboard shortcuts"),
|
||||
(Char('\''), CMD_SHIFT, A::ToggleGrid, "Show / hide dot grid"),
|
||||
@@ -434,6 +436,7 @@ pub fn category(a: Action) -> &'static str {
|
||||
| A::ToggleAnnotations
|
||||
| A::OpenVariables
|
||||
| A::ToggleUi
|
||||
| A::TogglePanels
|
||||
| A::ToggleChat
|
||||
| A::ToggleHelp
|
||||
| A::NextPage
|
||||
@@ -618,6 +621,7 @@ pub fn menus() -> Vec<(&'static str, Vec<MenuEntry>)> {
|
||||
I("Dot Grid", A::ToggleGrid),
|
||||
I("Annotations", A::ToggleAnnotations),
|
||||
I("Show/Hide UI", A::ToggleUi),
|
||||
I("Show/Hide Panels", A::TogglePanels),
|
||||
I("Design / Chat Mode", A::ToggleChat),
|
||||
I("Keyboard Shortcuts", A::ToggleHelp),
|
||||
],
|
||||
|
||||
@@ -948,9 +948,15 @@ pub fn modal_backing(ui: &egui::Ui, fill: Color32, radius: u8) {
|
||||
craft_ui::material::paint_backing(ui, shared(), fill, radius);
|
||||
}
|
||||
|
||||
/// Narrowest a popup or menu gets, so short lists don't read as a sliver.
|
||||
pub const POPUP_MIN_W: f32 = 180.0;
|
||||
/// Space between a button and the menu it opens.
|
||||
pub const POPUP_GAP: f32 = 6.0;
|
||||
|
||||
/// [`modal_backing`] for a popup, menu or context menu: called first in its closure, it lights
|
||||
/// the popup's frame (its rect from the last frame) under acrylic.
|
||||
pub fn popup_backing(ui: &egui::Ui) {
|
||||
pub fn popup_backing(ui: &mut egui::Ui) {
|
||||
ui.set_min_width(POPUP_MIN_W);
|
||||
let v = ui.visuals();
|
||||
modal_backing(ui, v.window_fill, v.menu_corner_radius.nw);
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ pub fn menu(ui: &mut Ui, ed: &Editor) -> Option<Act> {
|
||||
pub fn chip(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
let r = super::kit::chip(ui, "Actions", false).on_hover_text("Actions on the selection");
|
||||
let mut act = None;
|
||||
egui::Popup::menu(&r).width(200.0).show(|ui| {
|
||||
egui::Popup::menu(&r).gap(crate::theme::POPUP_GAP).width(200.0).show(|ui| {
|
||||
crate::theme::popup_backing(ui);
|
||||
act = menu(ui, ed);
|
||||
if act.is_some() {
|
||||
|
||||
@@ -58,7 +58,7 @@ pub fn json_config(url: &str) -> String {
|
||||
impl AgentUi {
|
||||
/// The top-level "Agent" menu.
|
||||
pub fn menu(&mut self, ui: &mut egui::Ui) {
|
||||
ui.menu_button("Agent", |ui| {
|
||||
super::menus::menu_button(ui, "Agent", |ui| {
|
||||
crate::theme::popup_backing(ui);
|
||||
if super::icon_menu_item(ui, crate::theme::Icon::Export, "Connect…").clicked() {
|
||||
self.open = true;
|
||||
|
||||
@@ -279,7 +279,7 @@ fn category_select(ui: &mut Ui, current: &str, cats: &[AnnotationCategory]) -> O
|
||||
p.secondary,
|
||||
);
|
||||
let mut chosen = None;
|
||||
egui::Popup::menu(&resp).width(rect.width().max(190.0)).show(|ui| {
|
||||
egui::Popup::menu(&resp).gap(crate::theme::POPUP_GAP).width(rect.width().max(190.0)).show(|ui| {
|
||||
crate::theme::popup_backing(ui);
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
for c in cats {
|
||||
|
||||
@@ -162,7 +162,7 @@ fn picker(ui: &mut Ui, ed: &Editor, kind: Kind, current: Option<&str>) -> Option
|
||||
}
|
||||
|
||||
fn open_picker(resp: &egui::Response, ed: &Editor, kind: Kind, current: Option<&str>) -> Option<String> {
|
||||
egui::Popup::menu(resp)
|
||||
egui::Popup::menu(resp).gap(crate::theme::POPUP_GAP)
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.width(POPUP_W)
|
||||
.show(|ui| {
|
||||
|
||||
@@ -1612,9 +1612,8 @@ fn pan_to(ed: &mut Editor, key: &str) {
|
||||
/// The left column: replaces the layers panel in Chat mode.
|
||||
pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, frame: egui::Frame, floating: bool, fill: egui::Color32) {
|
||||
let r = egui::Panel::left("chat")
|
||||
.resizable(true)
|
||||
.default_size(st.chat.column_width())
|
||||
.size_range(COLUMN_MIN..=COLUMN_MAX)
|
||||
.resizable(false)
|
||||
.exact_size(st.chat.column_width().clamp(COLUMN_MIN, COLUMN_MAX))
|
||||
.frame(frame)
|
||||
.show(ui, |ui| {
|
||||
if floating {
|
||||
@@ -1623,7 +1622,13 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, frame: egui::Frame,
|
||||
column(ui, ed, st);
|
||||
}
|
||||
});
|
||||
let w = r.response.rect.width();
|
||||
let gap = if floating { super::PANEL_GAP } else { 0.0 };
|
||||
let grip = super::panel_handle::show(ui.ctx(), "chat-grip".into(), r.response.rect, super::panel_handle::Side::Left, gap);
|
||||
let w = if grip.reset {
|
||||
COLUMN_DEFAULT
|
||||
} else {
|
||||
super::panel_handle::resized(st.chat.column_width(), grip.delta, COLUMN_MIN..=COLUMN_MAX)
|
||||
};
|
||||
if (w - st.chat.column_width()).abs() > 0.5 {
|
||||
st.chat.width = w;
|
||||
st.chat.width_dirty = true;
|
||||
@@ -1924,7 +1929,7 @@ fn chat_menu(anchor: &egui::Response, st: &mut UiState) {
|
||||
const LOGO: f32 = 14.0;
|
||||
const LIST_MAX_H: f32 = 360.0;
|
||||
let running = st.chat.is_running();
|
||||
egui::Popup::menu(anchor).width(300.0).show(|ui| {
|
||||
egui::Popup::menu(anchor).gap(crate::theme::POPUP_GAP).width(300.0).show(|ui| {
|
||||
crate::theme::popup_backing(ui);
|
||||
let p = theme::pal(ui);
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Pixel-grid helpers for thin icon strokes: a line centred between two device pixels is drawn
|
||||
//! as a soft double-width smear, which reads as aliasing. Snapping the stroke to whole device
|
||||
//! pixels and its centre to the grid keeps horizontal and vertical glyphs sharp.
|
||||
|
||||
use egui::{Pos2, Stroke};
|
||||
|
||||
/// A stroke of about `width` points, rounded to a whole number of device pixels (at least one),
|
||||
/// and the number of device pixels it covers.
|
||||
pub fn stroke(ppp: f32, width: f32, color: egui::Color32) -> (Stroke, u32) {
|
||||
let px = (width * ppp).round().max(1.0);
|
||||
(Stroke::new(px / ppp, color), px as u32)
|
||||
}
|
||||
|
||||
/// Snaps `v` (points) so a line `px` device pixels wide centred on it covers whole pixels: odd
|
||||
/// widths are centred on a pixel's middle, even widths on a pixel edge.
|
||||
pub fn snap(v: f32, ppp: f32, px: u32) -> f32 {
|
||||
let offset = if px % 2 == 1 { 0.5 } else { 0.0 };
|
||||
((v * ppp - offset).round() + offset) / ppp
|
||||
}
|
||||
|
||||
pub fn snap_pos(p: Pos2, ppp: f32, px: u32) -> Pos2 {
|
||||
Pos2::new(snap(p.x, ppp, px), snap(p.y, ppp, px))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stroke_covers_whole_device_pixels() {
|
||||
let (s, px) = stroke(1.5, 1.25, egui::Color32::WHITE);
|
||||
assert_eq!(px, 2);
|
||||
assert!((s.width * 1.5 - 2.0).abs() < 1e-5);
|
||||
assert_eq!(stroke(1.0, 0.2, egui::Color32::WHITE).1, 1, "never thinner than a pixel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_widths_centre_on_pixel_middles_even_on_edges() {
|
||||
let on_grid = |v: f32, ppp: f32, off: f32| ((v * ppp - off) - (v * ppp - off).round()).abs() < 1e-4;
|
||||
assert!(on_grid(snap(10.3, 1.0, 1), 1.0, 0.5));
|
||||
assert!(on_grid(snap(10.3, 1.5, 2), 1.5, 0.0));
|
||||
assert_eq!(snap(10.0, 2.0, 2), 10.0);
|
||||
}
|
||||
}
|
||||
@@ -184,37 +184,33 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
| "type-scale"
|
||||
| "touch-target"
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("●").color(dot_color).size(theme::SECONDARY));
|
||||
let r = ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(format!("{node_name}: {}", issue.message))
|
||||
.size(theme::SECONDARY),
|
||||
)
|
||||
.truncate()
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if r.on_hover_text(&issue.fix).clicked() {
|
||||
ed.selection = vec![issue.node];
|
||||
ed.run(crate::editor::shortcuts::Action::ZoomSelection);
|
||||
}
|
||||
if fixable {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Zap), "Fix")
|
||||
.on_hover_text(&issue.fix)
|
||||
.clicked()
|
||||
{
|
||||
let target = issue.node;
|
||||
ed.edit("Fix lint issue", |tx| {
|
||||
crate::mcp::lint::autofix_node(
|
||||
tx,
|
||||
target,
|
||||
&crate::mcp::lint::DEFAULT_SCALE,
|
||||
);
|
||||
});
|
||||
}
|
||||
// The fix button is an icon at the row's end and takes its room first, so the
|
||||
// text beside it truncates instead of running underneath.
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if fixable
|
||||
&& crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Zap), "")
|
||||
.on_hover_text(format!("Fix: {}", issue.fix))
|
||||
.clicked()
|
||||
{
|
||||
let target = issue.node;
|
||||
ed.edit("Fix lint issue", |tx| {
|
||||
crate::mcp::lint::autofix_node(tx, target, &crate::mcp::lint::DEFAULT_SCALE);
|
||||
});
|
||||
}
|
||||
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.label(RichText::new("●").color(dot_color).size(theme::SECONDARY));
|
||||
let r = ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(format!("{node_name}: {}", issue.message)).size(theme::SECONDARY),
|
||||
)
|
||||
.truncate()
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if r.on_hover_text(&issue.fix).clicked() {
|
||||
ed.selection = vec![issue.node];
|
||||
ed.run(crate::editor::shortcuts::Action::ZoomSelection);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -614,7 +614,7 @@ fn project_card(ui: &mut Ui, hv: &mut HomeView, e: &Entry) {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let more =
|
||||
theme::icon_button(ui, Icon::More, 20.0, false).on_hover_text("More");
|
||||
egui::Popup::menu(&more).show(|ui| {
|
||||
egui::Popup::menu(&more).gap(crate::theme::POPUP_GAP).show(|ui| {
|
||||
crate::theme::popup_backing(ui);
|
||||
card_menu(ui, hv, e)
|
||||
});
|
||||
|
||||
@@ -901,6 +901,7 @@ fn export_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
};
|
||||
if theme::lit(ui, |ui| ui.add(
|
||||
egui::Button::new(format!("Export {what}…"))
|
||||
.truncate()
|
||||
.min_size(egui::vec2(ui.available_width(), 24.0)),
|
||||
))
|
||||
.clicked()
|
||||
|
||||
@@ -10,6 +10,8 @@ const SIZE: f32 = 26.0;
|
||||
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> {
|
||||
@@ -33,8 +35,35 @@ pub fn save_hidden(hidden: bool) {
|
||||
let _ = crate::vfs::write(p, if hidden { HIDDEN } else { "shown" });
|
||||
}
|
||||
|
||||
/// The three-bar button. Clicking toggles the strip: the caller flips and saves the choice.
|
||||
pub fn button(ui: &mut Ui, hidden: bool) -> Response {
|
||||
/// 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);
|
||||
@@ -43,17 +72,27 @@ pub fn button(ui: &mut Ui, hidden: bool) -> Response {
|
||||
ui.painter().rect_filled(rect, theme::radius_md(), fill);
|
||||
let stroke = Stroke::new(BAR_STROKE, p.text);
|
||||
let c = rect.center();
|
||||
for dy in [-BAR_GAP, 0.0, BAR_GAP] {
|
||||
ui.painter().hline(c.x - BAR_HALF..=c.x + BAR_HALF, c.y + dy, stroke);
|
||||
for [a, b] in bars(open) {
|
||||
ui.painter().line_segment([c + a, c + b], stroke);
|
||||
}
|
||||
}
|
||||
resp.on_hover_text(if hidden { "Show the menu bar" } else { "Hide the menu bar" })
|
||||
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"));
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! The menu bar's building blocks: top-level menus that open a little below their button, and
|
||||
//! items with a leading icon.
|
||||
|
||||
use crate::editor::Tool;
|
||||
use crate::editor::shortcuts::Action;
|
||||
use crate::editor::vector_ops::BoolOp;
|
||||
use crate::theme::{self, Icon};
|
||||
use egui::{Atom, Button, Id, InnerResponse, Popup, UiKind, UiStackInfo, Ui, Vec2};
|
||||
use egui::containers::menu::MenuConfig;
|
||||
|
||||
/// Room kept for an item's icon, with or without one, so the labels line up.
|
||||
const ICON_SLOT: f32 = 16.0;
|
||||
|
||||
/// A menu title that opens its menu [`theme::POPUP_GAP`] below the button, unlike egui's own
|
||||
/// `menu_button`, which butts the menu up against it. Inside a menu use `ui.menu_button`.
|
||||
pub fn menu_button<R>(
|
||||
ui: &mut Ui,
|
||||
title: &str,
|
||||
content: impl FnOnce(&mut Ui) -> R,
|
||||
) -> InnerResponse<Option<R>> {
|
||||
let response = ui.add(Button::new(title));
|
||||
let config = MenuConfig::find(ui);
|
||||
let inner = Popup::menu(&response)
|
||||
.gap(theme::POPUP_GAP)
|
||||
.close_behavior(config.close_behavior)
|
||||
.style(config.style.clone())
|
||||
.info(UiStackInfo::new(UiKind::Menu).with_tag_value(MenuConfig::MENU_CONFIG_TAG, config))
|
||||
.show(content);
|
||||
InnerResponse::new(inner.map(|i| i.inner), response)
|
||||
}
|
||||
|
||||
/// An item: its icon (if it has one), the label, and the shortcut on the right.
|
||||
pub fn item(ui: &mut Ui, label: &str, shortcut: String, icon: Option<Icon>) -> egui::Response {
|
||||
let slot = Id::new(("menu-icon", label));
|
||||
let button = Button::new((Atom::custom(slot, Vec2::splat(ICON_SLOT)), label)).shortcut_text(shortcut);
|
||||
let out = button.atom_ui(ui);
|
||||
if let (Some(icon), Some(rect)) = (icon, out.rect(slot)) {
|
||||
let ink = ui.style().interact(&out.response).fg_stroke.color;
|
||||
theme::paint_icon(ui.painter(), rect, icon, ink);
|
||||
}
|
||||
out.response
|
||||
}
|
||||
|
||||
/// The icon an action's menu item wears, where there is a fitting one.
|
||||
pub fn icon_of(action: Action) -> Option<Icon> {
|
||||
use Action as A;
|
||||
Some(match action {
|
||||
A::Tool(t) => tool_icon(t),
|
||||
A::Undo => Icon::Restore,
|
||||
A::Delete => Icon::Trash,
|
||||
A::SelectAll => Icon::Pointer,
|
||||
A::Group | A::Ungroup | A::WrapInFrame => Icon::Group,
|
||||
A::CreateComponent => Icon::Component,
|
||||
A::DetachInstance => Icon::Instance,
|
||||
A::PlaceImage => Icon::Image,
|
||||
A::ExportPng | A::ExportSvg | A::ExportPdf | A::CopyAsPng | A::CopyAsSvg => Icon::Export,
|
||||
A::Home => Icon::Grid,
|
||||
A::New => Icon::Plus,
|
||||
A::Open | A::SaveAs => Icon::Page,
|
||||
A::Align(0, v) => align_x(v),
|
||||
A::Align(_, v) => align_y(v),
|
||||
A::Distribute(0) => Icon::DistributeH,
|
||||
A::Distribute(_) => Icon::DistributeV,
|
||||
A::ToggleVisible => Icon::Eye,
|
||||
A::ToggleLock => Icon::Lock,
|
||||
A::AddAutoLayout | A::RemoveAutoLayout => Icon::Wrap,
|
||||
A::Boolean(op) => bool_icon(op),
|
||||
A::Bold | A::Italic | A::Underline | A::Strikethrough | A::Rename => Icon::Text,
|
||||
A::ToggleSketch => Icon::Sketch,
|
||||
A::CommandPalette | A::Ask => Icon::Search,
|
||||
A::ToggleUi | A::TogglePanels => Icon::Sidebar,
|
||||
A::ToggleGrid => Icon::Grid,
|
||||
A::ToggleAnnotations => Icon::Note,
|
||||
A::OpenVariables => Icon::Database,
|
||||
A::ToggleHelp => Icon::Question,
|
||||
A::ZoomIn => Icon::Plus,
|
||||
A::ZoomOut => Icon::Minus,
|
||||
A::Zoom100 | A::ZoomFit | A::ZoomSelection => Icon::Scale,
|
||||
A::NextPage | A::PrevPage => Icon::Page,
|
||||
A::ToggleChat => Icon::Zap,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_icon(t: Tool) -> Icon {
|
||||
match t {
|
||||
Tool::Select => Icon::Pointer,
|
||||
Tool::Hand => Icon::Hand,
|
||||
Tool::Frame => Icon::Frame,
|
||||
Tool::Rect => Icon::Rect,
|
||||
Tool::Ellipse => Icon::Ellipse,
|
||||
Tool::Polygon => Icon::Polygon,
|
||||
Tool::Star => Icon::Star,
|
||||
Tool::Diamond => Icon::Diamond,
|
||||
Tool::Line => Icon::Line,
|
||||
Tool::Arrow => Icon::Arrow,
|
||||
Tool::Pen => Icon::Pen,
|
||||
Tool::Text => Icon::Text,
|
||||
Tool::Sticky => Icon::Sticky,
|
||||
Tool::Pencil => Icon::Pencil,
|
||||
Tool::Eraser => Icon::Eraser,
|
||||
Tool::Laser => Icon::Laser,
|
||||
Tool::Annotate => Icon::Note,
|
||||
}
|
||||
}
|
||||
|
||||
fn align_x(at: f64) -> Icon {
|
||||
if at < 0.25 {
|
||||
Icon::AlignLeft
|
||||
} else if at > 0.75 {
|
||||
Icon::AlignRight
|
||||
} else {
|
||||
Icon::AlignHCenter
|
||||
}
|
||||
}
|
||||
|
||||
fn align_y(at: f64) -> Icon {
|
||||
if at < 0.25 {
|
||||
Icon::AlignTop
|
||||
} else if at > 0.75 {
|
||||
Icon::AlignBottom
|
||||
} else {
|
||||
Icon::AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
fn bool_icon(op: BoolOp) -> Icon {
|
||||
match op {
|
||||
BoolOp::Union => Icon::Group,
|
||||
BoolOp::Subtract | BoolOp::Intersect | BoolOp::Exclude => Icon::Corners,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_tool_has_an_icon_and_alignments_follow_their_edge() {
|
||||
for t in Tool::ALL {
|
||||
assert!(icon_of(Action::Tool(t)).is_some(), "{t:?}");
|
||||
}
|
||||
assert_eq!(icon_of(Action::Align(0, 0.0)), Some(Icon::AlignLeft));
|
||||
assert_eq!(icon_of(Action::Align(0, 1.0)), Some(Icon::AlignRight));
|
||||
assert_eq!(icon_of(Action::Align(1, 0.5)), Some(Icon::AlignVCenter));
|
||||
assert_eq!(icon_of(Action::Escape), None);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +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;
|
||||
@@ -99,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).
|
||||
@@ -607,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;
|
||||
|
||||
@@ -615,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;
|
||||
@@ -792,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();
|
||||
@@ -808,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
|
||||
@@ -929,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,
|
||||
@@ -962,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
|
||||
@@ -979,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,
|
||||
@@ -995,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 {
|
||||
@@ -1022,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 {
|
||||
@@ -1042,11 +1118,17 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
|
||||
if floating {
|
||||
floating_panel(ui, p.inspector, inspector::MARGIN, |ui| {
|
||||
inspector::panel(ui, ed, st)
|
||||
});
|
||||
})
|
||||
} else {
|
||||
inspector::panel(ui, ed, st)
|
||||
let room = ui.available_width();
|
||||
inspector::panel(ui, ed, st);
|
||||
overflow(ui, room)
|
||||
}
|
||||
});
|
||||
st.panel_need[1] = grown_need(st.panel_need[1], right.response.rect.width(), right.inner);
|
||||
let grip = panel_handle::show(ui.ctx(), "design-grip".into(), right.response.rect, panel_handle::Side::Right, grip_gap);
|
||||
st.panel_w[1] = grip_width(st.panel_w[1], grip, right_w, right_range);
|
||||
}
|
||||
}
|
||||
egui::Panel::top("toolbar")
|
||||
.exact_size(if floating {
|
||||
@@ -1128,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();
|
||||
}
|
||||
@@ -1154,37 +1233,53 @@ fn menu_entries(ui: &mut Ui, ed: &mut Editor, entries: &[shortcuts::MenuEntry])
|
||||
}
|
||||
}
|
||||
|
||||
/// The File / Edit strip. `open` is its eased 0..=1 state: while it moves, the strip is laid out
|
||||
/// at its natural width but only `open` of it is shown (clipped and faded), and the toolbar
|
||||
/// beside it follows that width, so it slides rather than pops.
|
||||
fn menu_strip(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, open: f32) {
|
||||
if open <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let width_id = egui::Id::new("menu-strip-width");
|
||||
let natural: f32 = ui.ctx().data(|d| d.get_temp(width_id)).unwrap_or(MENU_STRIP_GUESS_W);
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(natural * open, ui.available_height()), egui::Sense::hover());
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
child.set_clip_rect(rect.intersect(ui.clip_rect()));
|
||||
child.set_opacity(open);
|
||||
menu_bar(&mut child, ed, &mut st.agent_link);
|
||||
let measured = child.min_rect().width();
|
||||
ui.ctx().data_mut(|d| d.insert_temp(width_id, measured));
|
||||
}
|
||||
|
||||
/// The unified toolbar: document title on the left, zoom and help on the right. Without native
|
||||
/// chrome (Linux) it also carries an egui menu bar.
|
||||
fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
let p = theme::pal(ui);
|
||||
// In a narrow window only the essentials stay: zoom, present and share.
|
||||
let compact = ui.max_rect().width() < 720.0;
|
||||
let tight = ui.max_rect().width() < TIGHT_BAR_W;
|
||||
// The window buttons take 3 x 46 px, so the bar gives up its extras that much sooner.
|
||||
let buttons_w = if st.custom_titlebar { 3.0 * WIN_BTN_W } else { 0.0 };
|
||||
let compact = ui.max_rect().width() < 720.0 + buttons_w;
|
||||
let tight = ui.max_rect().width() < TIGHT_BAR_W + buttons_w + TIGHT_EXTRA;
|
||||
if st.custom_titlebar {
|
||||
drag_area(ui, ui.max_rect(), st);
|
||||
}
|
||||
ui.horizontal_centered(|ui| {
|
||||
if !st.native_chrome {
|
||||
if menu_toggle::button(ui, st.menu_bar_hidden).clicked() {
|
||||
st.menu_bar_hidden = !st.menu_bar_hidden;
|
||||
menu_toggle::save_hidden(st.menu_bar_hidden);
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
if !st.menu_bar_hidden {
|
||||
if ui.max_rect().width() < MENU_BAR_W {
|
||||
// Not enough room for every menu: they fold into one.
|
||||
ui.scope(|ui| {
|
||||
flat_menus(ui);
|
||||
let menu = ui.menu_button("Menu", |ui| {
|
||||
crate::theme::popup_backing(ui);
|
||||
flat_menu(ui, ed)
|
||||
});
|
||||
let _ = menu;
|
||||
});
|
||||
} else {
|
||||
ui.scope(|ui| menu_bar(ui, ed, &mut st.agent_link));
|
||||
if ui.max_rect().width() < MENU_BAR_W {
|
||||
// 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 {
|
||||
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();
|
||||
@@ -1250,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| {
|
||||
@@ -1277,7 +1372,7 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
let share = kit::button_compact(ui, kit::ButtonKind::Primary, Some(Icon::Export), "Share");
|
||||
egui::Popup::menu(&share).show(|ui| {
|
||||
egui::Popup::menu(&share).gap(crate::theme::POPUP_GAP).show(|ui| {
|
||||
crate::theme::popup_backing(ui);
|
||||
ui.set_min_width(190.0);
|
||||
for (icon, label, action) in [
|
||||
@@ -1392,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);
|
||||
});
|
||||
@@ -1466,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
|
||||
@@ -1489,6 +1584,10 @@ fn theme_menu(ui: &mut Ui) {
|
||||
const WIN_BTN_W: f32 = 46.0;
|
||||
/// Stroke of the minimize, maximize and close glyphs: the weight of the toolbar's icons.
|
||||
const WIN_GLYPH_STROKE: f32 = 1.25;
|
||||
/// How long the File / Edit strip takes to slide open or shut.
|
||||
const MENU_STRIP_SECS: f32 = 0.22;
|
||||
/// The strip's width before it has been laid out once (it is measured every frame after).
|
||||
const MENU_STRIP_GUESS_W: f32 = 300.0;
|
||||
|
||||
/// Without native chrome (Windows, Linux) the toolbar is the title bar: its empty space drags
|
||||
/// the window and a double click maximizes. Widgets added after this take the clicks.
|
||||
@@ -1512,7 +1611,8 @@ fn window_buttons(ui: &mut Ui, st: &mut UiState, h: f32) {
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(WIN_BTN_W, h), egui::Sense::click());
|
||||
let hover = motion::hover(ui.ctx(), resp.id.with("win-btn"), &resp);
|
||||
let down = resp.is_pointer_button_down_on();
|
||||
let cell = r.shrink2(egui::vec2(2.0, 4.0));
|
||||
let 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) {
|
||||
@@ -1523,11 +1623,15 @@ fn window_buttons(ui: &mut Ui, st: &mut UiState, h: f32) {
|
||||
let fill = if down { p.pressed } else { p.hover.gamma_multiply(hover) };
|
||||
ui.painter().rect_filled(cell, theme::radius_md(), fill);
|
||||
}
|
||||
let stroke = egui::Stroke::new(WIN_GLYPH_STROKE, ink);
|
||||
let c = r.center();
|
||||
let k = 5.0;
|
||||
// Whole-pixel strokes on the pixel grid, so the straight glyphs stay sharp.
|
||||
let (stroke, px) = crisp::stroke(ppp, WIN_GLYPH_STROKE, ink);
|
||||
let c = crisp::snap_pos(r.center(), ppp, 2);
|
||||
let k = (5.0 * ppp).round() / ppp;
|
||||
match cmd {
|
||||
WinCmd::Minimize => _ = ui.painter().hline(c.x - k..=c.x + k, c.y, stroke),
|
||||
WinCmd::Minimize => {
|
||||
let y = crisp::snap(c.y, ppp, px);
|
||||
ui.painter().hline(c.x - k..=c.x + k, y, stroke);
|
||||
}
|
||||
WinCmd::ToggleMaximize => {
|
||||
ui.painter().rect_stroke(egui::Rect::from_center_size(c, egui::Vec2::splat(k * 2.0)), 0.0, stroke, egui::StrokeKind::Inside);
|
||||
}
|
||||
@@ -2190,7 +2294,7 @@ fn layer_menu(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: NodeId) {
|
||||
item(ui, lock, lock_label, "⇧⌘L", A::ToggleLock);
|
||||
item(ui, Icon::Search, "Zoom to layer", "⇧2", A::ZoomSelection);
|
||||
ui.separator();
|
||||
ui.menu_button("Actions", |ui| {
|
||||
menus::menu_button(ui, "Actions", |ui| {
|
||||
crate::theme::popup_backing(ui);
|
||||
if let Some(a) = actions::menu(ui, ed) {
|
||||
actions::dispatch(ed, st, a);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//! The grip between a side panel and the canvas. The panels float with a gap around them, so the
|
||||
//! grip sits in the middle of that gap: a slim rounded pill that is faint at rest, lengthens and
|
||||
//! brightens under the pointer and takes the accent colour while dragging. A double click puts the
|
||||
//! panel back to its default width.
|
||||
|
||||
use super::motion;
|
||||
use crate::theme;
|
||||
use egui::{Area, Context, CursorIcon, Id, Order, Rect, Sense, Vec2, pos2, vec2};
|
||||
|
||||
/// Which side of the canvas the panel is on.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Side {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Width of the grabbable strip, and the pill's thickness at rest and when active.
|
||||
const STRIP_MIN_W: f32 = 14.0;
|
||||
const PILL_W: f32 = 4.0;
|
||||
/// Pill length at rest and when hovered or dragged.
|
||||
const PILL_H: f32 = 36.0;
|
||||
const PILL_H_ACTIVE: f32 = 56.0;
|
||||
/// Opacity of the pill at rest.
|
||||
const REST_ALPHA: f32 = 0.35;
|
||||
|
||||
/// What the grip asked for this frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default)]
|
||||
pub struct Grip {
|
||||
/// Points the panel should grow by (negative: shrink).
|
||||
pub delta: f32,
|
||||
/// A double click: back to the default width.
|
||||
pub reset: bool,
|
||||
}
|
||||
|
||||
/// Where the grip's centre line is, for a panel occupying `panel` with `gap` of empty margin on
|
||||
/// its canvas side.
|
||||
pub fn centre_x(panel: Rect, side: Side, gap: f32) -> f32 {
|
||||
match side {
|
||||
Side::Left => panel.right() - gap / 2.0,
|
||||
Side::Right => panel.left() + gap / 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `width` plus `delta`, kept inside `range`.
|
||||
pub fn resized(width: f32, delta: f32, range: std::ops::RangeInclusive<f32>) -> f32 {
|
||||
(width + delta).clamp(*range.start(), (*range.end()).max(*range.start()))
|
||||
}
|
||||
|
||||
/// Draws the grip and reads the drag. `id` must be unique per panel.
|
||||
pub fn show(ctx: &Context, id: Id, panel: Rect, side: Side, gap: f32) -> Grip {
|
||||
let strip_w = (gap + 8.0).max(STRIP_MIN_W);
|
||||
let x = centre_x(panel, side, gap);
|
||||
let strip = Rect::from_center_size(pos2(x, panel.center().y), vec2(strip_w, panel.height()));
|
||||
let mut grip = Grip::default();
|
||||
Area::new(id).order(Order::Foreground).fixed_pos(strip.min).show(ctx, |ui| {
|
||||
let (rect, resp) = ui.allocate_exact_size(strip.size(), Sense::click_and_drag());
|
||||
if resp.hovered() || resp.dragged() {
|
||||
ctx.set_cursor_icon(CursorIcon::ResizeHorizontal);
|
||||
}
|
||||
let active = resp.hovered() || resp.dragged();
|
||||
let t = motion::progress(ctx, id.with("grip"), active, motion::FAST, motion::ease_out_cubic);
|
||||
let p = theme::pal(ui);
|
||||
let ink = if resp.dragged() { theme::accent() } else { motion::mix(p.tertiary, p.secondary, t) };
|
||||
let ink = ink.gamma_multiply(motion::lerp(REST_ALPHA, 1.0, t));
|
||||
let h = motion::lerp(PILL_H, PILL_H_ACTIVE, t);
|
||||
let pill = Rect::from_center_size(rect.center(), Vec2::new(PILL_W, h));
|
||||
ui.painter().rect_filled(pill, PILL_W / 2.0, ink);
|
||||
let sign = if side == Side::Left { 1.0 } else { -1.0 };
|
||||
grip.delta = resp.drag_delta().x * sign;
|
||||
grip.reset = resp.double_clicked();
|
||||
});
|
||||
grip
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_grip_sits_in_the_middle_of_the_gap() {
|
||||
let panel = Rect::from_min_max(pos2(0.0, 0.0), pos2(264.0, 800.0));
|
||||
assert_eq!(centre_x(panel, Side::Left, 12.0), 258.0);
|
||||
let right = Rect::from_min_max(pos2(1000.0, 0.0), pos2(1272.0, 800.0));
|
||||
assert_eq!(centre_x(right, Side::Right, 12.0), 1006.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_stays_inside_the_range() {
|
||||
assert_eq!(resized(250.0, 30.0, 190.0..=400.0), 280.0);
|
||||
assert_eq!(resized(250.0, -200.0, 190.0..=400.0), 190.0);
|
||||
assert_eq!(resized(390.0, 50.0, 190.0..=400.0), 400.0);
|
||||
assert_eq!(resized(250.0, 10.0, 190.0..=100.0), 190.0, "an inverted range doesn't panic");
|
||||
}
|
||||
}
|
||||
@@ -891,6 +891,10 @@ enum ReviewAct {
|
||||
Undo(String, crate::mcp::diff::NodeDiff),
|
||||
}
|
||||
|
||||
/// Space between a review row's hover tint and its badge and revert button, so they don't sit on
|
||||
/// the tint's edge.
|
||||
const DIFF_ROW_PAD: f32 = 10.0;
|
||||
|
||||
fn diff_row(
|
||||
ui: &mut Ui,
|
||||
page_id: &str,
|
||||
@@ -910,16 +914,16 @@ fn diff_row(
|
||||
ui.painter()
|
||||
.rect_filled(rect, theme::radius_lg(), p.hover.gamma_multiply(hover));
|
||||
let badge = egui::Rect::from_min_size(
|
||||
rect.left_center() - egui::vec2(0.0, 14.0),
|
||||
rect.left_center() + egui::vec2(DIFF_ROW_PAD, -14.0),
|
||||
egui::vec2(28.0, 28.0),
|
||||
);
|
||||
ui.painter()
|
||||
.rect_filled(badge, theme::radius_md(), tint.gamma_multiply(0.18));
|
||||
theme::paint_icon(ui.painter(), badge.shrink(6.0), icon, tint);
|
||||
let x = rect.left() + 40.0;
|
||||
let x = rect.left() + DIFF_ROW_PAD + 40.0;
|
||||
let clip = ui.painter().with_clip_rect(egui::Rect::from_min_max(
|
||||
rect.min,
|
||||
egui::pos2(rect.right() - 40.0, rect.bottom()),
|
||||
egui::pos2(rect.right() - DIFF_ROW_PAD - 40.0, rect.bottom()),
|
||||
));
|
||||
clip.text(
|
||||
egui::pos2(x, rect.center().y - 9.0),
|
||||
@@ -938,7 +942,7 @@ fn diff_row(
|
||||
// A deleted page has no live node to revert into; "Revert run" re-creates it.
|
||||
if !deleted {
|
||||
let btn = egui::Rect::from_min_size(
|
||||
egui::pos2(rect.right() - 32.0, rect.center().y - 16.0),
|
||||
egui::pos2(rect.right() - DIFF_ROW_PAD - 32.0, rect.center().y - 16.0),
|
||||
egui::vec2(32.0, 32.0),
|
||||
);
|
||||
let undo = ui
|
||||
|
||||
@@ -49,7 +49,7 @@ impl PerfPrefs {
|
||||
}
|
||||
|
||||
fn path() -> Option<std::path::PathBuf> {
|
||||
Some(dirs::config_dir()?.join("papercraft").join("performance"))
|
||||
Some(crate::project::config_dir()?.join("performance"))
|
||||
}
|
||||
|
||||
/// `"<overlay 0|1> <min fps>"`.
|
||||
|
||||
@@ -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| {
|
||||
|
||||