Files
figcraft/crates/ui-egui/src/toolbar.rs
T
Dylan M. Taylor 106ef69351 feat: startup update prompt modal and direct self-updating (#1328)
* feat: startup update prompt modal and direct self-updating

The update flow (check at start or on request, the startup prompt, the self-update worker) is craft-update's Updater; the app only supplies the suite, its close path and its status window. The check at start is opt-in for the desktop app, an install holds back while documents are unsaved, and the Windows update helper runs before argument parsing.

Ref: storytold/craft-libs#319

* fix(updates): re-add the update hooks to the 0.37 frame loop

* fix(updates): address review feedback

Wake the UI when a manual check is queued from a menu callback; the update prompt owns the keyboard while it is open (LightCraft); Download opens through the desktop URL hook (FilmCraft); the Help menu dispatches through menus::invoke (FigCraft); print jobs and comment drafts hold an install back (WordCraft); Check for Updates is a registered command (GridCraft); an install holds back while documents are unsaved.

* fix(updates): show the prompt on Home and presenter frames and close the application, not one window

* fix(updates): add the update strings to the English catalog
2026-10-25 16:23:00 -04:00

652 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The floating bottom toolbar (UI3): tool groups with flyouts, comments, actions.
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_engine::ToolId;
use figcraft_engine::vectoredit::VTool;
use serde_json::json;
use crate::FigApp;
use crate::a11y::{self, Nav, Region, Tip};
use crate::i18n::{tr, tr_noop, trf};
use crate::icons::{self, Icon};
use crate::theme::{Tokens, regular};
/// Every mark in the toolbar row is painted in a box this size, so they share one optical size
/// and one stroke width (#737, #738).
const GLYPH: f32 = 18.0;
/// The flyout carets: smaller than the marks, but on a box whose stroke rounds to the same width
/// as theirs at 1×, 1.5× and 2× (a 10-point box drew them one physical pixel thinner).
const CARET: f32 = 14.0;
/// `GLYPH`'s box centred in `r`.
fn glyph_rect(r: Rect) -> Rect {
Rect::from_center_size(r.center(), vec2(GLYPH, GLYPH))
}
struct Group {
key: &'static str,
/// The flyout button's name for screen readers.
name: &'static str,
tools: &'static [(ToolId, Icon)],
/// What a one-tool group's flyout offers instead of tools (boards: colours, shapes, stamps).
options: Options,
}
/// The choices a board tool's flyout shows.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Options {
None,
Colors,
Shapes,
Stamps,
}
impl Group {
const fn tools(key: &'static str, name: &'static str, tools: &'static [(ToolId, Icon)]) -> Group {
Group { key, name, tools, options: Options::None }
}
fn has_flyout(&self) -> bool {
self.tools.len() > 1 || self.options != Options::None
}
}
const GROUPS: &[Group] = &[
Group::tools("move", tr_noop("Move tools"), &[(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand), (ToolId::Scale, Icon::Scale)]),
Group::tools("frame", tr_noop("Frame tools"), &[(ToolId::Frame, Icon::Frame), (ToolId::Section, Icon::Section), (ToolId::Slice, Icon::Slice)]),
Group::tools(
"shape",
tr_noop("Shape tools"),
&[
(ToolId::Rectangle, Icon::Rectangle),
(ToolId::Line, Icon::Line),
(ToolId::Arrow, Icon::Arrow),
(ToolId::Ellipse, Icon::Ellipse),
(ToolId::Polygon, Icon::Polygon),
(ToolId::Star, Icon::Star),
],
),
Group::tools(
"pen",
tr_noop("Drawing tools"),
&[(ToolId::Pen, Icon::Pen), (ToolId::Pencil, Icon::Pencil), (ToolId::ShapeBuilder, Icon::ShapeBuilder)],
),
Group::tools("text", tr_noop("Text"), &[(ToolId::Text, Icon::Text), (ToolId::TextOnPath, Icon::TextOnPath)]),
Group::tools("comment", tr_noop("Comment"), &[(ToolId::Comment, Icon::Comment)]),
];
/// A board's toolbar: moving around, ink, stickies, shapes, connectors, text, sections, stamps.
const BOARD_GROUPS: &[Group] = &[
Group::tools("bmove", tr_noop("Move tools"), &[(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand)]),
Group::tools(
"ink",
tr_noop("Drawing tools"),
&[(ToolId::Marker, Icon::Marker), (ToolId::Highlighter, Icon::Highlighter), (ToolId::Eraser, Icon::Eraser)],
),
Group { key: "sticky", name: tr_noop("Sticky note colors"), tools: &[(ToolId::Sticky, Icon::Board)], options: Options::Colors },
Group { key: "bshape", name: tr_noop("Shapes"), tools: &[(ToolId::BoardShape, Icon::Shapes)], options: Options::Shapes },
Group::tools("connector", tr_noop("Connector"), &[(ToolId::Connector, Icon::Connector)]),
Group::tools("text", tr_noop("Text"), &[(ToolId::Text, Icon::Text)]),
Group::tools("section", tr_noop("Section"), &[(ToolId::Section, Icon::Section)]),
Group { key: "stamp", name: tr_noop("Stamps"), tools: &[(ToolId::Stamp, Icon::Stamp)], options: Options::Stamps },
Group::tools("comment", tr_noop("Comment"), &[(ToolId::Comment, Icon::Comment)]),
];
/// Return the last tool index remembered for a toolbar group, falling back to `default`.
fn remembered(ui: &Ui, key: &str, default: ToolId) -> ToolId {
ui.data(|d| d.get_temp::<u8>(egui::Id::unique(("tb_last", key)))).and_then(|i| ToolId::ALL.get(i as usize).copied()).unwrap_or(default)
}
pub fn show(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let canvas = app.canvas.rect;
app.ui.actions_rect = None;
// A file you can't edit now (view only, or on its way to a server): moving around only.
if let Some(why) = app.session.active_read_only() {
view_only_toolbar(app, ui, why);
return;
}
// Vector edit mode swaps the tools for the edit tools.
if let Some(tool) = app.session.ds().ok().and_then(|d| d.vector_edit.as_ref()).map(|v| v.tool) {
edit_toolbar(app, ui, tool);
return;
}
// So does the shape builder, for its merge/remove modes.
if app.session.tool.tool == ToolId::ShapeBuilder {
builder_toolbar(app, ui);
return;
}
let board = app.session.doc().is_ok_and(figcraft_engine::board::is_board);
main_toolbar(app, ui, canvas, &t, board);
}
/// The main toolbar pill's width for the active file: the Actions palette matches it so the two
/// read as one bar.
pub(crate) fn bar_width(app: &FigApp) -> f32 {
let board = app.session.doc().is_ok_and(figcraft_engine::board::is_board);
main_width(if board { BOARD_GROUPS } else { GROUPS }, board)
}
/// The main toolbar pill's width: the tool groups with their flyout carets, Resources and
/// Actions, and the mode switcher (boards: the Design panel's button instead).
fn main_width(groups: &[Group], board: bool) -> f32 {
let (cell, gap) = (TOOL_CELL, 4.0);
let carets = groups.iter().filter(|g| g.has_flyout()).count() as f32 * 10.0;
let end = if board { cell + 16.0 } else { 4.0 * 34.0 + 24.0 };
groups.len() as f32 * (cell + gap) + carets + 2.0 * (cell + gap) + 16.0 + end
}
/// The main toolbar pill: the tool groups with their flyouts, Resources and Actions, then the
/// Draw · Design · Prototype · Inspect mode switcher (on a board, the Design panel's button).
fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens, board: bool) {
let groups = if board { BOARD_GROUPS } else { GROUPS };
let rect = pill(ui, &mut app.ui.nav, canvas, main_width(groups, board), t);
let cy = rect.center().y;
let mut x = rect.min.x + 8.0;
for g in groups {
x = group_cell(app, ui, g, pos2(x, cy), rect.min.y, t);
}
x = side_buttons(app, ui, x, cy, t);
divider(ui, t, x, cy);
x += 8.0;
if board {
// Boards have no prototypes or Dev Mode; the Design panel is hidden until asked for.
let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));
let tip = crate::shortcuts::tip(tr("Design panel"), "view.design");
if tool_cell(ui, t, r, "board_panel", Icon::Panels, app.ui.board_panel, tip).clicked() {
app.run("view.design", json!({}));
}
} else {
mode_switcher(app, ui, x, cy, t);
}
help_button(app, ui, canvas, t);
}
/// A toolbar button's size.
const TOOL_CELL: f32 = 32.0;
/// Tool group `g` at `at` (its left edge, the pill's middle): the shown tool's button, its flyout
/// caret and, while open, its flyout above the pill (whose top is `top`). Where the next starts.
fn group_cell(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, top: f32, t: &Tokens) -> f32 {
let current = app.session.tool.tool;
let in_group = g.tools.iter().any(|(tid, _)| *tid == current);
let shown = if in_group { current } else { remembered(ui, g.key, g.tools.first().map(|x| x.0).unwrap_or(ToolId::Move)) };
let icon = g.tools.iter().find(|(tid, _)| *tid == shown).map(|(_, i)| *i).unwrap_or(Icon::Move);
let r = Rect::from_center_size(pos2(at.x + TOOL_CELL / 2.0, at.y), vec2(TOOL_CELL, TOOL_CELL));
let resp = tool_cell(ui, t, r, ("tool", g.key), icon, in_group, &format!("{} {}", tr(shown.label()), pretty_shortcut(shown.shortcut())));
if in_group {
a11y::prefer(&mut app.ui.nav, Region::Toolbar, resp.id);
}
if resp.clicked() {
// Enter (a keyboard click) on the shape or frame tool already chosen places one; the
// canvas takes the keyboard.
// In Dev Mode the tool goes through `choose`, which returns to the design first.
if in_group && shown.draws_shape() && app.ui.right_tab != crate::RightTab::Inspect && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
app.run("tool.place", json!({}));
app.ui.nav.go(Region::Canvas);
} else {
choose(app, shown);
}
}
let mut x = at.x + TOOL_CELL;
if g.has_flyout() {
flyout_caret(app, ui, g, pos2(x, at.y), t);
x += 10.0;
}
if app.ui.tool_flyout == Some(g.key) {
if g.options == Options::None {
flyout(app, ui, g, pos2(r.min.x, top - 8.0));
} else {
options_flyout(app, ui, g, pos2(r.min.x, top - 8.0));
}
}
x + 4.0
}
/// The caret after group `g`'s button (at `at`, its left edge and middle): opens or closes its
/// flyout.
fn flyout_caret(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, t: &Tokens) {
let cr = Rect::from_center_size(pos2(at.x + 5.0, at.y), vec2(10.0, TOOL_CELL));
let cresp = ui.interact(cr, ui.scope_id().with(("flyout", g.key)), Sense::click());
let open = app.ui.tool_flyout == Some(g.key);
a11y::menu_button(&cresp, tr(g.name), open);
if open {
a11y::popup(&mut app.ui.nav, cresp.id, flyout_id(g.key));
}
if cresp.hovered() {
ui.painter().rect_filled(cr, CornerRadius::same(4), t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(cr.center(), vec2(CARET, CARET)), Icon::Chevron, t.icon_secondary);
if cresp.clicked() {
app.ui.tool_flyout = if open { None } else { Some(g.key) };
}
}
/// Resources (components, plugins: the Assets panel) and the Actions palette's button, from `x`.
/// Where the next part starts.
fn side_buttons(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 {
for (icon, tip, id) in [
(Icon::Assets, crate::shortcuts::key_tip(tr("Resources"), "Shift+I"), "resources"),
(Icon::Actions, crate::shortcuts::tip(tr("Actions"), "app.actions"), "actions"),
] {
let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));
let resp = ui.interact(r, ui.scope_id().with(id), Sense::click()).tip(tip);
if id == "actions" {
app.ui.actions_rect = Some(r);
}
// The Actions button reads on while its panel is open; clicking it again closes it.
let actions_open = id == "actions" && app.ui.palette.is_some();
if actions_open {
ui.painter().rect_filled(r, CornerRadius::same(5), t.accent);
} else if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), glyph_rect(r), icon, if actions_open { t.accent_on } else { t.icon });
if resp.clicked() {
if id == "actions" {
crate::menus::toggle_palette(&mut app.ui);
} else {
app.run("view.assets", json!({}));
}
}
x += TOOL_CELL + 4.0;
}
x
}
/// The mode switcher from `x`: Draw · Design · Prototype · Inspect (code).
fn mode_switcher(app: &mut FigApp, ui: &mut Ui, x: f32, cy: f32, t: &Tokens) {
let modes = [(Icon::Pencil, tr("Draw")), (Icon::Frame, tr("Design")), (Icon::Prototype, tr("Prototype")), (Icon::Code, tr("Inspect"))];
let active = match app.ui.right_tab {
crate::RightTab::Design if app.session.tool.tool == ToolId::Pencil => 0,
crate::RightTab::Design => 1,
crate::RightTab::Prototype => 2,
crate::RightTab::Inspect => 3,
};
let seg = Rect::from_min_size(pos2(x, cy - 18.0), vec2(4.0 * 34.0 + 4.0, 36.0));
ui.painter().rect_filled(seg, CornerRadius::same(8), t.field);
for (i, (icon, tip)) in modes.iter().enumerate() {
let r = Rect::from_min_size(pos2(seg.min.x + 2.0 + i as f32 * 34.0, seg.min.y + 2.0), vec2(34.0, 32.0));
let resp = ui.interact(r, ui.scope_id().with(("mode", i)), Sense::click()).on_hover_text(*tip);
a11y::toggle(&resp, tip, i == active);
if i == active {
ui.painter().rect_filled(r, CornerRadius::same(6), t.panel);
ui.painter().rect_stroke(r, CornerRadius::same(6), Stroke::new(1.0, t.border), StrokeKind::Inside);
} else if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(6), t.hover);
}
icons::paint(ui.painter(), glyph_rect(r), *icon, if i == active { t.accent_text } else { t.icon_secondary });
if resp.clicked() {
pick_mode(app, i);
}
}
}
/// Mode `i` of the switcher chosen.
fn pick_mode(app: &mut FigApp, i: usize) {
match i {
0 => {
app.ui.right_tab = crate::RightTab::Design;
app.run("tool.set", json!({"tool": "pencil"}));
}
1 => {
app.ui.right_tab = crate::RightTab::Design;
if app.session.tool.tool == ToolId::Pencil {
app.run("tool.set", json!({"tool": "move"}));
}
}
2 => app.ui.right_tab = crate::RightTab::Prototype,
_ => app.ui.right_tab = crate::RightTab::Inspect,
}
}
/// The floating help button at the bottom right of the canvas.
fn help_button(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) {
let hr = Rect::from_center_size(pos2(canvas.max.x - 32.0, canvas.max.y - 32.0), vec2(32.0, 32.0));
let hresp = ui.interact(hr, ui.scope_id().with("help"), Sense::click()).tip(tr("Help and resources"));
ui.painter().circle_filled(hr.center() + vec2(0.0, 1.0), 17.0, Color32::from_black_alpha(20));
ui.painter().circle_filled(hr.center(), 16.0, t.panel);
ui.painter().text(hr.center(), Align2::CENTER_CENTER, "?", crate::theme::regular(15.0), t.text);
crate::right::menu_popup(&hresp).show(|ui| {
for (l, c) in [
(tr("Getting started"), "help.gettingStarted"),
(tr("Keyboard shortcuts"), "help.shortcuts"),
(tr("Help page"), "help.page"),
(tr("Check for updates…"), "help.checkForUpdates"),
(tr("About FigCraft"), "help.about"),
] {
if crate::right::menu_item(ui, l, crate::shortcuts::shortcut(c).unwrap_or("")) {
crate::menus::invoke(app, c);
ui.close();
}
}
});
}
/// The pill shown for a file you can't edit now: Move and Hand, and a badge saying why ("View
/// only", or "Publishing…" until the file is on the server).
fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) {
let t = Tokens::get(ui.ctx());
// The editing tools are off: the tool falls back to moving around.
if !matches!(app.session.tool.tool, ToolId::Move | ToolId::Hand) {
app.run("tool.set", json!({"tool": "move"}));
}
let label = if why == figcraft_engine::live::PUBLISHING { tr("Publishing…") } else { tr("View only") };
let cell = 32.0;
let text = ui.painter().layout_no_wrap(label.to_string(), crate::theme::semibold(11.5), t.text);
let badge_w = 12.0 + 14.0 + 6.0 + text.size().x + 12.0;
let width = 2.0 * (cell + 4.0) + 8.0 + 12.0 + badge_w + 8.0;
let rect = pill(ui, &mut app.ui.nav, app.canvas.rect, width, &t);
let cy = rect.center().y;
let mut x = rect.min.x + 8.0;
for (tool, icon) in [(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand)] {
let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
let active = app.session.tool.tool == tool;
let resp = tool_cell(ui, &t, r, ("tool", tool.id()), icon, active, &format!("{} {}", tr(tool.label()), pretty_shortcut(tool.shortcut())));
if active {
a11y::prefer(&mut app.ui.nav, Region::Toolbar, resp.id);
}
if resp.clicked() {
app.run("tool.set", json!({"tool": tool.id()}));
}
x += cell + 4.0;
}
divider(ui, &t, x, cy);
let badge = Rect::from_min_size(pos2(x + 12.0, cy - 13.0), vec2(badge_w, 26.0));
let resp = ui.interact(badge, ui.scope_id().with("view_only"), Sense::hover()).on_hover_text(why);
a11y::status(&resp, label, why);
ui.painter().rect_filled(badge, CornerRadius::same(13), t.field);
icons::paint(ui.painter(), Rect::from_center_size(pos2(badge.min.x + 12.0 + 7.0, cy), vec2(14.0, 14.0)), Icon::Eye, t.icon_secondary);
ui.painter().galley(pos2(badge.min.x + 12.0 + 14.0 + 6.0, cy - text.size().y / 2.0), text, t.text);
}
/// The pill shown in vector edit mode: the edit tools and Done.
fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {
let t = Tokens::get(ui.ctx());
let canvas = app.canvas.rect;
let cell = 32.0;
let width = VTool::ALL.len() as f32 * (cell + 4.0) + 8.0 + 12.0 + DONE_W + 8.0;
let rect = pill(ui, &mut app.ui.nav, canvas, width, &t);
let cy = rect.center().y;
let mut x = rect.min.x + 8.0;
// Holding Cmd with the Move tool bends: show it.
let held = current == VTool::Move && ui.input(|i| i.modifiers.command);
for tool in VTool::ALL {
let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
let tip = if tool == VTool::Bend {
trf("{tool} hold {key}", &[("tool", &tr(tool.label())), ("key", &pretty_shortcut(tool.shortcut()))])
} else {
format!("{} {}", tr(tool.label()), pretty_shortcut(tool.shortcut()))
};
let active = tool == current && !held || held && tool == VTool::Bend;
let icon = match tool {
VTool::Move => Icon::Move,
VTool::Pen => Icon::Pen,
VTool::Bend => Icon::Bend,
VTool::Paint => Icon::PaintBucket,
VTool::Lasso => Icon::Lasso,
VTool::Cut => Icon::Cut,
VTool::Eraser => Icon::Eraser,
VTool::Width => Icon::VariableWidth,
};
let resp = tool_cell(ui, &t, r, ("vtool", tool.id()), icon, active, &tip);
if active {
a11y::prefer(&mut app.ui.nav, Region::Toolbar, resp.id);
}
if resp.clicked() {
app.run("vector.edit.tool", json!({"tool": tool.id()}));
}
x += cell + 4.0;
}
if done_button(ui, &t, x, cy, &format!("{} Esc", tr("Done editing"))) {
app.run("vector.edit.end", json!({}));
}
}
/// The pill shown while the shape builder is active: Merge and Remove modes, a hint and Done.
fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let cell = 32.0;
let remove = app.session.tool.builder_remove;
let has_regions = figcraft_engine::shapebuilder::current(&app.session).is_some_and(|a| !a.regions.is_empty());
let alt = pretty_shortcut("Alt");
let hint = if !has_regions {
tr("Select overlapping shapes").to_string()
} else if remove {
trf("Click or drag to remove · {alt} merges", &[("alt", &alt)])
} else {
trf("Click or drag to merge · {alt} removes", &[("alt", &alt)])
};
let galley = ui.painter().layout_no_wrap(hint, regular(12.0), t.text_secondary);
let width = 2.0 * (cell + 4.0) + 8.0 + 12.0 + galley.size().x + 12.0 + 12.0 + DONE_W + 8.0;
let rect = pill(ui, &mut app.ui.nav, app.canvas.rect, width, &t);
let cy = rect.center().y;
let mut x = rect.min.x + 8.0;
for (mode, icon, tip) in [("merge", Icon::ShapeBuilder, tr("Merge regions")), ("remove", Icon::ShapeBuilderRemove, tr("Remove regions"))] {
let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
let active = (mode == "remove") == remove;
let resp = tool_cell(ui, &t, r, ("sbmode", mode), icon, active, tip);
if active {
a11y::prefer(&mut app.ui.nav, Region::Toolbar, resp.id);
}
if resp.clicked() {
app.run("shapeBuilder.mode", json!({"mode": mode}));
}
x += cell + 4.0;
}
divider(ui, &t, x, cy);
x += 12.0;
let w = galley.size().x;
ui.painter().galley(pos2(x, cy - galley.size().y / 2.0), galley, t.text_secondary);
x += w + 12.0;
if done_button(ui, &t, x, cy, &format!("{} {}", tr("Done"), pretty_shortcut("Enter"))) {
app.run("tool.set", json!({"tool": "move"}));
}
}
/// Where a toolbar pill `width` wide sits: centred 12 px above the canvas's bottom edge.
pub(crate) fn pill_rect(canvas: Rect, width: f32) -> Rect {
Rect::from_center_size(pos2(canvas.center().x, canvas.max.y - 12.0 - 24.0), vec2(width, 48.0))
}
/// The floating pill (shadow, panel, border) `width` wide at the bottom centre of the canvas: the
/// toolbar region keyboard navigation moves through.
fn pill(ui: &Ui, nav: &mut Nav, canvas: Rect, width: f32, t: &Tokens) -> Rect {
let rect = pill_rect(canvas, width);
// The pill takes the press and the drag over it: its buttons sense clicks only, and egui gives
// a drag the canvas under them would sense to the canvas, so the pen placed an anchor there.
// Not focusable: the keyboard walks the buttons, not the pill.
ui.interact(rect, ui.scope_id().with("pill"), Sense::CLICK | Sense::DRAG);
a11y::area(nav, Region::Toolbar, rect);
a11y::container_bounds(ui, rect);
let painter = ui.painter();
for (i, a) in [(6.0, 10u8), (3.0, 16), (1.0, 24)] {
painter.rect_filled(rect.expand(i).translate(vec2(0.0, i * 0.5)), CornerRadius::same((13.0 + i) as u8), Color32::from_black_alpha(a));
}
painter.rect_filled(rect, CornerRadius::same(13), t.panel);
painter.rect_stroke(rect, CornerRadius::same(13), Stroke::new(1.0, t.border), StrokeKind::Inside);
rect
}
/// A square tool button: accent when active, tinted on hover; `tip` is its tooltip and name.
pub(crate) fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSalt, icon: Icon, active: bool, tip: &str) -> egui::Response {
let resp = ui.interact(r, ui.scope_id().with(id), Sense::click()).on_hover_ui(|ui| {
ui.label(tip);
});
a11y::toggle(&resp, tip, active);
let bg = if active {
t.accent
} else if resp.hovered() {
t.hover
} else {
Color32::TRANSPARENT
};
ui.painter().rect_filled(r, CornerRadius::same(5), bg);
icons::paint(ui.painter(), glyph_rect(r), icon, if active { t.accent_on } else { t.icon });
resp
}
/// A vertical separator just after `x`.
fn divider(ui: &Ui, t: &Tokens, x: f32, cy: f32) {
ui.painter().line_segment([pos2(x + 2.0, cy - 14.0), pos2(x + 2.0, cy + 14.0)], Stroke::new(1.0, t.border));
}
const DONE_W: f32 = 56.0;
/// A divider at `x` and the accent Done button after it; true when clicked.
fn done_button(ui: &mut Ui, t: &Tokens, x: f32, cy: f32, tip: &str) -> bool {
divider(ui, t, x, cy);
let done = Rect::from_min_size(pos2(x + 12.0, cy - 14.0), vec2(DONE_W, 28.0));
let resp = ui.interact(done, ui.scope_id().with("tool_done"), Sense::click()).tip(tip);
ui.painter().rect_filled(done, CornerRadius::same(6), if resp.hovered() { t.accent_hover } else { t.accent });
ui.painter().text(done.center(), Align2::CENTER_CENTER, tr("Done"), crate::theme::semibold(11.5), t.accent_on);
resp.clicked()
}
/// A board tool's flyout: the colours, shapes or stamps it places, in a row. A pick chooses the
/// tool with it.
fn options_flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) {
let t = Tokens::get(ui.ctx());
let Some(&(tool, _)) = g.tools.first() else { return };
let items = crate::board_bar::choices(g.options, &app.session.tool.board);
let (cell, pad) = (32.0, 8.0);
let size = vec2(items.len() as f32 * (cell + 4.0) - 4.0 + 2.0 * pad, cell + 2.0 * pad);
let mut chosen = None;
egui::Area::new(flyout_id(g.key)).fixed_pos(pos2(bottom_left.x, bottom_left.y - size.y)).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
let (rect, _) = ui.allocate_exact_size(size, Sense::hover());
ui.painter().rect_filled(rect, CornerRadius::same(13), t.menu);
for (i, c) in items.iter().enumerate() {
let r = Rect::from_min_size(pos2(rect.min.x + pad + i as f32 * (cell + 4.0), rect.min.y + pad), vec2(cell, cell));
let resp = ui.interact(r, ui.scope_id().with(("opt", g.key, i)), Sense::click()).on_hover_text(tr(c.label));
a11y::menu_item(&resp, tr(c.label), "", Some(c.on));
a11y::own_focus(&resp);
if c.on || resp.hovered() || resp.has_focus() {
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
}
crate::board_bar::paint_choice(ui.painter(), r.shrink(5.0), &c.mark, t.menu_text);
if resp.clicked() {
chosen = Some(c.params.clone());
}
}
});
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
app.ui.tool_flyout = None;
}
if let Some(params) = chosen {
app.run(&format!("tool.{}", tool.id()), params);
app.ui.tool_flyout = None;
}
}
/// Draw a tool group's flyout above its button, with keyboard/hover highlighting, remembered
/// selection when a tool is clicked, and Escape to close.
fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) {
let t = Tokens::get(ui.ctx());
let item_h = 32.0;
let w = 220.0;
let h = item_h * g.tools.len() as f32 + 16.0;
let mut chosen = None;
egui::Area::new(flyout_id(g.key)).fixed_pos(pos2(bottom_left.x, bottom_left.y - h)).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
let (rect, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
ui.painter().rect_filled(rect, CornerRadius::same(13), t.menu);
let mut y = rect.min.y + 8.0;
for (tid, icon) in g.tools {
let r = Rect::from_min_size(pos2(rect.min.x + 8.0, y), vec2(w - 16.0, item_h));
let resp = ui.interact(r, ui.scope_id().with(("fly", tid.id())), Sense::click());
a11y::menu_item(&resp, tr(tid.label()), tid.shortcut(), Some(*tid == app.session.tool.tool));
// The keyboard's item is highlighted like the hovered one.
a11y::own_focus(&resp);
if resp.hovered() || resp.has_focus() {
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
}
if *tid == app.session.tool.tool {
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(12.0, 12.0)), Icon::Check, t.menu_text);
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 32.0, r.center().y), vec2(16.0, 16.0)), *icon, t.menu_text);
ui.painter().text(pos2(r.min.x + 48.0, r.center().y), Align2::LEFT_CENTER, tr(tid.label()), regular(12.0), t.menu_text);
ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, pretty_shortcut(tid.shortcut()), regular(12.0), t.menu_dim);
if resp.clicked() {
chosen = Some(*tid);
}
y += item_h;
}
});
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
app.ui.tool_flyout = None;
}
if let Some(tid) = chosen {
if let Some(i) = ToolId::ALL.iter().position(|x| *x == tid) {
ui.data_mut(|d| d.insert_temp(egui::Id::unique(("tb_last", g.key)), i as u8));
}
choose(app, tid);
app.ui.tool_flyout = None;
}
}
/// Choose `tool` from the toolbar. Dev Mode only inspects: a tool that edits leaves it for the
/// design.
fn choose(app: &mut FigApp, tool: ToolId) {
if app.ui.right_tab == crate::RightTab::Inspect && !matches!(tool, ToolId::Move | ToolId::Hand | ToolId::Comment) {
app.ui.right_tab = crate::RightTab::Design;
}
app.run("tool.set", json!({"tool": tool.id()}));
}
/// A tool group's flyout (an egui area).
fn flyout_id(key: &str) -> egui::Id {
egui::Id::unique(("flyout_area", key))
}
/// "Shift+Cmd+K" → "⇧⌘K" on macOS, "Ctrl+Shift+K" elsewhere.
pub fn pretty_shortcut(s: &str) -> String {
figcraft_engine::keymap::display(s, crate::shortcuts::platform())
}
#[cfg(test)]
mod tests {
use super::*;
use egui::Shape;
/// The stroke widths `icon` paints with in a `size` box at `ppp` pixels per point.
fn widths(ppp: f32, icon: Icon, size: f32) -> Vec<f32> {
let ctx = egui::Context::default();
let input = || {
let mut i = egui::RawInput::default();
i.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(ppp);
i
};
ctx.run_ui(input(), |_| {}).textures_delta.clear();
let mut out =
ctx.run_ui(input(), |ui| icons::paint(ui.painter(), Rect::from_center_size(pos2(50.0, 50.0), vec2(size, size)), icon, Color32::WHITE));
out.textures_delta.clear();
assert_eq!(ctx.pixels_per_point(), ppp);
let mut w: Vec<f32> = out
.shapes
.iter()
.filter_map(|c| match &c.shape {
Shape::LineSegment { stroke, .. } => Some(stroke.width),
Shape::Path(p) => Some(p.stroke.width),
Shape::Rect(r) => Some(r.stroke.width),
_ => None,
})
.filter(|w| *w > 0.0)
.collect();
w.dedup();
w
}
/// The flyout carets and the marks beside them draw with one stroke width (#737), and the
/// mode switcher's marks are the tool marks' size (#738).
#[test]
fn toolbar_marks_share_one_stroke_width() {
for ppp in [1.0, 1.5, 2.0] {
let mark = widths(ppp, Icon::Rectangle, GLYPH);
assert_eq!(mark.len(), 1, "one width at {ppp}×: {mark:?}");
assert_eq!(widths(ppp, Icon::Chevron, CARET), mark, "caret at {ppp}×");
assert_eq!(widths(ppp, Icon::Code, GLYPH), mark, "mode mark at {ppp}×");
for icon in [Icon::Frame, Icon::Text] {
assert_eq!(widths(ppp, icon, GLYPH), mark, "{icon:?} at {ppp}×");
}
}
}
}