Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c575b7e7a7 |
Generated
+1
-1
@@ -3128,7 +3128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "papercraft"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "papercraft"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
description = "A native infinite-canvas app for planning, working and sketching"
|
||||
|
||||
@@ -76,6 +76,13 @@ 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.
|
||||
|
||||
@@ -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 |
@@ -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),
|
||||
],
|
||||
|
||||
+7
-1
@@ -948,9 +948,15 @@ pub fn modal_backing(ui: &egui::Ui, fill: Color32, radius: u8) {
|
||||
craft_ui::material::paint_backing(ui, shared(), fill, radius);
|
||||
}
|
||||
|
||||
/// Narrowest a popup or menu gets, so short lists don't read as a sliver.
|
||||
pub const POPUP_MIN_W: f32 = 180.0;
|
||||
/// Space between a button and the menu it opens.
|
||||
pub const POPUP_GAP: f32 = 6.0;
|
||||
|
||||
/// [`modal_backing`] for a popup, menu or context menu: called first in its closure, it lights
|
||||
/// the popup's frame (its rect from the last frame) under acrylic.
|
||||
pub fn popup_backing(ui: &egui::Ui) {
|
||||
pub fn popup_backing(ui: &mut egui::Ui) {
|
||||
ui.set_min_width(POPUP_MIN_W);
|
||||
let v = ui.visuals();
|
||||
modal_backing(ui, v.window_fill, v.menu_corner_radius.nw);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
+25
-29
@@ -184,37 +184,33 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
| "type-scale"
|
||||
| "touch-target"
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("●").color(dot_color).size(theme::SECONDARY));
|
||||
let r = ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(format!("{node_name}: {}", issue.message))
|
||||
.size(theme::SECONDARY),
|
||||
)
|
||||
.truncate()
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if r.on_hover_text(&issue.fix).clicked() {
|
||||
ed.selection = vec![issue.node];
|
||||
ed.run(crate::editor::shortcuts::Action::ZoomSelection);
|
||||
}
|
||||
if fixable {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Zap), "Fix")
|
||||
.on_hover_text(&issue.fix)
|
||||
.clicked()
|
||||
{
|
||||
let target = issue.node;
|
||||
ed.edit("Fix lint issue", |tx| {
|
||||
crate::mcp::lint::autofix_node(
|
||||
tx,
|
||||
target,
|
||||
&crate::mcp::lint::DEFAULT_SCALE,
|
||||
);
|
||||
});
|
||||
}
|
||||
// The fix button is an icon at the row's end and takes its room first, so the
|
||||
// text beside it truncates instead of running underneath.
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if fixable
|
||||
&& crate::theme::small_icon_button(ui, Some(crate::theme::Icon::Zap), "")
|
||||
.on_hover_text(format!("Fix: {}", issue.fix))
|
||||
.clicked()
|
||||
{
|
||||
let target = issue.node;
|
||||
ed.edit("Fix lint issue", |tx| {
|
||||
crate::mcp::lint::autofix_node(tx, target, &crate::mcp::lint::DEFAULT_SCALE);
|
||||
});
|
||||
}
|
||||
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.label(RichText::new("●").color(dot_color).size(theme::SECONDARY));
|
||||
let r = ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(format!("{node_name}: {}", issue.message)).size(theme::SECONDARY),
|
||||
)
|
||||
.truncate()
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if r.on_hover_text(&issue.fix).clicked() {
|
||||
ed.selection = vec![issue.node];
|
||||
ed.run(crate::editor::shortcuts::Action::ZoomSelection);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+44
-5
@@ -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"));
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+152
-48
@@ -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");
|
||||
}
|
||||
}
|
||||
+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
|
||||
|
||||
+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