refactor(ui): simplify app and presentation handlers

This commit is contained in:
Dylan M. Taylor
2026-10-28 16:00:36 +00:00
parent 252d909427
commit ffb4fc7e3e
29 changed files with 1271 additions and 977 deletions
+6 -1
View File
@@ -2,7 +2,12 @@ use super::clock::{AUTO_SAVE_RETRY_SECS, AUTO_SAVE_SECS, AUTOSAVE_SECS, CJK_CHAN
use super::open::OPEN_EXTENSIONS;
use super::services::{DialogFuture, PickFuture, Picked, RepaintWaker, Services};
use super::state::{LEFT_PANEL, LeftTab, MOBILE_STRIP, MobilePanel, NARROW_WIDTH, PANEL_DEFAULT, RIGHT_PANEL, RightTab, TitleBar, fit_panels};
use super::*;
use super::{
Arc, Done, FigApp, NodeId, OsStr, Path, PathBuf, PendingAck, PendingDialog, Poll, Session, TaskContext, UiState, Value, Waker, a11y, branch_ui,
canvas, cjk, comments, cursors, dev_compare, dialogs, frame_stats, home, home_recents, i18n, icons, import_report, json, layers, live, menus,
missing_fonts, online, plugins_ui, present, render_worker, right, selection_spacing, shortcuts, style_picker, tabs, theme, toolbar, ui_scale,
updates_ui, variable_ui, versions, widgets, windows,
};
mod clipboard;
mod commands;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::FigApp;
impl FigApp {
/// Put `text` on the system clipboard. Input injected through the control channel never
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Done, FigApp, LeftTab, MobilePanel, NodeId, PANEL_DEFAULT, RightTab, Value, comments, dev_compare, dialogs, home, i18n, import_report, json,
layers, live, menus, missing_fonts, plugins_ui, present, tabs, updates_ui, versions,
};
impl FigApp {
/// Dismiss the composer after a successful switch away from the Comment tool.
+3 -1
View File
@@ -1,4 +1,6 @@
use super::*;
use super::{
FigApp, PendingAck, Value, cursors, dev_compare, home_recents, import_report, json, live, missing_fonts, online, present, tabs, windows,
};
impl FigApp {
/// Queue raw input injected through the control channel for the window drawing next, tagged
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
AUTO_SAVE_RETRY_SECS, AUTO_SAVE_SECS, AUTOSAVE_SECS, Arc, DialogFuture, FigApp, OPEN_EXTENSIONS, OsStr, Path, PathBuf, PendingDialog, PickFuture,
Picked, Poll, RepaintWaker, Services, TaskContext, Value, Waker, dialogs, i18n, json, repaint_in, windows,
};
impl FigApp {
pub fn save_as_dialog(&mut self) {
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Arc, CJK_CHANGES, FigApp, LEFT_PANEL, MobilePanel, PANEL_DEFAULT, RIGHT_PANEL, RightTab, TOAST_SECS, ToastClock, Value, a11y, cjk, i18n, icons,
json, repaint_in, theme,
};
impl FigApp {
/// Show what `command params` would do on the canvas as a live preview (`text.preview`) while
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{FigApp, shortcuts};
impl FigApp {
/// The pass's one IME observation: the focused on-canvas editor, or an explicit idle.
+1 -1
View File
@@ -1,6 +1,6 @@
use super::outline::stroke;
use super::overlays::accent;
use super::*;
use super::{Color32, FigApp, Pos2, Shape, Tokens, pos2};
/// Width of the lines hatching the hovered and selected regions in vector edit mode (screen px).
const REGION_HATCH_WIDTH: f32 = 1.4;
+29 -25
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{FigApp, Point, PointerKind, Pos2, Rect, Ui, json, pointer_event};
/// The zoom tool while Z is held (Alt: zoom out): a click zooms a step about the pointer, a drag
/// zooms to the dragged area. Takes the pointer from the other tools while it's active.
@@ -25,30 +25,7 @@ fn zoom_tool(app: &mut FigApp, ui: &Ui, resp: &egui::Response, pos: Pos2, mods:
true
}
/// The pointer and keys on the canvas. False when something on it with a cursor of its own has
/// the pointer (a comment pin, a ruler guide, a gradient or padding handle): the tool's cursor
/// then doesn't show.
pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
if crate::proto_canvas::input(app, ui, resp) {
// A prototype gesture is a press on the canvas too: the Pages list gives up the keyboard.
app.ui.pages_focus = None;
return true;
}
let ctx = ui.ctx().clone();
// Two fingers pan as well as pinch (a touch screen has no scroll wheel).
let (mods, scroll, zoom_delta, hover_pos, space) = ctx.input(|i| {
let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |m| m.translation_delta);
(i.modifiers, i.smooth_scroll_delta + pan, i.zoom_delta(), i.pointer.hover_pos(), i.key_down(egui::Key::Space))
});
let editing_text = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false);
let typing_elsewhere = crate::a11y::field_focused(&ctx);
app.canvas.space_pan = space && !editing_text && !typing_elsewhere;
if !app.canvas.dragging && crate::selection_spacing::input(app, &ctx, hover_pos, resp.hovered(), app.canvas.space_pan) {
return false;
}
// Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]);
// a dropped document opens as a file of its own, as on the home screen. Dev Mode imports
// nothing into the design, and says so once for the whole drop.
fn import_dropped(app: &mut FigApp, ctx: &egui::Context, hover_pos: Option<egui::Pos2>) {
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
let mut refused = false;
for f in dropped {
@@ -75,6 +52,33 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo
};
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target), read });
}
}
/// The pointer and keys on the canvas. False when something on it with a cursor of its own has
/// the pointer (a comment pin, a ruler guide, a gradient or padding handle): the tool's cursor
/// then doesn't show.
pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
if crate::proto_canvas::input(app, ui, resp) {
// A prototype gesture is a press on the canvas too: the Pages list gives up the keyboard.
app.ui.pages_focus = None;
return true;
}
let ctx = ui.ctx().clone();
// Two fingers pan as well as pinch (a touch screen has no scroll wheel).
let (mods, scroll, zoom_delta, hover_pos, space) = ctx.input(|i| {
let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |m| m.translation_delta);
(i.modifiers, i.smooth_scroll_delta + pan, i.zoom_delta(), i.pointer.hover_pos(), i.key_down(egui::Key::Space))
});
let editing_text = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false);
let typing_elsewhere = crate::a11y::field_focused(&ctx);
app.canvas.space_pan = space && !editing_text && !typing_elsewhere;
if !app.canvas.dragging && crate::selection_spacing::input(app, &ctx, hover_pos, resp.hovered(), app.canvas.space_pan) {
return false;
}
// Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]);
// a dropped document opens as a file of its own, as on the home screen. Dev Mode imports
// nothing into the design, and says so once for the whole drop.
import_dropped(app, &ctx, hover_pos);
let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else {
// A drag released off the canvas, or off the window, ends where the pointer was last seen.
if app.canvas.dragging && !ctx.input(|i| i.pointer.primary_down()) {
+3 -1
View File
@@ -1,4 +1,6 @@
use super::*;
use super::{
Arc, COMPONENT, Color32, CornerRadius, Document, FigApp, NodeId, NodeKind, Point, Pos2, Rect, Shape, Stroke, pos2, regular, semibold, tr, vec2,
};
pub(super) fn stroke(c: Color32, w: f32) -> Stroke {
Stroke::new(w, c)
+218 -177
View File
@@ -1,7 +1,10 @@
use super::builder::{builder_overlay, hatched, region_hatch};
use super::outline::{TextOnScreen, draw_outline, node_quad, outline_colour, quad, slot_badges, stroke, swap_highlight};
use super::rulers::{boards, fmt, rulers};
use super::*;
use super::{
Align2, COMPONENT, Color32, CornerRadius, Document, FigApp, NodeId, NodeKind, Point, Pos2, Rect, Shape, Stroke, StrokeKind, Tokens, ToolId,
doc_c32, pos2, selection_box, selection_frame, semibold, tr, vec2,
};
/// Everything drawn over the rendered page; returns the frame labels drawn (frame, corners on the
/// page) for the engine to hit-test.
@@ -15,22 +18,7 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
let view = ds.view.clone();
// Pixel grid at high zoom.
if view.pixel_grid_shown() {
let r = app.canvas.rect;
let p0 = app.to_page(r.min);
let p1 = app.to_page(r.max);
let col = Color32::from_black_alpha(18);
let mut x = p0.x.floor();
while x <= p1.x {
let s = app.to_screen(x, 0.0).x;
painter.line_segment([pos2(s, r.min.y), pos2(s, r.max.y)], stroke(col, 1.0));
x += 1.0;
}
let mut y = p0.y.floor();
while y <= p1.y {
let s = app.to_screen(0.0, y).y;
painter.line_segment([pos2(r.min.x, s), pos2(r.max.x, s)], stroke(col, 1.0));
y += 1.0;
}
pixel_grid(app, painter);
}
// Layout grids and frame labels for top-level frames (also those in sections); the focus view
// labels only its frame.
@@ -44,20 +32,7 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
continue;
}
if view.layout_grids && !n.grids.is_empty() && figcraft_layout::grids_shown(&doc, *c) {
let w = doc.world_transform(*c);
for g in n.grids.iter().filter(|g| g.visible) {
let col = doc_c32(painter.ctx(), g.color);
for r in figcraft_layout::grid_lines(g, n.width, n.height) {
let a = w * Point::new(r.x0, r.y0);
let b = w * Point::new(r.x1, r.y1);
let (sa, sb) = (app.to_screen(a.x, a.y), app.to_screen(b.x, b.y));
if g.kind == figcraft_doc::GridKind::Grid {
painter.line_segment([sa, sb], stroke(col, 1.0));
} else {
painter.rect_filled(Rect::from_two_pos(sa, sb), CornerRadius::ZERO, col);
}
}
}
layout_grids(app, painter, &doc, *c, n);
}
// The focus view's bar names its frame.
// Board objects (stickies, shapes with text, stamps) are frames without a name label.
@@ -85,19 +60,7 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
}
// Mask outlines (a mask doesn't draw itself), frame outlines and slices, as the View menu shows.
if view.mask_outlines || view.frame_outlines || view.show_slices {
for id in doc.subtree(page) {
let Some(n) = doc.node(id).filter(|n| n.visible) else { continue };
if view.mask_outlines && n.is_mask {
draw_outline(app, painter, &doc, id, stroke(accent(painter), 1.0));
} else if view.frame_outlines && n.kind.is_frame_like() {
draw_outline(app, painter, &doc, id, stroke(t.text_secondary, 1.0));
} else if view.show_slices
&& matches!(n.kind, NodeKind::Slice)
&& let Some(q) = node_quad(app, &doc, id)
{
painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(t.text_secondary, 1.0), 4.0, 3.0));
}
}
view_outlines(app, painter, t, ds, &doc);
}
// Drop target / insertion indicator.
let ov = &app.session.tool.overlay;
@@ -148,82 +111,7 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
// Selection.
let editing = ds.text_edit.clone();
if !sel.is_empty() && editing.is_none() && ds.vector_edit.is_none() {
let col = if sel.iter().any(|i| outline_colour(&doc, *i, accent(painter)) == COMPONENT) { COMPONENT } else { accent(painter) };
for id in &sel {
draw_outline(app, painter, &doc, *id, stroke(col, 1.0));
}
// The shape builder reshapes by regions: no transform box.
if app.session.tool.tool != ToolId::ShapeBuilder
&& let Some(b) = selection_box(&doc, &sel)
&& let Some((_, w, h)) = selection_frame(&doc, &sel)
{
let is_line = sel.len() == 1 && matches!(sel.first().and_then(|i| doc.node(*i)).map(|n| &n.kind), Some(NodeKind::Line));
// A selected connector's handles are its two ends (drag one onto another object).
let connector = figcraft_engine::board::selected_connector_ends(&doc, &sel).map(|(_, ends)| ends);
let mut label = if is_line { fmt(w) } else { format!("{} × {}", fmt(w), fmt(h)) };
let scale = app.session.tool.overlay.scale;
if let Some(hint) = scale {
label = format!("{label} · {}x", fmt(hint.factor.abs()));
}
// A layer smaller than its handles on screen shows just its outline (they still answer
// just outside it).
let tiny = figcraft_engine::tools::box_sides_px(&b, zoom).1 < figcraft_engine::tools::SMALL_BOX_PX;
// Measuring to another layer (Alt held) shows just the outlines: no handles or size.
let measuring = app.session.tool.overlay.measure_box.is_some();
// Locked layers (selected in the layers panel) can't be resized, nor anything in Dev
// Mode: no handles either.
let handles =
if measuring || app.session.tool.dev_mode || connector.is_some() || sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) {
0
} else if is_line {
2
} else if tiny {
0
} else {
4
};
size_label = transform_box(
app,
painter,
b.map(|p| app.to_screen(p.x, p.y)),
col,
sel.len() > 1,
handles,
(!measuring && connector.is_none()).then_some(label),
);
for end in connector.into_iter().flatten().filter(|_| !app.session.tool.dev_mode) {
let c = app.to_screen(end.x, end.y);
painter.circle_filled(c, 5.0, Color32::WHITE);
painter.circle_stroke(c, 5.0, stroke(col, 1.5));
}
if !measuring {
arc_overlay(app, painter, col);
shape_overlay(app, painter, col);
}
if app.session.tool.tool == ToolId::Scale {
// The point that stays put: the dragged pivot, else the anchor.
let pivot = scale
.map(|h| Point::new(h.pivot[0], h.pivot[1]))
.or_else(|| figcraft_engine::tools::scale_anchor_point(&doc, &sel, app.session.tool.scale_anchor));
if let Some(p) = pivot {
let c = app.to_screen(p.x, p.y);
painter.circle_filled(c, 4.0, Color32::WHITE);
painter.circle_stroke(c, 4.0, stroke(col, 1.5));
painter.circle_filled(c, 1.5, col);
}
}
// The rotation origin while it shows (Alt+R): a ring with a cross through it.
if let Some(o) = ds.origin_handle() {
let c = app.to_screen(o.x, o.y);
painter.circle_filled(c, 5.0, Color32::WHITE);
painter.circle_stroke(c, 5.0, stroke(col, 1.5));
for d in [vec2(1.0, 0.0), vec2(0.0, 1.0)] {
painter.line_segment([c - d * 10.0, c - d * 5.0], stroke(col, 1.5));
painter.line_segment([c + d * 5.0, c + d * 10.0], stroke(col, 1.5));
}
painter.circle_filled(c, 1.5, col);
}
}
size_label = selection_overlay(app, painter, ds, &doc, &sel, zoom);
}
if app.session.tool.tool == ToolId::ShapeBuilder
&& let Some(arr) = figcraft_engine::shapebuilder::current(&app.session)
@@ -231,43 +119,8 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
builder_overlay(app, painter, t, &arr);
}
// Text editing: caret and selection, or the IME composition underlined at the caret.
let preedit = app.preedit_view();
let edoc: &Document = preedit.map(|p| &*p.doc).unwrap_or(&doc);
if let Some(te) = editing
&& let Some(text) = TextOnScreen::of(edoc, te.id)
{
let layout = &text.layout;
if let Some(q) = node_quad(app, edoc, te.id) {
quad(painter, q, stroke(accent(painter), 1.0));
}
let (a, b) = (te.caret.min(te.anchor), te.caret.max(te.anchor));
if a != b && preedit.is_none() {
for q in text.selection(app, a, b) {
painter.add(Shape::convex_polygon(q.to_vec(), accent(painter).gamma_multiply(0.3), Stroke::NONE));
}
}
if let Some(p) = preedit {
for r in layout.selection_rects(p.range.0, p.range.1) {
// Underline just below the baseline, across the composed chars only.
let Some(line) = layout.lines.iter().find(|l| (l.top - r.y0).abs() < 1e-6) else { continue };
let x1 = layout
.glyphs
.get(line.glyph_start..line.glyph_end)
.unwrap_or(&[])
.iter()
.filter(|g| g.char_end > g.char_start && g.char_start >= p.range.0 && g.char_end <= p.range.1)
.map(|g| g.x + g.advance)
.fold(r.x0, f64::max);
let y = line.baseline + (line.top + line.height - line.baseline) * 0.35;
painter.line_segment([text.at(app, r.x0, y), text.at(app, x1, y)], stroke(t.text, 1.0));
}
}
let caret = preedit.map(|p| p.cursor).unwrap_or(te.caret);
let blink = (painter.ctx().input(|i| i.time) * 2.0) as i64 % 2 == 0;
if blink || a != b {
painter.line_segment(text.caret(app, caret, zoom), stroke(t.text, 1.0));
}
painter.ctx().request_repaint_after(std::time::Duration::from_millis(500));
if let Some(te) = editing {
text_edit_overlay(app, painter, t, &doc, &te, zoom);
}
// Smart guides and measurements (their labels kept clear of the size label).
crate::measure_overlay::draw(app, painter, t, size_label);
@@ -278,6 +131,198 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
painter.rect_stroke(r, CornerRadius::ZERO, stroke(accent(painter), 1.0), StrokeKind::Inside);
}
// Pen path.
pen_overlay(app, painter, zoom);
// Eraser brush (vector edit mode).
if let Some([x, y, r]) = ov.brush {
let c = app.to_screen(x, y);
let rs = (r * app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0)) as f32;
painter.circle_stroke(c, rs, stroke(Color32::WHITE, 3.0));
painter.circle_stroke(c, rs, stroke(accent(painter), 1.5));
}
// Lasso (vector edit mode).
if ov.lasso.len() > 1 {
let mut pts: Vec<Pos2> = ov.lasso.iter().map(|[x, y]| app.to_screen(*x, *y)).collect();
if let Some(first) = pts.first().copied() {
pts.push(first);
}
painter.add(Shape::dashed_line(&pts, stroke(accent(painter), 1.0), 4.0, 3.0));
}
// Guides (under the rulers).
crate::guides::draw(app, painter);
// Rulers.
if view.rulers {
rulers(app, painter, t, &doc, &sel);
}
labels
}
/// Pixel grid at high zoom.
fn pixel_grid(app: &FigApp, painter: &egui::Painter) {
let r = app.canvas.rect;
let p0 = app.to_page(r.min);
let p1 = app.to_page(r.max);
let col = Color32::from_black_alpha(18);
let mut x = p0.x.floor();
while x <= p1.x {
let s = app.to_screen(x, 0.0).x;
painter.line_segment([pos2(s, r.min.y), pos2(s, r.max.y)], stroke(col, 1.0));
x += 1.0;
}
let mut y = p0.y.floor();
while y <= p1.y {
let s = app.to_screen(0.0, y).y;
painter.line_segment([pos2(r.min.x, s), pos2(r.max.x, s)], stroke(col, 1.0));
y += 1.0;
}
}
/// The layout grids of frame `c` (`n`).
fn layout_grids(app: &FigApp, painter: &egui::Painter, doc: &Document, c: NodeId, n: &figcraft_doc::Node) {
let w = doc.world_transform(c);
for g in n.grids.iter().filter(|g| g.visible) {
let col = doc_c32(painter.ctx(), g.color);
for r in figcraft_layout::grid_lines(g, n.width, n.height) {
let a = w * Point::new(r.x0, r.y0);
let b = w * Point::new(r.x1, r.y1);
let (sa, sb) = (app.to_screen(a.x, a.y), app.to_screen(b.x, b.y));
if g.kind == figcraft_doc::GridKind::Grid {
painter.line_segment([sa, sb], stroke(col, 1.0));
} else {
painter.rect_filled(Rect::from_two_pos(sa, sb), CornerRadius::ZERO, col);
}
}
}
}
fn text_edit_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Document, te: &figcraft_engine::TextEdit, zoom: f64) {
let preedit = app.preedit_view();
let edoc: &Document = preedit.map(|p| &*p.doc).unwrap_or(doc);
let Some(text) = TextOnScreen::of(edoc, te.id) else { return };
let layout = &text.layout;
if let Some(q) = node_quad(app, edoc, te.id) {
quad(painter, q, stroke(accent(painter), 1.0));
}
let (a, b) = (te.caret.min(te.anchor), te.caret.max(te.anchor));
if a != b && preedit.is_none() {
for q in text.selection(app, a, b) {
painter.add(Shape::convex_polygon(q.to_vec(), accent(painter).gamma_multiply(0.3), Stroke::NONE));
}
}
if let Some(p) = preedit {
for r in layout.selection_rects(p.range.0, p.range.1) {
// Underline just below the baseline, across the composed chars only.
let Some(line) = layout.lines.iter().find(|l| (l.top - r.y0).abs() < 1e-6) else { continue };
let x1 = layout
.glyphs
.get(line.glyph_start..line.glyph_end)
.unwrap_or(&[])
.iter()
.filter(|g| g.char_end > g.char_start && g.char_start >= p.range.0 && g.char_end <= p.range.1)
.map(|g| g.x + g.advance)
.fold(r.x0, f64::max);
let y = line.baseline + (line.top + line.height - line.baseline) * 0.35;
painter.line_segment([text.at(app, r.x0, y), text.at(app, x1, y)], stroke(t.text, 1.0));
}
}
let caret = preedit.map(|p| p.cursor).unwrap_or(te.caret);
let blink = (painter.ctx().input(|i| i.time) * 2.0) as i64 % 2 == 0;
if blink || a != b {
painter.line_segment(text.caret(app, caret, zoom), stroke(t.text, 1.0));
}
painter.ctx().request_repaint_after(std::time::Duration::from_millis(500));
}
/// The selection's outlines, transform box and handles; returns the size label's box.
fn selection_overlay(
app: &FigApp,
painter: &egui::Painter,
ds: &figcraft_engine::DocState,
doc: &Document,
sel: &[NodeId],
zoom: f64,
) -> Option<Rect> {
let mut size_label = None;
let col = if sel.iter().any(|i| outline_colour(doc, *i, accent(painter)) == COMPONENT) { COMPONENT } else { accent(painter) };
for id in sel {
draw_outline(app, painter, doc, *id, stroke(col, 1.0));
}
// The shape builder reshapes by regions: no transform box.
if app.session.tool.tool != ToolId::ShapeBuilder
&& let Some(b) = selection_box(doc, sel)
&& let Some((_, w, h)) = selection_frame(doc, sel)
{
let is_line = sel.len() == 1 && matches!(sel.first().and_then(|i| doc.node(*i)).map(|n| &n.kind), Some(NodeKind::Line));
// A selected connector's handles are its two ends (drag one onto another object).
let connector = figcraft_engine::board::selected_connector_ends(doc, sel).map(|(_, ends)| ends);
let mut label = if is_line { fmt(w) } else { format!("{} × {}", fmt(w), fmt(h)) };
let scale = app.session.tool.overlay.scale;
if let Some(hint) = scale {
label = format!("{label} · {}x", fmt(hint.factor.abs()));
}
// A layer smaller than its handles on screen shows just its outline (they still answer
// just outside it).
let tiny = figcraft_engine::tools::box_sides_px(&b, zoom).1 < figcraft_engine::tools::SMALL_BOX_PX;
// Measuring to another layer (Alt held) shows just the outlines: no handles or size.
let measuring = app.session.tool.overlay.measure_box.is_some();
// Locked layers (selected in the layers panel) can't be resized, nor anything in Dev
// Mode: no handles either.
let handles =
if measuring || app.session.tool.dev_mode || connector.is_some() || sel.iter().any(|i| figcraft_engine::tools::locked_in(doc, *i)) {
0
} else if is_line {
2
} else if tiny {
0
} else {
4
};
size_label = transform_box(
app,
painter,
b.map(|p| app.to_screen(p.x, p.y)),
col,
sel.len() > 1,
handles,
(!measuring && connector.is_none()).then_some(label),
);
for end in connector.into_iter().flatten().filter(|_| !app.session.tool.dev_mode) {
let c = app.to_screen(end.x, end.y);
painter.circle_filled(c, 5.0, Color32::WHITE);
painter.circle_stroke(c, 5.0, stroke(col, 1.5));
}
if !measuring {
arc_overlay(app, painter, col);
shape_overlay(app, painter, col);
}
if app.session.tool.tool == ToolId::Scale {
// The point that stays put: the dragged pivot, else the anchor.
let pivot = scale
.map(|h| Point::new(h.pivot[0], h.pivot[1]))
.or_else(|| figcraft_engine::tools::scale_anchor_point(doc, sel, app.session.tool.scale_anchor));
if let Some(p) = pivot {
let c = app.to_screen(p.x, p.y);
painter.circle_filled(c, 4.0, Color32::WHITE);
painter.circle_stroke(c, 4.0, stroke(col, 1.5));
painter.circle_filled(c, 1.5, col);
}
}
// The rotation origin while it shows (Alt+R): a ring with a cross through it.
if let Some(o) = ds.origin_handle() {
let c = app.to_screen(o.x, o.y);
painter.circle_filled(c, 5.0, Color32::WHITE);
painter.circle_stroke(c, 5.0, stroke(col, 1.5));
for d in [vec2(1.0, 0.0), vec2(0.0, 1.0)] {
painter.line_segment([c - d * 10.0, c - d * 5.0], stroke(col, 1.5));
painter.line_segment([c + d * 5.0, c + d * 10.0], stroke(col, 1.5));
}
painter.circle_filled(c, 1.5, col);
}
}
size_label
}
fn pen_overlay(app: &FigApp, painter: &egui::Painter, zoom: f64) {
let ov = &app.session.tool.overlay;
if !ov.pen.is_empty() {
let pts: Vec<Pos2> = ov.pen.iter().map(|[x, y]| app.to_screen(*x, *y)).collect();
if app.session.tool.tool == ToolId::Pencil {
@@ -315,28 +360,24 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
// A connector being drawn, or one of its ends dragged: from where it starts to the pointer.
painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 1.5));
}
// Eraser brush (vector edit mode).
if let Some([x, y, r]) = ov.brush {
let c = app.to_screen(x, y);
let rs = (r * app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0)) as f32;
painter.circle_stroke(c, rs, stroke(Color32::WHITE, 3.0));
painter.circle_stroke(c, rs, stroke(accent(painter), 1.5));
}
// Lasso (vector edit mode).
if ov.lasso.len() > 1 {
let mut pts: Vec<Pos2> = ov.lasso.iter().map(|[x, y]| app.to_screen(*x, *y)).collect();
if let Some(first) = pts.first().copied() {
pts.push(first);
}
/// Mask outlines, frame outlines and slices, as the View menu shows.
fn view_outlines(app: &FigApp, painter: &egui::Painter, t: &Tokens, ds: &figcraft_engine::DocState, doc: &Document) {
let (view, page) = (&ds.view, ds.page);
for id in doc.subtree(page) {
let Some(n) = doc.node(id).filter(|n| n.visible) else { continue };
if view.mask_outlines && n.is_mask {
draw_outline(app, painter, doc, id, stroke(accent(painter), 1.0));
} else if view.frame_outlines && n.kind.is_frame_like() {
draw_outline(app, painter, doc, id, stroke(t.text_secondary, 1.0));
} else if view.show_slices
&& matches!(n.kind, NodeKind::Slice)
&& let Some(q) = node_quad(app, doc, id)
{
painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(t.text_secondary, 1.0), 4.0, 3.0));
}
painter.add(Shape::dashed_line(&pts, stroke(accent(painter), 1.0), 4.0, 3.0));
}
// Guides (under the rulers).
crate::guides::draw(app, painter);
// Rulers.
if view.rulers {
rulers(app, painter, t, &doc, &sel);
}
labels
}
/// A transform box (screen corners tl, tr, br, bl): its outline when `outline`, the first `handles`
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, CanvasState, Done, FigApp, Job, NodeId, Worker};
#[derive(Clone, PartialEq)]
pub(super) struct JobKey {
+1 -1
View File
@@ -1,6 +1,6 @@
use super::outline::stroke;
use super::overlays::accent;
use super::*;
use super::{Align2, Color32, CornerRadius, Document, FigApp, NodeId, NodeKind, Pos2, Rect, Tokens, pos2, regular, semibold, tr, vec2};
/// The layers that get canvas labels and layout grids, in z-order: the page's visible top-level
/// layers and, inside sections whose contents show, their visible children (sections nest). A
+415 -392
View File
@@ -924,6 +924,414 @@ fn b(p: &Value, k: &str) -> bool {
p.get(k).and_then(Value::as_bool).unwrap_or(false)
}
fn ui_key(app: &mut FigApp, ctx: &Context, p: &Value) -> Result<Value, String> {
let key = p.get("key").and_then(Value::as_str).ok_or("`key` required")?;
let m = figcraft_engine::keymap::Mods { shift: b(p, "shift"), alt: b(p, "alt"), cmd: b(p, "cmd"), ctrl: b(p, "ctrl") };
let platform = crate::shortcuts::platform();
use figcraft_engine::keymap::{Context, lookup, normalize};
// Text editing keys go to the editor (after the shortcuts live while editing).
if app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false) {
if let Some(bd) = lookup(platform, Context::Text, key, m).filter(|_| m.cmd || m.alt || m.ctrl) {
crate::menus::invoke(app, bd.id);
return Ok(json!({"command": bd.id}));
}
let (word, line) = figcraft_engine::keymap::text_motion(platform, key, m);
figcraft_engine::textedit::key(&mut app.session, key, m.shift, word, line).map_err(|e| e.to_string())?;
return Ok(json!({"ok": true}));
}
// Vector edit mode and shape builder keys (tools, nudging points, delete, join, Enter).
if app.session.tool_key(key, m.shift, m.alt, m.cmd).map_err(|e| e.to_string())? {
return Ok(json!({"ok": true}));
}
// The Comment tool's arrows step through the comment threads.
if app.session.tool.tool == figcraft_engine::ToolId::Comment && m.is_none() && matches!(key, "ArrowUp" | "ArrowDown" | "Up" | "Down") {
let r = crate::comments::step(app, ctx, if matches!(key, "ArrowUp" | "Up") { -1 } else { 1 });
return Ok(json!({"comment": r.and_then(|v| v.get("id").cloned())}));
}
if let Some(bd) = lookup(platform, Context::Canvas, key, m) {
crate::shortcuts::run_binding(app, bd.id);
return Ok(json!({"command": bd.id}));
}
let (name, _) = normalize(key).ok_or_else(|| format!("unknown key `{key}`"))?;
match name {
"ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" => {
let (dx, dy) = match name {
"ArrowLeft" => (-1, 0),
"ArrowRight" => (1, 0),
"ArrowUp" => (0, -1),
_ => (0, 1),
};
let (cmd, params) = if m.cmd || m.ctrl {
("transform.nudgeSize", json!({"dw": dx, "dh": dy, "big": m.shift}))
} else {
("transform.nudge", json!({"dx": dx, "dy": dy, "big": m.shift}))
};
app.session.execute(cmd, &params).map_err(|e| e.to_string())
}
_ => {
// Number keys: opacity (two in quick succession make an exact percentage). A
// hidden window's logic tick has a stale `ctx.input(i.time)`.
let now = app.clock.now();
match egui::Key::from_name(name) {
Some(k) if m.is_none() && crate::shortcuts::opacity_digit(app, k, now) => Ok(json!({"opacity": true})),
_ => Err(format!("no shortcut `{key}`")),
}
}
}
}
fn ui_input(app: &mut FigApp, ctx: &Context, p: &Value) -> Result<Value, String> {
let list = p
.get("events")
.and_then(Value::as_array)
.ok_or("`events` required: [{move|down|up: [x, y]} | {key, shift?, alt?, cmd?} | {text} | {leave: true}]")?;
let pos = |v: &Value| -> Option<egui::Pos2> {
let a = v.as_array()?;
let p = egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32);
p.is_finite().then_some(p)
};
let mut events = Vec::new();
let mut pads = Vec::new();
for e in list.iter().take(256) {
let button =
if e.get("button").and_then(Value::as_str) == Some("secondary") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary };
let command = b(e, "cmd") || b(e, "ctrl");
let mods = egui::Modifiers { alt: b(e, "alt"), ctrl: command, shift: b(e, "shift"), mac_cmd: false, command };
if let Some(p) = e.get("move").and_then(pos) {
events.push(egui::Event::PointerMoved(p));
} else if let Some((p, pressed)) = e.get("down").and_then(pos).map(|p| (p, true)).or_else(|| e.get("up").and_then(pos).map(|p| (p, false))) {
events.push(egui::Event::PointerMoved(p));
events.push(egui::Event::PointerButton { pos: p, button, pressed, modifiers: mods });
} else if let Some(k) = e.get("key").and_then(Value::as_str) {
let key = egui::Key::from_name(k).ok_or_else(|| format!("unknown key `{k}`"))?;
for pressed in [true, false] {
events.push(egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: mods });
}
} else if let Some(t) = e.get("text").and_then(Value::as_str) {
events.push(egui::Event::Text(t.to_string()));
} else if let Some(d) = e.get("scroll").and_then(pos) {
// Content moves by `d` points (negative y scrolls down, like a wheel turned towards you).
events.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
source: egui::MouseWheelSource::Wheel,
delta: d.to_vec2(),
phase: egui::TouchPhase::Move,
modifiers: mods,
});
} else if let Some(g) = e.get("gamepad") {
let names = figcraft_doc::proto::GAMEPAD_BUTTONS.join(", ");
pads.push(pad_name(g).ok_or_else(|| format!("unknown controller button {g} (an index 0-16 or one of {names})"))?.to_string());
} else if b(e, "leave") {
events.push(egui::Event::PointerGone);
} else if let Some(h) = e.get("hold") {
let held = egui::Modifiers {
alt: b(h, "alt"),
ctrl: b(h, "cmd") || b(h, "ctrl"),
shift: b(h, "shift"),
mac_cmd: false,
command: b(h, "cmd") || b(h, "ctrl"),
};
events.push(egui::Event::ModifiersChanged(held));
} else {
return Err(format!("unknown input event {e}"));
}
}
// Controller buttons go straight to the prototype playing (its next frame takes them).
if !pads.is_empty() {
match (app.ui.present.as_mut(), app.ui.preview.as_mut()) {
(Some(p), _) => p.pads.extend(pads),
(None, Some(pane)) => pane.player.pads.extend(pads),
(None, None) => return Err("controller buttons go to a prototype that is playing: run proto.present or proto.preview".into()),
}
}
app.control_input_queued = app.push_control_input(events);
ctx.request_repaint();
Ok(json!({"queued": list.len().min(256)}))
}
fn ui_set_view(app: &mut FigApp, ctx: &Context, p: &Value) -> Result<(), String> {
if let Some(t) = p.get("rightTab").and_then(Value::as_str) {
app.ui.right_tab = match t {
"prototype" => crate::RightTab::Prototype,
"inspect" => crate::RightTab::Inspect,
_ => crate::RightTab::Design,
};
}
if let Some(t) = p.get("leftTab").and_then(Value::as_str) {
app.ui.left_tab = if t == "assets" { crate::LeftTab::Assets } else { crate::LeftTab::File };
}
if let Some(t) = p.get("theme").and_then(Value::as_str) {
app.session.prefs.theme = t.to_string();
}
if let Some(h) = p.get("home").and_then(Value::as_bool) {
app.ui.home = h;
}
if let Some(section) = p.get("homeSection").and_then(Value::as_str).and_then(crate::home::Section::parse) {
app.ui.home_view.section = section;
}
if let Some(query) = p.get("homeSearch").and_then(Value::as_str) {
app.ui.home_view.file_query = query.chars().take(256).collect();
}
if let Some(view) = p.get("homeRecentsView").and_then(Value::as_str).and_then(crate::home_recents::View::parse) {
view.store(&mut app.session.prefs);
}
if let Some(key) = p.get("homeRecentsSort").and_then(Value::as_str).and_then(crate::home_recents::SortKey::parse) {
crate::home_recents::Sort::by(key).store(&mut app.session.prefs);
}
if let Some(order) = p.get("homeRecentsOrder").and_then(Value::as_str).filter(|o| matches!(*o, "asc" | "desc")) {
let sort = crate::home_recents::Sort::of(&app.session.prefs);
crate::home_recents::Sort { descending: order == "desc", ..sort }.store(&mut app.session.prefs);
}
if let Some(h) = p.get("palette").and_then(Value::as_bool) {
crate::menus::set_palette(&mut app.ui, h);
}
// true / false, or a top-level menu to open it at ("Plugins").
match p.get("mainMenu") {
Some(Value::Bool(h)) => app.ui.main_menu = *h,
Some(Value::String(top)) => {
app.ui.main_menu = true;
crate::menus::open_main_menu(ctx, top);
}
_ => {}
}
Ok(())
}
fn ui_set_plugins(app: &mut FigApp, ctx: &Context, p: &Value) -> Result<(), String> {
if let Some(h) = p.get("pluginsWindow").and_then(Value::as_bool) {
app.ui.plugins_window = h;
}
// The Plugins window's tab ("installed", "browse"), and the plugin of the listed
// directory whose install asks for confirmation (null: none).
if let Some(tab) = p.get("pluginsTab").and_then(Value::as_str) {
app.ui.plugins_manage.tab = if tab == "browse" { crate::plugins_ui::PluginsTab::Browse } else { crate::plugins_ui::PluginsTab::Installed };
}
match p.get("pluginConfirm") {
Some(Value::String(id)) => app.ui.plugins_manage.confirm = Some(id.clone()),
Some(Value::Null) => app.ui.plugins_manage.confirm = None,
_ => {}
}
// A plugin command's parameters form: {id, command?}; null closes it.
match p.get("pluginForm") {
Some(Value::Null) => app.ui.plugin_form = None,
Some(v @ Value::Object(_)) => {
let id = v.get("id").and_then(Value::as_str).ok_or("pluginForm: {id, command?}")?;
let command = v.get("command").and_then(Value::as_str).unwrap_or_default();
crate::plugins_ui::start(app, id, command);
}
_ => {}
}
// Give a region the keyboard as F6 would: toolbar, layers, canvas or properties.
if let Some(r) = p.get("focusRegion").and_then(Value::as_str) {
app.ui.nav.go(crate::a11y::Region::parse(r).ok_or_else(|| format!("unknown region `{r}`"))?);
}
// A right-click on the canvas at screen [sx, sy] (null closes the menu);
// `contextSelectLayer` opens its Select layer submenu.
match p.get("contextMenu") {
Some(Value::Array(at)) => {
let (x, y) = (at.first().and_then(Value::as_f64).unwrap_or(0.0), at.get(1).and_then(Value::as_f64).unwrap_or(0.0));
crate::canvas::open_context_menu(app, egui::pos2(x as f32, y as f32), b(p, "cmd"));
}
Some(Value::Null) => app.ui.context_menu = None,
_ => {}
}
if let Some(v) = p.get("contextSelectLayer").and_then(Value::as_bool) {
crate::menus::open_select_layer(ctx, v);
}
if let Some(h) = p.get("iconGallery").and_then(Value::as_bool) {
app.ui.icon_gallery = h;
}
Ok(())
}
fn ui_set_comments(app: &mut FigApp, ctx: &Context, p: &Value) -> Result<(), String> {
// Comment thread popover: an id opens it, null closes it.
if let Some(v) = p.get("commentOpen") {
match v.as_u64() {
Some(id) => {
// Pins are drawn for the current page only: reveal the thread's page first.
let page = app.session.doc().ok().and_then(|d| d.comment(id)).map(|c| c.page);
if let Some(page) = page.filter(|p| app.session.page().is_ok_and(|cur| cur != *p)) {
app.session.execute("page.select", &json!({"id": page.0})).map_err(|e| e.to_string())?;
}
app.ui.comments.show(id);
}
None => app.ui.comments.close(),
}
}
if let Some(v) = p.get("commentDraft") {
match (v.get("x").and_then(Value::as_f64), v.get("y").and_then(Value::as_f64)) {
(Some(x), Some(y)) => crate::comments::start_draft(app, x, y),
_ => app.ui.comments.draft = None,
}
}
if let Some(v) = p.get("showResolvedComments").and_then(Value::as_bool) {
app.ui.comments.filter = if v { Filter::All } else { Filter::Open };
}
if let Some(v) = p.get("commentFilter").and_then(Value::as_str) {
app.ui.comments.filter = Filter::parse(v).ok_or("commentFilter: open|resolved|mine|unread|all")?;
}
if let Some(v) = p.get("commentSort").and_then(Value::as_str) {
app.ui.comments.sort = Sort::parse(v).ok_or("commentSort: date|page")?;
}
if let Some(v) = p.get("commentSearch").and_then(Value::as_str) {
app.ui.comments.search = v.to_string();
}
// The open thread's reply field, with the cursor at its end (an `@` there offers names).
if let Some(v) = p.get("commentReply").and_then(Value::as_str) {
app.ui.comments.reply = v.to_string();
if let Some(id) = app.ui.comments.open {
crate::comments::focus_reply(ctx, id, v.chars().count());
}
}
// Edit a message of the open thread: its index (0: the first message); null stops.
if let Some(v) = p.get("commentEdit") {
app.ui.comments.editing = match (app.ui.comments.open, v.as_u64()) {
(Some(id), Some(i)) => {
let text = app.session.doc().ok().and_then(|d| d.comment(id)).and_then(|c| c.message(i as usize)).map(|m| m.text.clone());
Some((id, i as usize, text.ok_or("commentEdit: no such message")?))
}
_ => None,
};
}
if let Some(v) = p.get("presentComments").and_then(Value::as_bool) {
app.ui.present_view.comments = v;
if let Some(pres) = app.ui.present.as_mut() {
pres.view.comments = v;
}
}
Ok(())
}
fn ui_set_panels(app: &mut FigApp, ctx: &Context, p: &Value) -> Result<(), String> {
// Colour picker on the selection's paint `{fills?, index?}`; null closes it.
if let Some(v) = p.get("colorPicker") {
app.ui.picker = v.is_object().then(|| crate::colorpicker::PickerTarget::Paint {
fills: v.get("fills").and_then(Value::as_bool).unwrap_or(true),
index: v.get("index").and_then(Value::as_u64).unwrap_or(0).min(1000) as usize,
ids: app.session.selection(),
});
}
// The Variables editor: true / a collection (id or name) / {library, collection?} (an
// enabled library's, by id or name) opens it, false closes it.
match p.get("variables") {
Some(Value::Bool(open)) => app.ui.variables = open.then(Default::default),
Some(Value::String(c)) => app.ui.variables = Some(crate::variables_panel::Editor::on(Some(c.clone()))),
Some(v @ Value::Object(_)) => {
let text = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
app.ui.variables = Some(match text("library") {
Some(lib) => crate::variables_panel::Editor::in_library(lib, text("collection")),
None => crate::variables_panel::Editor::on(text("collection")),
});
}
_ => {}
}
// Layers panel search: a string opens it with that filter, null closes it.
if let Some(v) = p.get("layerFilter") {
app.ui.layer_search = v.is_string();
app.ui.layer_filter = v.as_str().unwrap_or_default().chars().take(200).collect();
}
if p.get("collapseLayers").and_then(Value::as_bool) == Some(true) {
app.ui.expanded.clear();
}
// The interaction editor popover in the Prototype panel: [layer id, index]; null closes it.
match p.get("protoEditor") {
Some(Value::Null) => crate::proto_panel::open_editor(ctx, None),
Some(Value::Array(a)) => {
let id = a.first().and_then(Value::as_u64).ok_or("protoEditor: [layer id, interaction index]")?;
let k = a.get(1).and_then(Value::as_u64).unwrap_or(0) as usize;
crate::proto_panel::open_editor(ctx, Some((figcraft_doc::NodeId(id), k)));
}
_ => {}
}
// The prototype preview pane: true / a frame id opens it, false or null closes it.
match p.get("preview") {
Some(Value::Bool(false) | Value::Null) => app.ui.preview = None,
Some(v @ (Value::Bool(true) | Value::Number(_))) => {
let params = if v.is_number() { json!({"start": v}) } else { json!({}) };
app.session.execute("proto.preview", &params).map_err(|e| e.to_string())?;
app.handle_ui_requests();
}
_ => {}
}
// Scroll the presented screen (the full-window player, else the preview pane) to [x, y].
if let Some(s) = p.get("presentScroll").and_then(Value::as_array) {
let at = |i: usize| s.get(i).and_then(Value::as_f64).unwrap_or(0.0);
let player = match (app.ui.present.as_mut(), app.ui.preview.as_mut()) {
(Some(p), _) => Some(p),
(None, Some(pane)) => Some(&mut pane.player),
_ => None,
};
player.ok_or("nothing is presented")?.scroll_to(at(0), at(1));
}
Ok(())
}
fn ui_set_styles(app: &mut FigApp, p: &Value) -> Result<(), String> {
// The style picker: a slot (fill, stroke, effect, grid, text) opens it for the selection, null
// closes it; `stylePickerEdit` (a style id or name) opens its editor; `stylePickerView`
// is `list` or `grid`.
if let Some(v) = p.get("stylePicker") {
match v.as_str().and_then(crate::style_picker::StyleSlot::parse) {
Some(slot) => crate::style_picker::open_for_selection(app, slot, p.get("stylePickerEdit").and_then(Value::as_str)),
None => app.ui.style_picker = None,
}
}
if let Some(v) = p.get("stylePickerView").and_then(Value::as_str) {
app.ui.style_grid = v == "grid";
}
// The Color profile dialog: a profile id opens it with that profile chosen, null closes it.
if let Some(v) = p.get("colorProfileDialog") {
app.ui.color_profile = v.as_str().and_then(figcraft_color::ColorProfile::parse);
}
if let Some(v) = p.get("exportPreview").and_then(Value::as_bool) {
app.ui.export_thumb = v;
}
// Dev Mode's compare view: a frame or section opens it, null closes it; `devCompareVersion`
// the saved version it compares with (null: when it was marked ready for dev),
// `devCompareMode` is sideBySide or overlay, `devCompareSplit` the overlay slider (0–1).
if let Some(v) = p.get("devCompare") {
app.ui.dev.compare = figcraft_engine::params::id_of(v).map(|id| crate::dev_compare::CompareView::new(id, None));
}
if let Some(c) = app.ui.dev.compare.as_mut() {
if let Some(v) = p.get("devCompareVersion") {
c.version = v.as_str().map(str::to_string);
}
match p.get("devCompareMode").and_then(Value::as_str) {
Some("overlay") => c.mode = crate::dev_compare::Mode::Overlay,
Some("sideBySide") => c.mode = crate::dev_compare::Mode::SideBySide,
Some(_) => return Err("devCompareMode: sideBySide|overlay".into()),
None => {}
}
if let Some(x) = p.get("devCompareSplit").and_then(Value::as_f64).filter(|x| x.is_finite()) {
c.split = x.clamp(0.0, 1.0) as f32;
}
}
// The branch review dialog: a branch id opens it, null closes it; `branchReviewPicks`
// chooses sides ({<conflict key>: "branch" | "main"}).
if let Some(v) = p.get("branchReview") {
match v.as_str() {
Some(id) => crate::branch_ui::open(app, Some(id)),
None => app.ui.branch_review = None,
}
}
if let (Some(r), Some(picks)) = (app.ui.branch_review.as_mut(), p.get("branchReviewPicks").and_then(Value::as_object)) {
for (k, v) in picks {
let side = v.as_str().and_then(figcraft_engine::branches::Side::parse).ok_or("branchReviewPicks: \"branch\" or \"main\"")?;
r.picks.insert(k.clone(), side);
}
}
// Which annotations the canvas shows: all, none (uncategorised) or a category.
if let Some(v) = p.get("annotationFilter").and_then(Value::as_str) {
let doc = app.session.doc().map_err(|e| e.to_string())?;
app.ui.dev.categories = crate::dev_mode::CategoryFilter::parse(doc, v).ok_or("annotationFilter: all|none|a category id or name")?;
}
if b(p, "resetFrameStats") {
app.frame_stats.reset();
}
Ok(())
}
/// Handle one control method (also used by tests and the headless snapshot tool).
pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Result<Value, String> {
match method {
@@ -972,134 +1380,8 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
Ok(v)
}
"ui.pointer" => pointer(app, p),
"ui.key" => {
let key = p.get("key").and_then(Value::as_str).ok_or("`key` required")?;
let m = figcraft_engine::keymap::Mods { shift: b(p, "shift"), alt: b(p, "alt"), cmd: b(p, "cmd"), ctrl: b(p, "ctrl") };
let platform = crate::shortcuts::platform();
use figcraft_engine::keymap::{Context, lookup, normalize};
// Text editing keys go to the editor (after the shortcuts live while editing).
if app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false) {
if let Some(bd) = lookup(platform, Context::Text, key, m).filter(|_| m.cmd || m.alt || m.ctrl) {
crate::menus::invoke(app, bd.id);
return Ok(json!({"command": bd.id}));
}
let (word, line) = figcraft_engine::keymap::text_motion(platform, key, m);
figcraft_engine::textedit::key(&mut app.session, key, m.shift, word, line).map_err(|e| e.to_string())?;
return Ok(json!({"ok": true}));
}
// Vector edit mode and shape builder keys (tools, nudging points, delete, join, Enter).
if app.session.tool_key(key, m.shift, m.alt, m.cmd).map_err(|e| e.to_string())? {
return Ok(json!({"ok": true}));
}
// The Comment tool's arrows step through the comment threads.
if app.session.tool.tool == figcraft_engine::ToolId::Comment && m.is_none() && matches!(key, "ArrowUp" | "ArrowDown" | "Up" | "Down") {
let r = crate::comments::step(app, ctx, if matches!(key, "ArrowUp" | "Up") { -1 } else { 1 });
return Ok(json!({"comment": r.and_then(|v| v.get("id").cloned())}));
}
if let Some(bd) = lookup(platform, Context::Canvas, key, m) {
crate::shortcuts::run_binding(app, bd.id);
return Ok(json!({"command": bd.id}));
}
let (name, _) = normalize(key).ok_or_else(|| format!("unknown key `{key}`"))?;
match name {
"ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" => {
let (dx, dy) = match name {
"ArrowLeft" => (-1, 0),
"ArrowRight" => (1, 0),
"ArrowUp" => (0, -1),
_ => (0, 1),
};
let (cmd, params) = if m.cmd || m.ctrl {
("transform.nudgeSize", json!({"dw": dx, "dh": dy, "big": m.shift}))
} else {
("transform.nudge", json!({"dx": dx, "dy": dy, "big": m.shift}))
};
app.session.execute(cmd, &params).map_err(|e| e.to_string())
}
_ => {
// Number keys: opacity (two in quick succession make an exact percentage). A
// hidden window's logic tick has a stale `ctx.input(i.time)`.
let now = app.clock.now();
match egui::Key::from_name(name) {
Some(k) if m.is_none() && crate::shortcuts::opacity_digit(app, k, now) => Ok(json!({"opacity": true})),
_ => Err(format!("no shortcut `{key}`")),
}
}
}
}
"ui.input" => {
let list = p
.get("events")
.and_then(Value::as_array)
.ok_or("`events` required: [{move|down|up: [x, y]} | {key, shift?, alt?, cmd?} | {text} | {leave: true}]")?;
let pos = |v: &Value| -> Option<egui::Pos2> {
let a = v.as_array()?;
let p = egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32);
p.is_finite().then_some(p)
};
let mut events = Vec::new();
let mut pads = Vec::new();
for e in list.iter().take(256) {
let button = if e.get("button").and_then(Value::as_str) == Some("secondary") {
egui::PointerButton::Secondary
} else {
egui::PointerButton::Primary
};
let command = b(e, "cmd") || b(e, "ctrl");
let mods = egui::Modifiers { alt: b(e, "alt"), ctrl: command, shift: b(e, "shift"), mac_cmd: false, command };
if let Some(p) = e.get("move").and_then(pos) {
events.push(egui::Event::PointerMoved(p));
} else if let Some((p, pressed)) =
e.get("down").and_then(pos).map(|p| (p, true)).or_else(|| e.get("up").and_then(pos).map(|p| (p, false)))
{
events.push(egui::Event::PointerMoved(p));
events.push(egui::Event::PointerButton { pos: p, button, pressed, modifiers: mods });
} else if let Some(k) = e.get("key").and_then(Value::as_str) {
let key = egui::Key::from_name(k).ok_or_else(|| format!("unknown key `{k}`"))?;
for pressed in [true, false] {
events.push(egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: mods });
}
} else if let Some(t) = e.get("text").and_then(Value::as_str) {
events.push(egui::Event::Text(t.to_string()));
} else if let Some(d) = e.get("scroll").and_then(pos) {
// Content moves by `d` points (negative y scrolls down, like a wheel turned towards you).
events.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
source: egui::MouseWheelSource::Wheel,
delta: d.to_vec2(),
phase: egui::TouchPhase::Move,
modifiers: mods,
});
} else if let Some(g) = e.get("gamepad") {
let names = figcraft_doc::proto::GAMEPAD_BUTTONS.join(", ");
pads.push(pad_name(g).ok_or_else(|| format!("unknown controller button {g} (an index 0-16 or one of {names})"))?.to_string());
} else if b(e, "leave") {
events.push(egui::Event::PointerGone);
} else if let Some(h) = e.get("hold") {
let held = egui::Modifiers {
alt: b(h, "alt"),
ctrl: b(h, "cmd") || b(h, "ctrl"),
shift: b(h, "shift"),
mac_cmd: false,
command: b(h, "cmd") || b(h, "ctrl"),
};
events.push(egui::Event::ModifiersChanged(held));
} else {
return Err(format!("unknown input event {e}"));
}
}
// Controller buttons go straight to the prototype playing (its next frame takes them).
if !pads.is_empty() {
match (app.ui.present.as_mut(), app.ui.preview.as_mut()) {
(Some(p), _) => p.pads.extend(pads),
(None, Some(pane)) => pane.player.pads.extend(pads),
(None, None) => return Err("controller buttons go to a prototype that is playing: run proto.present or proto.preview".into()),
}
}
app.control_input_queued = app.push_control_input(events);
ctx.request_repaint();
Ok(json!({"queued": list.len().min(256)}))
}
"ui.key" => ui_key(app, ctx, p),
"ui.input" => ui_input(app, ctx, p),
"ui.type" => {
let text = p.get("text").and_then(Value::as_str).ok_or("`text` required")?;
figcraft_engine::textedit::insert(&mut app.session, text).map_err(|e| e.to_string())?;
@@ -1111,270 +1393,11 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
Ok(json!({"ok": true, "lastError": app.ui.last_error}))
}
"ui.set" => {
if let Some(t) = p.get("rightTab").and_then(Value::as_str) {
app.ui.right_tab = match t {
"prototype" => crate::RightTab::Prototype,
"inspect" => crate::RightTab::Inspect,
_ => crate::RightTab::Design,
};
}
if let Some(t) = p.get("leftTab").and_then(Value::as_str) {
app.ui.left_tab = if t == "assets" { crate::LeftTab::Assets } else { crate::LeftTab::File };
}
if let Some(t) = p.get("theme").and_then(Value::as_str) {
app.session.prefs.theme = t.to_string();
}
if let Some(h) = p.get("home").and_then(Value::as_bool) {
app.ui.home = h;
}
if let Some(section) = p.get("homeSection").and_then(Value::as_str).and_then(crate::home::Section::parse) {
app.ui.home_view.section = section;
}
if let Some(query) = p.get("homeSearch").and_then(Value::as_str) {
app.ui.home_view.file_query = query.chars().take(256).collect();
}
if let Some(view) = p.get("homeRecentsView").and_then(Value::as_str).and_then(crate::home_recents::View::parse) {
view.store(&mut app.session.prefs);
}
if let Some(key) = p.get("homeRecentsSort").and_then(Value::as_str).and_then(crate::home_recents::SortKey::parse) {
crate::home_recents::Sort::by(key).store(&mut app.session.prefs);
}
if let Some(order) = p.get("homeRecentsOrder").and_then(Value::as_str).filter(|o| matches!(*o, "asc" | "desc")) {
let sort = crate::home_recents::Sort::of(&app.session.prefs);
crate::home_recents::Sort { descending: order == "desc", ..sort }.store(&mut app.session.prefs);
}
if let Some(h) = p.get("palette").and_then(Value::as_bool) {
crate::menus::set_palette(&mut app.ui, h);
}
// true / false, or a top-level menu to open it at ("Plugins").
match p.get("mainMenu") {
Some(Value::Bool(h)) => app.ui.main_menu = *h,
Some(Value::String(top)) => {
app.ui.main_menu = true;
crate::menus::open_main_menu(ctx, top);
}
_ => {}
}
if let Some(h) = p.get("pluginsWindow").and_then(Value::as_bool) {
app.ui.plugins_window = h;
}
// The Plugins window's tab ("installed", "browse"), and the plugin of the listed
// directory whose install asks for confirmation (null: none).
if let Some(tab) = p.get("pluginsTab").and_then(Value::as_str) {
app.ui.plugins_manage.tab =
if tab == "browse" { crate::plugins_ui::PluginsTab::Browse } else { crate::plugins_ui::PluginsTab::Installed };
}
match p.get("pluginConfirm") {
Some(Value::String(id)) => app.ui.plugins_manage.confirm = Some(id.clone()),
Some(Value::Null) => app.ui.plugins_manage.confirm = None,
_ => {}
}
// A plugin command's parameters form: {id, command?}; null closes it.
match p.get("pluginForm") {
Some(Value::Null) => app.ui.plugin_form = None,
Some(v @ Value::Object(_)) => {
let id = v.get("id").and_then(Value::as_str).ok_or("pluginForm: {id, command?}")?;
let command = v.get("command").and_then(Value::as_str).unwrap_or_default();
crate::plugins_ui::start(app, id, command);
}
_ => {}
}
// Give a region the keyboard as F6 would: toolbar, layers, canvas or properties.
if let Some(r) = p.get("focusRegion").and_then(Value::as_str) {
app.ui.nav.go(crate::a11y::Region::parse(r).ok_or_else(|| format!("unknown region `{r}`"))?);
}
// A right-click on the canvas at screen [sx, sy] (null closes the menu);
// `contextSelectLayer` opens its Select layer submenu.
match p.get("contextMenu") {
Some(Value::Array(at)) => {
let (x, y) = (at.first().and_then(Value::as_f64).unwrap_or(0.0), at.get(1).and_then(Value::as_f64).unwrap_or(0.0));
crate::canvas::open_context_menu(app, egui::pos2(x as f32, y as f32), b(p, "cmd"));
}
Some(Value::Null) => app.ui.context_menu = None,
_ => {}
}
if let Some(v) = p.get("contextSelectLayer").and_then(Value::as_bool) {
crate::menus::open_select_layer(ctx, v);
}
if let Some(h) = p.get("iconGallery").and_then(Value::as_bool) {
app.ui.icon_gallery = h;
}
// Comment thread popover: an id opens it, null closes it.
if let Some(v) = p.get("commentOpen") {
match v.as_u64() {
Some(id) => {
// Pins are drawn for the current page only: reveal the thread's page first.
let page = app.session.doc().ok().and_then(|d| d.comment(id)).map(|c| c.page);
if let Some(page) = page.filter(|p| app.session.page().is_ok_and(|cur| cur != *p)) {
app.session.execute("page.select", &json!({"id": page.0})).map_err(|e| e.to_string())?;
}
app.ui.comments.show(id);
}
None => app.ui.comments.close(),
}
}
if let Some(v) = p.get("commentDraft") {
match (v.get("x").and_then(Value::as_f64), v.get("y").and_then(Value::as_f64)) {
(Some(x), Some(y)) => crate::comments::start_draft(app, x, y),
_ => app.ui.comments.draft = None,
}
}
if let Some(v) = p.get("showResolvedComments").and_then(Value::as_bool) {
app.ui.comments.filter = if v { Filter::All } else { Filter::Open };
}
if let Some(v) = p.get("commentFilter").and_then(Value::as_str) {
app.ui.comments.filter = Filter::parse(v).ok_or("commentFilter: open|resolved|mine|unread|all")?;
}
if let Some(v) = p.get("commentSort").and_then(Value::as_str) {
app.ui.comments.sort = Sort::parse(v).ok_or("commentSort: date|page")?;
}
if let Some(v) = p.get("commentSearch").and_then(Value::as_str) {
app.ui.comments.search = v.to_string();
}
// The open thread's reply field, with the cursor at its end (an `@` there offers names).
if let Some(v) = p.get("commentReply").and_then(Value::as_str) {
app.ui.comments.reply = v.to_string();
if let Some(id) = app.ui.comments.open {
crate::comments::focus_reply(ctx, id, v.chars().count());
}
}
// Edit a message of the open thread: its index (0: the first message); null stops.
if let Some(v) = p.get("commentEdit") {
app.ui.comments.editing = match (app.ui.comments.open, v.as_u64()) {
(Some(id), Some(i)) => {
let text = app.session.doc().ok().and_then(|d| d.comment(id)).and_then(|c| c.message(i as usize)).map(|m| m.text.clone());
Some((id, i as usize, text.ok_or("commentEdit: no such message")?))
}
_ => None,
};
}
if let Some(v) = p.get("presentComments").and_then(Value::as_bool) {
app.ui.present_view.comments = v;
if let Some(pres) = app.ui.present.as_mut() {
pres.view.comments = v;
}
}
// Colour picker on the selection's paint `{fills?, index?}`; null closes it.
if let Some(v) = p.get("colorPicker") {
app.ui.picker = v.is_object().then(|| crate::colorpicker::PickerTarget::Paint {
fills: v.get("fills").and_then(Value::as_bool).unwrap_or(true),
index: v.get("index").and_then(Value::as_u64).unwrap_or(0).min(1000) as usize,
ids: app.session.selection(),
});
}
// The Variables editor: true / a collection (id or name) / {library, collection?} (an
// enabled library's, by id or name) opens it, false closes it.
match p.get("variables") {
Some(Value::Bool(open)) => app.ui.variables = open.then(Default::default),
Some(Value::String(c)) => app.ui.variables = Some(crate::variables_panel::Editor::on(Some(c.clone()))),
Some(v @ Value::Object(_)) => {
let text = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
app.ui.variables = Some(match text("library") {
Some(lib) => crate::variables_panel::Editor::in_library(lib, text("collection")),
None => crate::variables_panel::Editor::on(text("collection")),
});
}
_ => {}
}
// Layers panel search: a string opens it with that filter, null closes it.
if let Some(v) = p.get("layerFilter") {
app.ui.layer_search = v.is_string();
app.ui.layer_filter = v.as_str().unwrap_or_default().chars().take(200).collect();
}
if p.get("collapseLayers").and_then(Value::as_bool) == Some(true) {
app.ui.expanded.clear();
}
// The interaction editor popover in the Prototype panel: [layer id, index]; null closes it.
match p.get("protoEditor") {
Some(Value::Null) => crate::proto_panel::open_editor(ctx, None),
Some(Value::Array(a)) => {
let id = a.first().and_then(Value::as_u64).ok_or("protoEditor: [layer id, interaction index]")?;
let k = a.get(1).and_then(Value::as_u64).unwrap_or(0) as usize;
crate::proto_panel::open_editor(ctx, Some((figcraft_doc::NodeId(id), k)));
}
_ => {}
}
// The prototype preview pane: true / a frame id opens it, false or null closes it.
match p.get("preview") {
Some(Value::Bool(false) | Value::Null) => app.ui.preview = None,
Some(v @ (Value::Bool(true) | Value::Number(_))) => {
let params = if v.is_number() { json!({"start": v}) } else { json!({}) };
app.session.execute("proto.preview", &params).map_err(|e| e.to_string())?;
app.handle_ui_requests();
}
_ => {}
}
// Scroll the presented screen (the full-window player, else the preview pane) to [x, y].
if let Some(s) = p.get("presentScroll").and_then(Value::as_array) {
let at = |i: usize| s.get(i).and_then(Value::as_f64).unwrap_or(0.0);
let player = match (app.ui.present.as_mut(), app.ui.preview.as_mut()) {
(Some(p), _) => Some(p),
(None, Some(pane)) => Some(&mut pane.player),
_ => None,
};
player.ok_or("nothing is presented")?.scroll_to(at(0), at(1));
}
// The style picker: a slot (fill, stroke, effect, grid, text) opens it for the selection, null
// closes it; `stylePickerEdit` (a style id or name) opens its editor; `stylePickerView`
// is `list` or `grid`.
if let Some(v) = p.get("stylePicker") {
match v.as_str().and_then(crate::style_picker::StyleSlot::parse) {
Some(slot) => crate::style_picker::open_for_selection(app, slot, p.get("stylePickerEdit").and_then(Value::as_str)),
None => app.ui.style_picker = None,
}
}
if let Some(v) = p.get("stylePickerView").and_then(Value::as_str) {
app.ui.style_grid = v == "grid";
}
// The Color profile dialog: a profile id opens it with that profile chosen, null closes it.
if let Some(v) = p.get("colorProfileDialog") {
app.ui.color_profile = v.as_str().and_then(figcraft_color::ColorProfile::parse);
}
if let Some(v) = p.get("exportPreview").and_then(Value::as_bool) {
app.ui.export_thumb = v;
}
// Dev Mode's compare view: a frame or section opens it, null closes it; `devCompareVersion`
// the saved version it compares with (null: when it was marked ready for dev),
// `devCompareMode` is sideBySide or overlay, `devCompareSplit` the overlay slider (0–1).
if let Some(v) = p.get("devCompare") {
app.ui.dev.compare = figcraft_engine::params::id_of(v).map(|id| crate::dev_compare::CompareView::new(id, None));
}
if let Some(c) = app.ui.dev.compare.as_mut() {
if let Some(v) = p.get("devCompareVersion") {
c.version = v.as_str().map(str::to_string);
}
match p.get("devCompareMode").and_then(Value::as_str) {
Some("overlay") => c.mode = crate::dev_compare::Mode::Overlay,
Some("sideBySide") => c.mode = crate::dev_compare::Mode::SideBySide,
Some(_) => return Err("devCompareMode: sideBySide|overlay".into()),
None => {}
}
if let Some(x) = p.get("devCompareSplit").and_then(Value::as_f64).filter(|x| x.is_finite()) {
c.split = x.clamp(0.0, 1.0) as f32;
}
}
// The branch review dialog: a branch id opens it, null closes it; `branchReviewPicks`
// chooses sides ({<conflict key>: "branch" | "main"}).
if let Some(v) = p.get("branchReview") {
match v.as_str() {
Some(id) => crate::branch_ui::open(app, Some(id)),
None => app.ui.branch_review = None,
}
}
if let (Some(r), Some(picks)) = (app.ui.branch_review.as_mut(), p.get("branchReviewPicks").and_then(Value::as_object)) {
for (k, v) in picks {
let side = v.as_str().and_then(figcraft_engine::branches::Side::parse).ok_or("branchReviewPicks: \"branch\" or \"main\"")?;
r.picks.insert(k.clone(), side);
}
}
// Which annotations the canvas shows: all, none (uncategorised) or a category.
if let Some(v) = p.get("annotationFilter").and_then(Value::as_str) {
let doc = app.session.doc().map_err(|e| e.to_string())?;
app.ui.dev.categories = crate::dev_mode::CategoryFilter::parse(doc, v).ok_or("annotationFilter: all|none|a category id or name")?;
}
if b(p, "resetFrameStats") {
app.frame_stats.reset();
}
ui_set_view(app, ctx, p)?;
ui_set_plugins(app, ctx, p)?;
ui_set_comments(app, ctx, p)?;
ui_set_panels(app, ctx, p)?;
ui_set_styles(app, p)?;
Ok(app.inspect())
}
"ui.render" => {
+21 -14
View File
@@ -968,6 +968,25 @@ fn a_prepared_field_save_cannot_commit_into_a_same_pass_replacement_document() {
}
}
/// The save shortcut's key events (and, routing by tab, a click on the tab at `tab_at`), with the modifiers held.
fn save_events(route: &str, save_as: bool, tab_at: egui::Pos2) -> (Vec<Event>, Modifiers) {
let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) };
if save_as {
modifiers = modifiers.plus(Modifiers::SHIFT);
}
let mut events = vec![
Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers },
Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers },
];
if route == "tab" {
events.push(Event::PointerMoved(tab_at));
for pressed in [true, false] {
events.push(Event::PointerButton { pos: tab_at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
}
}
(events, modifiers)
}
/// One route/save-as/discard combination of the same-pass replacement test: a save prepared for A
/// must not commit A's cached X editor into B when routing presents B before the fields draw,
/// even though both panels use the same widget ID.
@@ -1031,21 +1050,9 @@ fn prepared_save_cannot_enter_a_replacement(route: &str, save_as: bool, discard:
} else {
None
};
let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) };
if save_as {
modifiers = modifiers.plus(Modifiers::SHIFT);
}
let mut events = vec![
Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers },
Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers },
];
if route == "tab" {
events.push(Event::PointerMoved(tab_at));
for pressed in [true, false] {
events.push(Event::PointerButton { pos: tab_at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
}
}
let (events, modifiers) = save_events(route, save_as, tab_at);
if discard {
let mut events = events;
events.insert(0, Event::ModifiersChanged(modifiers));
u.frame_raw_with_discard(egui::RawInput { events, ..Default::default() });
} else {
+94 -66
View File
@@ -1,4 +1,6 @@
use super::*;
use super::{
Color32, FRAC_PI_2, FRAC_PI_4, Icon, Mesh, PI, Painter, Pos2, Rect, Shape, Stroke, TAU, Vec2, arc_pts, capsule, pos2, triangulate, vec2,
};
struct P<'a> {
painter: &'a Painter,
@@ -190,6 +192,89 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
draw(&p, icon);
}
/// An open hand: four fingers (the middle one tallest), then the palm and the thumb.
fn hand(p: &P) {
for (x, top) in [(4.5, 5.0), (6.5, 3.5), (8.5, 4.0), (10.5, 5.5)] {
let mut f = vec![(x, 8.0)];
f.extend(arc_pts(x + 1.0, top, 1.0, PI, TAU));
f.push((x + 2.0, 8.0));
p.path(&f, false);
}
let mut h = vec![(12.5, 8.0), (12.5, 10.0)];
h.extend(arc_pts(9.5, 10.0, 3.0, 0.0, FRAC_PI_2));
h.extend([(8.0, 13.0), (3.0, 9.4)]);
h.extend(arc_pts(3.6, 8.6, 1.0, 2.2, 5.3));
h.push((4.5, 8.5));
p.path(&h, false);
}
/// A curve with a smooth point and its handle line.
fn bend(p: &P) {
let pts: Vec<(f32, f32)> = (0..=16)
.map(|i| {
let t = i as f32 / 16.0;
let u = 1.0 - t;
(u * u * 2.5 + 2.0 * u * t * 8.0 + t * t * 13.5, u * u * 13.5 + 2.0 * u * t * 1.5 + t * t * 13.5)
})
.collect();
p.path(&pts, false);
p.line(3.0, 7.5, 13.0, 7.5);
p.dot(3.0, 7.5, 1.3);
p.dot(13.0, 7.5, 1.3);
p.fill_rect(7.0, 6.0, 9.0, 9.0, 0.0);
}
/// A five-pointed star, filled.
fn star(p: &P) {
let pts: Vec<(f32, f32)> = (0..10)
.map(|i| {
let a = -FRAC_PI_2 + PI * i as f32 / 5.0;
let r = if i % 2 == 0 { 6.0 } else { 2.7 };
(8.0 + r * a.cos(), 8.6 + r * a.sin())
})
.collect();
p.path(&pts, true);
}
/// The Component icon.
fn draw_component(p: &P) {
for (cx, cy) in [(8.0, 4.2), (11.8, 8.0), (8.0, 11.8), (4.2, 8.0)] {
p.fill(&[(cx, cy - 2.5), (cx + 2.5, cy), (cx, cy + 2.5), (cx - 2.5, cy)]);
}
}
/// The ComponentSet icon.
fn draw_componentset(p: &P) {
for (cx, cy) in [(8.0, 4.3), (11.7, 8.0), (8.0, 11.7), (4.3, 8.0)] {
p.path(&[(cx, cy - 2.2), (cx + 2.2, cy), (cx, cy + 2.2), (cx - 2.2, cy)], true);
}
}
/// The Group icon.
fn draw_group(p: &P) {
for (x, y, dx, dy) in [(2.5, 2.5, 1.0, 1.0), (13.5, 2.5, -1.0, 1.0), (2.5, 13.5, 1.0, -1.0), (13.5, 13.5, -1.0, -1.0)] {
p.poly(&[(x + 3.5 * dx, y), (x, y), (x, y + 3.5 * dy)], false);
}
}
/// The Grid/LayoutGrid icons.
fn draw_grid_layoutgrid(p: &P) {
for i in [2.5, 6.5, 10.5] {
for j in [2.5, 6.5, 10.5] {
p.rect(i, j, i + 3.0, j + 3.0, 0.0);
}
}
}
/// The FlipH icon.
fn draw_fliph(p: &P) {
for y in [1.0, 5.0, 9.0, 13.0] {
p.line(7.5, y, 7.5, y + 2.0);
}
p.poly(&[(5.5, 3.5), (5.5, 12.5), (1.5, 12.5)], true);
p.fill(&[(9.0, 3.0), (14.0, 13.0), (9.0, 13.0)]);
}
/// Draw `icon` on the 16-unit grid through `p`, which maps grid units to the target box.
fn draw(p: &P, icon: Icon) {
use Icon::*;
@@ -201,19 +286,7 @@ fn draw(p: &P, icon: Icon) {
p.poly(&[(9.5, 2.5), (13.5, 2.5), (13.5, 6.5)], false);
}
Hand => {
// An open hand: four fingers (the middle one tallest), then the palm and the thumb.
for (x, top) in [(4.5, 5.0), (6.5, 3.5), (8.5, 4.0), (10.5, 5.5)] {
let mut f = vec![(x, 8.0)];
f.extend(arc_pts(x + 1.0, top, 1.0, PI, TAU));
f.push((x + 2.0, 8.0));
p.path(&f, false);
}
let mut h = vec![(12.5, 8.0), (12.5, 10.0)];
h.extend(arc_pts(9.5, 10.0, 3.0, 0.0, FRAC_PI_2));
h.extend([(8.0, 13.0), (3.0, 9.4)]);
h.extend(arc_pts(3.6, 8.6, 1.0, 2.2, 5.3));
h.push((4.5, 8.5));
p.path(&h, false);
hand(p);
}
Frame => {
// Inset a unit from the box: four long hinted strokes read heavier than the
@@ -235,19 +308,7 @@ fn draw(p: &P, icon: Icon) {
p.fill(&capsule((2.8, 13.2), (5.9, 10.1), 1.3));
}
Bend => {
// A curve with a smooth point and its handle line.
let pts: Vec<(f32, f32)> = (0..=16)
.map(|i| {
let t = i as f32 / 16.0;
let u = 1.0 - t;
(u * u * 2.5 + 2.0 * u * t * 8.0 + t * t * 13.5, u * u * 13.5 + 2.0 * u * t * 1.5 + t * t * 13.5)
})
.collect();
p.path(&pts, false);
p.line(3.0, 7.5, 13.0, 7.5);
p.dot(3.0, 7.5, 1.3);
p.dot(13.0, 7.5, 1.3);
p.fill_rect(7.0, 6.0, 9.0, 9.0, 0.0);
bend(p);
}
PaintBucket => {
p.poly(&[(2.5, 8.0), (7.5, 3.0), (12.5, 8.0), (7.5, 13.0)], true);
@@ -273,16 +334,7 @@ fn draw(p: &P, icon: Icon) {
}
Ellipse => p.circle(8.0, 8.0, 5.5),
Polygon => p.poly(&[(8.0, 2.5), (13.5, 12.5), (2.5, 12.5)], true),
Star => {
let pts: Vec<(f32, f32)> = (0..10)
.map(|i| {
let a = -FRAC_PI_2 + PI * i as f32 / 5.0;
let r = if i % 2 == 0 { 6.0 } else { 2.7 };
(8.0 + r * a.cos(), 8.6 + r * a.sin())
})
.collect();
p.path(&pts, true);
}
Star => star(p),
Image => {
p.rect(2.5, 3.5, 13.5, 12.5, 1.0);
p.poly(&[(2.5, 11.5), (6.0, 8.0), (9.0, 11.0), (10.5, 9.5), (13.5, 12.5)], false);
@@ -352,22 +404,10 @@ fn draw(p: &P, icon: Icon) {
p.arc(13.0, 4.5, 2.5, PI, TAU);
}
}
Component => {
for (cx, cy) in [(8.0, 4.2), (11.8, 8.0), (8.0, 11.8), (4.2, 8.0)] {
p.fill(&[(cx, cy - 2.5), (cx + 2.5, cy), (cx, cy + 2.5), (cx - 2.5, cy)]);
}
}
ComponentSet => {
for (cx, cy) in [(8.0, 4.3), (11.7, 8.0), (8.0, 11.7), (4.3, 8.0)] {
p.path(&[(cx, cy - 2.2), (cx + 2.2, cy), (cx, cy + 2.2), (cx - 2.2, cy)], true);
}
}
Component => draw_component(p),
ComponentSet => draw_componentset(p),
Instance => p.path(&[(8.0, 2.0), (14.0, 8.0), (8.0, 14.0), (2.0, 8.0)], true),
Group => {
for (x, y, dx, dy) in [(2.5, 2.5, 1.0, 1.0), (13.5, 2.5, -1.0, 1.0), (2.5, 13.5, 1.0, -1.0), (13.5, 13.5, -1.0, -1.0)] {
p.poly(&[(x + 3.5 * dx, y), (x, y), (x, y + 3.5 * dy)], false);
}
}
Group => draw_group(p),
Vector => {
p.path(&[(3.0, 12.5), (6.0, 4.0), (10.0, 12.0), (13.0, 3.5)], false);
p.fill_rect(1.5, 11.0, 4.5, 14.0, 0.0);
@@ -413,13 +453,7 @@ fn draw(p: &P, icon: Icon) {
p.circle(7.0, 7.0, 4.5);
p.line(10.5, 10.5, 14.0, 14.0);
}
Grid | LayoutGrid => {
for i in [2.5, 6.5, 10.5] {
for j in [2.5, 6.5, 10.5] {
p.rect(i, j, i + 3.0, j + 3.0, 0.0);
}
}
}
Grid | LayoutGrid => draw_grid_layoutgrid(p),
AlignLeft => {
p.line(2.5, 2.0, 2.5, 14.0);
p.fill_rect(4.0, 4.0, 13.0, 7.0, 0.0);
@@ -462,13 +496,7 @@ fn draw(p: &P, icon: Icon) {
}
Rotate => p.arc_arrow(8.0, 8.5, 5.0, -PI * 0.55, PI * 1.25),
Reset => p.arc_arrow(8.0, 8.5, 5.0, -PI * 0.45, -PI * 2.25),
FlipH => {
for y in [1.0, 5.0, 9.0, 13.0] {
p.line(7.5, y, 7.5, y + 2.0);
}
p.poly(&[(5.5, 3.5), (5.5, 12.5), (1.5, 12.5)], true);
p.fill(&[(9.0, 3.0), (14.0, 13.0), (9.0, 13.0)]);
}
FlipH => draw_fliph(p),
FlipV => {
for x in [1.0, 5.0, 9.0, 13.0] {
p.line(x, 7.5, x + 2.0, 7.5);
+1 -1
View File
@@ -1,7 +1,7 @@
#[cfg(target_arch = "wasm32")]
use super::services::browser_can_read;
use super::services::{ReadFuture, unreadable};
use super::*;
use super::{OsStr, Path, theme};
/// The file types File ▸ Open and a drop on the home screen open.
pub(crate) const OPEN_EXTENSIONS: &[&str] = &["figcraft", "fig", "sketch", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"];
+18 -5
View File
@@ -1,4 +1,17 @@
use super::*;
use super::{
Action, Document, Easing, FrameTex, NodeId, NodeKind, OverlayAnchor, OverlayPosition, OverlayState, Presenter, Rect, ScrollAnim, Temp,
Transition, Trigger, Waiting,
};
#[derive(Clone, Copy)]
struct ActionTiming {
transition: Transition,
duration: f64,
easing: Easing,
rect: Option<Rect>,
now: f64,
depth: usize,
}
impl Presenter {
/// A mouse enter, leave, down or up on `node` (shown on `root`) met its interaction `k`: its
@@ -68,12 +81,12 @@ impl Presenter {
}
pub(crate) fn perform(&mut self, a: &Action, node: NodeId, tr: Transition, dur: f64, easing: Easing, rect: Option<Rect>, now: f64) -> bool {
self.perform_at(a, node, tr, dur, easing, rect, now, 0)
self.perform_at(a, node, ActionTiming { transition: tr, duration: dur, easing, rect, now, depth: 0 })
}
/// One action; `depth` counts the conditional actions it is inside.
#[allow(clippy::too_many_arguments)]
fn perform_at(&mut self, a: &Action, node: NodeId, tr: Transition, dur: f64, easing: Easing, rect: Option<Rect>, now: f64, depth: usize) -> bool {
fn perform_at(&mut self, a: &Action, node: NodeId, timing: ActionTiming) -> bool {
let ActionTiming { transition: tr, duration: dur, easing, rect, now, depth } = timing;
let Some(doc) = self.doc.clone() else { return false };
match a {
Action::Navigate { dest, preserve_scroll } => {
@@ -213,7 +226,7 @@ impl Presenter {
// Evaluated now: earlier actions of the interaction may have changed variables.
let mut did = false;
for inner in figcraft_engine::proto_run::branch(&doc, node, blocks) {
did |= self.perform_at(&inner, node, tr, dur, easing, rect, now, depth + 1);
did |= self.perform_at(&inner, node, ActionTiming { depth: depth + 1, ..timing });
}
did
}
+4 -1
View File
@@ -1,7 +1,10 @@
use super::layout::{Chrome, FULL, Place};
use super::player::player;
use super::scroll::page_settings;
use super::*;
use super::{
Align2, Color32, CornerRadius, Document, Easing, FigApp, HashSet, Icon, Id, NodeId, Pos2, PresentScale, Presenter, PrototypeSettings, Rect,
Sense, Stroke, Tokens, Transition, Ui, icons, json, pos2, regular, tr, vec2, widgets,
};
/// What the player's top bar asked for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+1 -1
View File
@@ -1,5 +1,5 @@
use super::layout::{bezel, upright_w};
use super::*;
use super::{Color32, CornerRadius, DeviceColor, Rect, Stroke, StrokeKind, pos2, vec2};
/// The black glass around a device's screen.
const GLASS: Color32 = Color32::from_rgb(0x16, 0x16, 0x18);
+1 -1
View File
@@ -1,7 +1,7 @@
use super::actions::texture_scale;
use super::layout::{Layout, Place, hotspots};
use super::transition::{smart_interpolate, transition_frames};
use super::*;
use super::{ACCENT, Color32, CornerRadius, FLASH_SECS, NodeId, OverlayState, Presenter, Rect, Stroke, StrokeKind, Transition, pos2, vec2};
pub(super) fn draw(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context, l: &Layout, now: f64) {
let ppp = f64::from(ctx.pixels_per_point());
+167 -116
View File
@@ -1,20 +1,174 @@
use super::actions::find_trigger;
use super::chrome::layer_at;
use super::layout::{Layout, hotspots};
use super::layout::{Layout, Place, hotspots};
use super::navigate::next_delay;
use super::scroll::{COAST_MIN, DECAY_DRAG, DECAY_WHEEL, RUBBER_PTS, clamp_scroll, page_settings, rubber_band, scrollers_at, units_per_point};
use super::transition::{direction_vec, lerp};
use super::*;
use super::{Document, Easing, FLASH_SECS, Fling, KeyMods, NodeId, Pos2, Presenter, Rect, Scroller, Transition, Trigger, Vec2, vec2};
/// Keep a key the prototype used from reaching the editor's shortcuts.
fn consume_key(ctx: &egui::Context, key: egui::Key) {
ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key: k, .. } if *k == key)));
}
/// A transition a drag started follows the pointer while it stays down, then plays on from there.
fn follow_drag(p: &mut Presenter, pos: Option<egui::Pos2>, down: bool, now: f64) {
if let Some(a) = p.anim.as_mut()
&& let Some((from, dir, len)) = a.follow
{
match pos.filter(|_| down) {
Some(q) => a.start = now - f64::from(((q - from).dot(dir) / len).clamp(0.0, 1.0)) * a.dur,
None => a.follow = None,
}
}
}
/// A drag that scrolled lets go: it carries on at the pointer's speed (and springs back from
/// past the ends).
fn release_drag_scroll(p: &mut Presenter, l: &Layout, down: bool, velocity: Vec2, now: f64) {
if !down && let Some((t, _)) = p.drag_scroll.take() {
let u = units_per_point(t, l.scale);
let r = p.doc.as_ref().map_or((0.0, 0.0), |b| p.limits(b, t));
let along = |v: f32, r: f64| if r > 0.0 { -f64::from(v) * u } else { 0.0 };
p.fling = Some(Fling { target: t, vel: (along(velocity.x, r.0), along(velocity.y, r.1)), decay: DECAY_DRAG, bounce: true, at: now });
}
}
/// Scrolls the first of `targets` the wheel can still move, and smooths the wheel's speed over
/// ~50 ms whatever the frame rate.
fn wheel_scroll(p: &mut Presenter, l: &Layout, base: &Document, targets: Vec<Scroller>, wheel: Vec2, dt: f64) {
let mut moved = None;
for t in targets {
let u = units_per_point(t, l.scale);
let cur = p.offset(t);
let next = clamp_scroll((cur.0 - f64::from(wheel.x) * u, cur.1 - f64::from(wheel.y) * u), p.limits(base, t));
if (next.0 - cur.0).abs() > 1e-6 || (next.1 - cur.1).abs() > 1e-6 {
p.set_offset(t, next);
p.scroll_anim = None;
p.fling = None;
moved = Some((t, ((next.0 - cur.0) / dt, (next.1 - cur.1) / dt)));
break;
}
}
// The wheel's speed, smoothed over ~50 ms whatever the frame rate.
p.wheel = moved.map(|(t, inst)| {
let prev = p.wheel.filter(|w| w.0 == t).map_or(inst, |w| w.1);
let a = 1.0 - (-dt / 0.05).exp();
(t, (lerp(prev.0, inst.0, a), lerp(prev.1, inst.1, a)))
});
}
/// A drag begun inside a mobile screen moves its content, even if the pointer leaves the
/// screen. The device body stays in place even when the drag begins outside its screen.
/// Panning the whole view remains available only without a mobile device.
fn begin_drag_scroll(p: &mut Presenter, doc: &Document, root: NodeId, place: &Place, start: Pos2, d: Vec2) {
if let Some(base) = p.doc.clone() {
let horizontal = d.x.abs() >= d.y.abs();
let scrolls = scrollers_at(p, &base, doc, root, place, start);
let on_axis = |range: (f64, f64)| if horizontal { range.0 > 0.0 } else { range.1 > 0.0 };
let mobile = page_settings(&base, root).device().is_some_and(|device| device.rotates);
let target = scrolls
.iter()
.copied()
.find(|f| on_axis(p.range(&base, *f)))
.or_else(|| scrolls.first().copied())
.map(Scroller::Frame)
.or((!mobile && p.pan_range != (0.0, 0.0)).then_some(Scroller::View));
if let Some(t) = target {
p.fling = None;
p.drag_scroll = Some((t, clamp_scroll(p.offset(t), p.limits(&base, t))));
}
}
}
/// Dragging scrolls with the pointer; past the ends it stretches.
fn drag_scrolls(p: &mut Presenter, ctx: &egui::Context, l: &Layout, down: bool, pos: Option<Pos2>) {
if down
&& let (Some((t, from)), Some(start), Some(q)) = (p.drag_scroll, p.press, pos)
&& let Some(base) = p.doc.as_ref()
{
let (d, u) = (q - start, units_per_point(t, l.scale));
let r = p.limits(base, t);
let limit = RUBBER_PTS * u;
let to = (rubber_band(from.0 - f64::from(d.x) * u, r.0, limit), rubber_band(from.1 - f64::from(d.y) * u, r.1, limit));
p.set_offset(t, to);
p.scroll_anim = None;
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
}
}
/// The player's own keys, when the prototype doesn't use them: R restarts, Z cycles the
/// full-window presenter's size options. True when `key` was used.
fn player_key(p: &mut Presenter, ctx: &egui::Context, l: &Layout, key: &egui::Key, mods: &egui::Modifiers, pane: bool, now: f64) -> bool {
if mods.is_none() {
match key {
egui::Key::R => {
p.restart(p.start, now);
consume_key(ctx, *key);
return true;
}
// (A responsive frame filling the window has no size options.)
egui::Key::Z if !pane && (l.device.is_some() || p.fit.is_none()) => {
p.set_scale(p.view.scale.next(l.device.is_some()));
consume_key(ctx, *key);
return true;
}
_ => {}
}
}
false
}
/// The arrows (and Space or N forward) step through top-level frames when nothing else uses
/// them. True when `key` stepped.
fn step_frames(p: &mut Presenter, ctx: &egui::Context, key: &egui::Key, mods: &egui::Modifiers, now: f64) -> bool {
let forward = match key {
egui::Key::ArrowRight => Some(true),
egui::Key::Space | egui::Key::N if mods.is_none() => Some(true),
egui::Key::ArrowLeft => Some(false),
_ => None,
};
if let Some(forward) = forward
&& p.overlays.is_empty()
&& let Some(base) = p.doc.as_ref()
{
let frames = base.top_frames(base.page_of(p.current).unwrap_or(p.current));
if let Some(i) = frames.iter().position(|f| *f == p.current) {
let next = if forward { frames.get(i + 1) } else { i.checked_sub(1).and_then(|j| frames.get(j)) };
if let Some(n) = next.copied() {
p.go(n, Transition::Instant, 0.0, Easing::Linear, now, true, false);
consume_key(ctx, *key);
return true;
}
}
}
false
}
/// Controller buttons: pressed alone, anywhere on the screen, like keys. True when one fired.
fn fire_pads(p: &mut Presenter, doc: &Document, root: NodeId, pads: &[String], now: f64) -> bool {
for name in pads {
let found = doc.subtree(root).into_iter().find_map(|id| find_trigger(doc, id, |t| t.fires_on_key(name, KeyMods::default())).map(|k| (id, k)));
if let Some((id, k)) = found
&& p.fire(id, k, None, now)
{
return true;
}
}
false
}
/// Pointer, wheel, keys and timers. `over_area`: the pointer is over the player; `keys_on`: the
/// player takes the keyboard; `pane`: it plays in the preview pane (which always fits).
#[allow(clippy::too_many_arguments)]
pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, keys_on: bool, pane: bool, now: f64) {
pub(super) struct InputMode {
pub over_area: bool,
pub keys_on: bool,
pub pane: bool,
pub now: f64,
}
pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, mode: InputMode) {
let InputMode { over_area, keys_on, pane, now } = mode;
let (pos, pressed, released, down, wheel, velocity, dt, keys) = ctx.input(|i| {
let keys: Vec<(egui::Key, egui::Modifiers)> = i
.events
@@ -39,24 +193,9 @@ pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_are
// Controller buttons pressed while a link notice shows are dropped, as keys are.
let pads = std::mem::take(&mut p.pads);
let pads = if p.link_notice.is_none() { pads } else { Vec::new() };
// A transition a drag started follows the pointer while it stays down, then plays on from there.
if let Some(a) = p.anim.as_mut()
&& let Some((from, dir, len)) = a.follow
{
match pos.filter(|_| down) {
Some(q) => a.start = now - f64::from(((q - from).dot(dir) / len).clamp(0.0, 1.0)) * a.dur,
None => a.follow = None,
}
}
follow_drag(p, pos, down, now);
let pos = pos.filter(|_| over_area || p.press.is_some());
// A drag that scrolled lets go: it carries on at the pointer's speed (and springs back from
// past the ends).
if !down && let Some((t, _)) = p.drag_scroll.take() {
let u = units_per_point(t, l.scale);
let r = p.doc.as_ref().map_or((0.0, 0.0), |b| p.limits(b, t));
let along = |v: f32, r: f64| if r > 0.0 { -f64::from(v) * u } else { 0.0 };
p.fling = Some(Fling { target: t, vel: (along(velocity.x, r.0), along(velocity.y, r.1)), decay: DECAY_DRAG, bounce: true, at: now });
}
release_drag_scroll(p, l, down, velocity, now);
// The active layer: the top overlay, else the frame.
let (root, place, clip) = l.active(p.current);
let doc = &l.disp;
@@ -83,25 +222,7 @@ pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_are
if p.pan_range != (0.0, 0.0) {
targets.push(Scroller::View);
}
let mut moved = None;
for t in targets {
let u = units_per_point(t, l.scale);
let cur = p.offset(t);
let next = clamp_scroll((cur.0 - f64::from(wheel.x) * u, cur.1 - f64::from(wheel.y) * u), p.limits(&base, t));
if (next.0 - cur.0).abs() > 1e-6 || (next.1 - cur.1).abs() > 1e-6 {
p.set_offset(t, next);
p.scroll_anim = None;
p.fling = None;
moved = Some((t, ((next.0 - cur.0) / dt, (next.1 - cur.1) / dt)));
break;
}
}
// The wheel's speed, smoothed over ~50 ms whatever the frame rate.
p.wheel = moved.map(|(t, inst)| {
let prev = p.wheel.filter(|w| w.0 == t).map_or(inst, |w| w.1);
let a = 1.0 - (-dt / 0.05).exp();
(t, (lerp(prev.0, inst.0, a), lerp(prev.1, inst.1, a)))
});
wheel_scroll(p, l, &base, targets, wheel, dt);
} else if wheel == Vec2::ZERO
&& let Some((t, v)) = p.wheel.take()
&& v.0.hypot(v.1) > COAST_MIN * units_per_point(t, l.scale)
@@ -202,37 +323,10 @@ pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_are
// A drag begun inside a mobile screen moves its content, even if the pointer leaves the
// screen. The device body stays in place even when the drag begins outside its screen.
// Panning the whole view remains available only without a mobile device.
if let Some(base) = p.doc.clone() {
let horizontal = d.x.abs() >= d.y.abs();
let scrolls = scrollers_at(p, &base, doc, root, &place, start);
let on_axis = |range: (f64, f64)| if horizontal { range.0 > 0.0 } else { range.1 > 0.0 };
let mobile = page_settings(&base, root).device().is_some_and(|device| device.rotates);
let target = scrolls
.iter()
.copied()
.find(|f| on_axis(p.range(&base, *f)))
.or_else(|| scrolls.first().copied())
.map(Scroller::Frame)
.or((!mobile && p.pan_range != (0.0, 0.0)).then_some(Scroller::View));
if let Some(t) = target {
p.fling = None;
p.drag_scroll = Some((t, clamp_scroll(p.offset(t), p.limits(&base, t))));
}
}
begin_drag_scroll(p, doc, root, &place, start, d);
}
// Dragging scrolls with the pointer; past the ends it stretches.
if down
&& let (Some((t, from)), Some(start), Some(q)) = (p.drag_scroll, p.press, pos)
&& let Some(base) = p.doc.as_ref()
{
let (d, u) = (q - start, units_per_point(t, l.scale));
let r = p.limits(base, t);
let limit = RUBBER_PTS * u;
let to = (rubber_band(from.0 - f64::from(d.x) * u, r.0, limit), rubber_band(from.1 - f64::from(d.y) * u, r.1, limit));
p.set_offset(t, to);
p.scroll_anim = None;
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
}
drag_scrolls(p, ctx, l, down, pos);
if released && p.press.is_some() {
let dragged = p.dragged;
p.press = None;
@@ -262,14 +356,8 @@ pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_are
}
}
}
// Controller buttons: pressed alone, anywhere on the screen, like keys.
for name in &pads {
let found = doc.subtree(root).into_iter().find_map(|id| find_trigger(doc, id, |t| t.fires_on_key(name, KeyMods::default())).map(|k| (id, k)));
if let Some((id, k)) = found
&& p.fire(id, k, None, now)
{
return;
}
if fire_pads(p, doc, root, &pads, now) {
return;
}
// Keys.
for (key, mods) in &keys {
@@ -282,45 +370,8 @@ pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_are
}
continue;
}
// The player's own keys, when the prototype doesn't use them: R restarts, Z cycles the
// full-window presenter's size options.
if mods.is_none() {
match key {
egui::Key::R => {
p.restart(p.start, now);
consume_key(ctx, *key);
return;
}
// (A responsive frame filling the window has no size options.)
egui::Key::Z if !pane && (l.device.is_some() || p.fit.is_none()) => {
p.set_scale(p.view.scale.next(l.device.is_some()));
consume_key(ctx, *key);
return;
}
_ => {}
}
}
// The arrows (and Space or N forward) step through top-level frames when nothing else
// uses them.
let forward = match key {
egui::Key::ArrowRight => Some(true),
egui::Key::Space | egui::Key::N if mods.is_none() => Some(true),
egui::Key::ArrowLeft => Some(false),
_ => None,
};
if let Some(forward) = forward
&& p.overlays.is_empty()
&& let Some(base) = p.doc.as_ref()
{
let frames = base.top_frames(base.page_of(p.current).unwrap_or(p.current));
if let Some(i) = frames.iter().position(|f| *f == p.current) {
let next = if forward { frames.get(i + 1) } else { i.checked_sub(1).and_then(|j| frames.get(j)) };
if let Some(n) = next.copied() {
p.go(n, Transition::Instant, 0.0, Easing::Linear, now, true, false);
consume_key(ctx, *key);
return;
}
}
if player_key(p, ctx, l, key, mods, pane, now) || step_frames(p, ctx, key, mods, now) {
return;
}
}
// After delay (timers start when the frame appears; not while animating).
+4 -1
View File
@@ -1,5 +1,8 @@
use super::scroll::{clamp_scroll, page_settings};
use super::*;
use super::{
Device, DeviceColor, Document, NodeId, OverlayState, Point, Pos2, PresentScale, PresentView, Presenter, Rect, Scroller, Vec2,
anchored_overlay_rect, pos2, vec2,
};
/// Page → screen mapping for one presented frame.
#[derive(Clone, Copy, Debug)]
+1 -1
View File
@@ -1,5 +1,5 @@
use super::scroll::{apply_scroll, clamp_scroll, fit_viewport, page_settings, raise_fixed, viewport};
use super::*;
use super::{Anim, Arc, Document, Easing, NodeId, Presenter, Scroller, Snapshot, Transition, Trigger, Value, Visit, json};
impl Presenter {
/// Close the top overlay; it animates out with its opening transition reversed.
+6 -3
View File
@@ -1,12 +1,15 @@
use super::chrome::{BarAction, layer_at, paint_menu_chevron};
use super::device::{device_body, device_glass};
use super::draw::draw;
use super::input::input;
use super::input::{InputMode, input};
use super::layout::{Chrome, Layout, layout};
use super::navigate::next_delay;
use super::scroll::page_settings;
use super::transition::lerp;
use super::*;
use super::{
ACCENT, Align2, Arc, Color32, CornerRadius, Device, Icon, NodeKind, PresentScale, Presenter, Rect, Sense, Stroke, StrokeKind, Tip, Tokens, Ui,
icons, pos2, regular, semibold, tr, tr_noop, vec2,
};
/// One frame of the player in `rect`: the top bar, then the prototype (on its device) below.
/// Returns what the bar asked for (restarting is done here).
@@ -55,7 +58,7 @@ pub(super) fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrom
// The pane only takes the keyboard while the pointer is over it. While a link notice shows
// the prototype takes neither keys nor the pointer (Escape there cancels the notice).
let keys = chrome.keys && p.link_notice.is_none() && (!chrome.pane || over);
input(p, &ctx, &l, over && p.link_notice.is_none(), keys, chrome.pane, now);
input(p, &ctx, &l, InputMode { over_area: over && p.link_notice.is_none(), keys_on: keys, pane: chrome.pane, now });
} else {
p.pads.clear();
}
+4 -1
View File
@@ -1,5 +1,8 @@
use super::layout::Place;
use super::*;
use super::{
Affine, Document, HConstraint, HashMap, NodeId, NodeKind, Overflow, Pos2, Presenter, PrototypeSettings, ScrollMove, ScrollPosition, Scroller,
VConstraint,
};
/// How quickly a fling slows down after a drag (1/s; it travels its speed / decay).
pub(super) const DECAY_DRAG: f64 = 2.5;
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Affine, BTreeMap, Direction, Document, HashMap, HashSet, Node, NodeId, OverlayPosition, Paint, PaintKind, Point, Rect, Transition, Vec2,
VecDeque, pos2,
};
// ---------------------------------------------------------------------------------------------
// Pure prototype logic (tested below).
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, FigApp, Future, PathBuf, Pin, Session, Value, Wake, i18n, json};
/// Platform services the app crate provides (file dialogs, clipboard, URLs).
pub trait Services {
+253 -159
View File
@@ -105,21 +105,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
let rect = ui.max_rect();
let buttons_w = if app.ui.title_bar == TitleBar::Custom { window_buttons(app, ui, rect) } else { 0.0 };
let mut x = rect.min.x + if app.ui.title_bar == TitleBar::Inset { 76.0 } else { 8.0 };
// Home.
let hr = Rect::from_min_size(pos2(x, rect.min.y), vec2(44.0, HEIGHT));
let hresp = ui.interact(hr, ui.scope_id().with("home"), Sense::click()).tip(tr("Home"));
let home_active = (app.ui.home || !app.has_files()) && app.ui.present.is_none();
crate::a11y::tab(&hresp, tr("Home"), home_active);
if home_active {
ui.painter().rect_filled(hr, CornerRadius::ZERO, t.tab_active);
} else if hresp.hovered() {
ui.painter().rect_filled(hr, CornerRadius::ZERO, t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(hr.center(), vec2(16.0, 16.0)), Icon::Home, t.icon);
if hresp.clicked() {
pause_present(app);
app.ui.home = true;
}
home_tab(app, ui, &t, x, rect);
x += 44.0;
ui.painter().line_segment([pos2(x, rect.min.y), pos2(x, rect.max.y)], Stroke::new(1.0, t.border));
let document_tabs: Vec<usize> = app.session.docs.iter().enumerate().filter_map(|(i, d)| app.shows(d.uid).then_some(i)).collect();
@@ -133,15 +119,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
motion.begin_frame(current_uids, now, reduce_motion);
motion.opening.clone()
});
let preferred: Vec<f32> = document_tabs
.iter()
.map(|&i| {
let d = &app.session.docs[i];
let mark_w = if live_mark(app, d.uid).is_some() { 18.0 } else { 0.0 };
let dot_w = if d.is_dirty() && mark_w > 0.0 { 12.0 } else { 0.0 };
(regular(12.0).size * 0.6 * d.doc.name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0)
})
.collect();
let preferred: Vec<f32> = document_tabs.iter().map(|&i| preferred_width(app, i)).collect();
// Leave the new-tab button, theme toggle, window controls and a draggable gap free.
let room = (rect.max.x - buttons_w - 70.0 - x - 34.0).max(0.0);
let present_pref: Vec<f32> = app.ui.presents.iter().map(present_tab_width).collect();
@@ -172,25 +150,17 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
let name = &d.doc.name;
// In a live session: a mark after the name says whether others get in.
let live = live_mark(app, d.uid);
let mark_w = if live.is_some() { 18.0 } else { 0.0 };
let dirty = d.is_dirty();
let r = tab_rects[slot];
let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y));
let close_r = Rect::from_min_max(body_r.right_top(), r.max);
let resp = ui.interact(body_r, ui.scope_id().with(("tab", d.uid)), Sense::click_and_drag());
let cresp = ui.interact(close_r, ui.scope_id().with(("tabclose", d.uid)), Sense::click()).tip(tr("Close tab"));
if (resp.dragged() || resp.drag_stopped())
&& let Some(p) = ui.input(|inp| inp.pointer.latest_pos())
{
if resp.drag_stopped() {
if several && (p.y - rect.center().y).abs() > HEIGHT * 2.0 {
detach = Some((i, Some(p)));
} else if (rect.min.y - 12.0..=rect.max.y + 12.0).contains(&p.y) {
dropped = Some((i, p.x));
}
} else {
dragging = Some((i, p));
}
match tab_gesture(ui, &resp, rect, several) {
Some(TabGesture::Detach(p)) => detach = Some((i, Some(p))),
Some(TabGesture::Drop(x)) => dropped = Some((i, x)),
Some(TabGesture::Drag(p)) => dragging = Some((i, p)),
None => {}
}
if several {
resp.context_menu(|ui| {
@@ -201,51 +171,20 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
}
});
}
let label = match live {
Some(true) => trf("{name}, in a live session", &[("name", name)]),
Some(false) => trf("{name}, online, open only to you", &[("name", name)]),
None => name.clone(),
};
let label = tab_label(name, live);
let resp = if live.is_some() { resp.on_hover_text(&label) } else { resp };
crate::a11y::tab(&resp, &label, active);
let progress = open_progress(&opening, d.uid, now);
let paint_r = r.translate(vec2(-28.0 * (1.0 - progress).powi(2), 0.0));
let painter = ui.painter().with_clip_rect(r);
if active {
painter.rect_filled(paint_r, CornerRadius::ZERO, t.tab_active.linear_multiply(progress));
} else if resp.hovered() || cresp.hovered() {
painter.rect_filled(paint_r, CornerRadius::ZERO, t.hover.linear_multiply(progress));
}
icons::paint(
&painter,
Rect::from_center_size(pos2(paint_r.min.x + 18.0, paint_r.center().y), vec2(14.0, 14.0)),
icons::file_mark(d.doc.kind),
crate::theme::ACCENT.linear_multiply(progress),
);
// The unsaved-changes dot follows the name on screen, whichever way the name reads.
let shown = crate::bidi::paint_name(
&painter,
pos2(paint_r.min.x + 32.0, paint_r.center().y),
let tab = TabPaint {
name,
regular(12.0),
t.text.linear_multiply(progress),
(w - mark_w - if dirty { 72.0 } else { 60.0 }).max(0.0),
);
if let Some(shared) = live {
let at = Rect::from_center_size(pos2(paint_r.max.x - 38.0, paint_r.center().y), vec2(14.0, 14.0));
icons::paint(&painter, at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary }.linear_multiply(progress));
}
if dirty {
painter.text(pos2(shown.max.x + 4.0, paint_r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text.linear_multiply(progress));
}
let cr = Rect::from_center_size(close_r.center() + vec2(paint_r.min.x - r.min.x, 0.0), vec2(18.0, 18.0));
// The close target never overlaps the draggable body, including on inactive tabs.
if resp.hovered() || cresp.hovered() || active {
if cresp.hovered() {
painter.rect_filled(cr, CornerRadius::same(4), t.pressed.linear_multiply(progress));
}
icons::paint(&painter, cr.shrink(4.0), Icon::Close, t.icon_secondary.linear_multiply(progress));
}
kind: d.doc.kind,
rects: [r, close_r],
width: w,
state: [active, resp.hovered(), cresp.hovered(), dirty],
live,
progress,
};
paint_tab(ui, &t, &tab);
if cresp.clicked() {
close = Some(i);
}
@@ -267,26 +206,8 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
}
let reorder = dropped.and_then(|(from, at)| tab_drop_position(&visible, from, at).map(|(to, _)| (from, to)));
if layout.overflow {
let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(OVERFLOW_WIDTH, HEIGHT));
let resp = ui.interact(r, ui.scope_id().with("tab_overflow"), Sense::click()).tip(tr("Open files"));
crate::a11y::menu_button(&resp, tr("Open files"), crate::a11y::menu_open(&resp));
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), Icon::Chevron, t.icon);
crate::right::menu_popup(&resp).show(|ui| {
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
for &i in &document_tabs {
let Some(d) = app.session.docs.get(i) else { continue };
let on = i == app.session.active && !app.ui.home && app.ui.present.is_none();
let label = if d.is_dirty() { format!("{} •", d.doc.name) } else { d.doc.name.clone() };
if crate::right::menu_check(ui, on, &label, "") {
switch = Some(i);
ui.close();
}
}
});
});
let resp = overflow_button(ui, &t, pos2(x, rect.min.y), "tab_overflow", tr("Open files"));
switch = doc_overflow_menu(app, &resp, &document_tabs).or(switch);
x += OVERFLOW_WIDTH;
}
// Presentations live in their own tabs. Switching back to the design keeps the player
@@ -306,26 +227,8 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
ui.painter().line_segment([pos2(x, rect.min.y + 10.0), pos2(x, rect.max.y - 10.0)], Stroke::new(1.0, t.border));
}
if present_layout.overflow {
let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(OVERFLOW_WIDTH, HEIGHT));
let resp = ui.interact(r, ui.scope_id().with("present_overflow"), Sense::click()).tip(tr("Open presentations"));
crate::a11y::menu_button(&resp, tr("Open presentations"), crate::a11y::menu_open(&resp));
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), Icon::Chevron, t.icon);
crate::right::menu_popup(&resp).show(|ui| {
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
for tab in &app.ui.presents {
let on = present_on == Some(tab.id);
if crate::right::menu_check(ui, on, &present_tab_name(tab), "") {
if !on {
to_present = Some(tab.id);
}
ui.close();
}
}
});
});
let resp = overflow_button(ui, &t, pos2(x, rect.min.y), "present_overflow", tr("Open presentations"));
to_present = present_overflow_menu(app, &resp, present_on).or(to_present);
x += OVERFLOW_WIDTH;
}
// New tab.
@@ -340,28 +243,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
app.run("file.new", json!({}));
app.ui.home = false;
}
// Right side: theme toggle (left of the window buttons).
let theme_r = Rect::from_center_size(pos2(rect.max.x - buttons_w - 24.0, rect.center().y), vec2(28.0, 28.0));
let tresp = ui.interact(theme_r, ui.scope_id().with("theme"), Sense::click()).tip(tr("Toggle light/dark theme"));
if tresp.hovered() {
ui.painter().rect_filled(theme_r, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), theme_r.shrink(7.0), if app.ui.dark { Icon::Sun } else { Icon::Moon }, t.icon);
if tresp.clicked() {
let cmd = if app.ui.dark { "view.themeLight" } else { "view.themeDark" };
app.run(cmd, json!({}));
}
// Only the free space drags the window. A full-bar drag target steals tab drags.
if app.ui.title_bar != TitleBar::Native && x + 34.0 < theme_r.min.x {
let free = Rect::from_min_max(pos2(x + 34.0, rect.min.y), pos2(theme_r.min.x, rect.max.y));
let drag = ui.interact(free, ui.scope_id().with("titlebar_drag"), Sense::click_and_drag());
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if drag.double_clicked() {
let max = ui.input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
}
let theme_r = theme_toggle(app, ui, &t, rect, buttons_w);
if app.ui.title_bar != TitleBar::Native {
drag_free_space(ui, rect, x + 34.0, theme_r.min.x);
}
if let Some(i) = switch {
pause_present(app);
@@ -374,11 +258,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
show_present(app, id);
}
if let Some((i, at)) = detach {
// Where the pointer let go, on the screen.
let at = at.and_then(|p| ui.input(|inp| inp.viewport().inner_rect).map(|r| r.min + p.to_vec2() - vec2(60.0, HEIGHT / 2.0)));
if let Err(e) = crate::windows::open(app, ui.ctx(), Some(i), None, at) {
app.toast(e);
}
detach_tab(app, ui, i, at);
}
if let Some((from, to)) = reorder
&& from != to
@@ -386,20 +266,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
app.run("file.moveTab", json!({"from": from, "to": to}));
}
if let Some(i) = close {
// A branch tab's edits live on in its main file: closing it loses nothing.
let dirty = app.session.docs.get(i).is_some_and(|d| d.is_dirty() && d.branch_of.is_none());
if dirty {
app.ui.find = None;
app.toast(trf(
"Unsaved changes: save first, or close again with {key} to discard",
&[("key", &crate::toolbar::pretty_shortcut("Alt"))],
));
if ui.input(|inp| inp.modifiers.alt) {
app.run("file.close", json!({"index": i, "force": true}));
}
} else {
app.run("file.close", json!({"index": i}));
}
close_tab(app, ui, i);
}
if !opening.is_empty() {
ui.ctx().request_repaint();
@@ -407,6 +274,233 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
});
}
/// The chevron button at `at` that opens a menu of the tabs that did not fit.
fn overflow_button(ui: &mut Ui, t: &Tokens, at: egui::Pos2, salt: &str, label: &'static str) -> egui::Response {
let r = Rect::from_min_size(at, vec2(OVERFLOW_WIDTH, HEIGHT));
let resp = ui.interact(r, ui.scope_id().with(salt), Sense::click()).tip(label);
crate::a11y::menu_button(&resp, label, crate::a11y::menu_open(&resp));
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), Icon::Chevron, t.icon);
resp
}
/// Only the free space between `from_x` and `to_x` drags the window (a full-bar drag target would
/// steal tab drags); double click maximizes.
fn drag_free_space(ui: &mut Ui, rect: Rect, from_x: f32, to_x: f32) {
if from_x >= to_x {
return;
}
let free = Rect::from_min_max(pos2(from_x, rect.min.y), pos2(to_x, rect.max.y));
let drag = ui.interact(free, ui.scope_id().with("titlebar_drag"), Sense::click_and_drag());
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if drag.double_clicked() {
let max = ui.input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
}
}
/// Closes tab `i`; unsaved changes need a second close with Alt held.
fn close_tab(app: &mut FigApp, ui: &Ui, i: usize) {
// A branch tab's edits live on in its main file: closing it loses nothing.
let dirty = app.session.docs.get(i).is_some_and(|d| d.is_dirty() && d.branch_of.is_none());
if dirty {
app.ui.find = None;
app.toast(trf("Unsaved changes: save first, or close again with {key} to discard", &[("key", &crate::toolbar::pretty_shortcut("Alt"))]));
if ui.input(|inp| inp.modifiers.alt) {
app.run("file.close", json!({"index": i, "force": true}));
}
} else {
app.run("file.close", json!({"index": i}));
}
}
/// The Home tab at `x`.
fn home_tab(app: &mut FigApp, ui: &mut Ui, t: &Tokens, x: f32, rect: Rect) {
let hr = Rect::from_min_size(pos2(x, rect.min.y), vec2(44.0, HEIGHT));
let hresp = ui.interact(hr, ui.scope_id().with("home"), Sense::click()).tip(tr("Home"));
let home_active = (app.ui.home || !app.has_files()) && app.ui.present.is_none();
crate::a11y::tab(&hresp, tr("Home"), home_active);
if home_active {
ui.painter().rect_filled(hr, CornerRadius::ZERO, t.tab_active);
} else if hresp.hovered() {
ui.painter().rect_filled(hr, CornerRadius::ZERO, t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(hr.center(), vec2(16.0, 16.0)), Icon::Home, t.icon);
if hresp.clicked() {
pause_present(app);
app.ui.home = true;
}
}
/// The theme toggle, left of the window buttons; returns its rect.
fn theme_toggle(app: &mut FigApp, ui: &mut Ui, t: &Tokens, rect: Rect, buttons_w: f32) -> Rect {
let theme_r = Rect::from_center_size(pos2(rect.max.x - buttons_w - 24.0, rect.center().y), vec2(28.0, 28.0));
let tresp = ui.interact(theme_r, ui.scope_id().with("theme"), Sense::click()).tip(tr("Toggle light/dark theme"));
if tresp.hovered() {
ui.painter().rect_filled(theme_r, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), theme_r.shrink(7.0), if app.ui.dark { Icon::Sun } else { Icon::Moon }, t.icon);
if tresp.clicked() {
let cmd = if app.ui.dark { "view.themeLight" } else { "view.themeDark" };
app.run(cmd, json!({}));
}
theme_r
}
/// What a drag on a document tab is doing.
enum TabGesture {
/// Released far off the strip: the tab moves to a window of its own, at this point.
Detach(egui::Pos2),
/// Released on the strip, at this x.
Drop(f32),
/// Still dragging, at this point.
Drag(egui::Pos2),
}
/// The drag on a tab body `resp` in the tab bar `rect`, if any.
fn tab_gesture(ui: &Ui, resp: &egui::Response, rect: Rect, several: bool) -> Option<TabGesture> {
if !(resp.dragged() || resp.drag_stopped()) {
return None;
}
let p = ui.input(|inp| inp.pointer.latest_pos())?;
if !resp.drag_stopped() {
return Some(TabGesture::Drag(p));
}
if several && (p.y - rect.center().y).abs() > HEIGHT * 2.0 {
Some(TabGesture::Detach(p))
} else if (rect.min.y - 12.0..=rect.max.y + 12.0).contains(&p.y) {
Some(TabGesture::Drop(p.x))
} else {
None
}
}
/// The menu of document tabs that did not fit: the one picked.
fn doc_overflow_menu(app: &FigApp, resp: &egui::Response, document_tabs: &[usize]) -> Option<usize> {
let mut switch = None;
crate::right::menu_popup(resp).show(|ui| {
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
for &i in document_tabs {
let Some(d) = app.session.docs.get(i) else { continue };
let on = i == app.session.active && !app.ui.home && app.ui.present.is_none();
let label = if d.is_dirty() { format!("{} •", d.doc.name) } else { d.doc.name.clone() };
if crate::right::menu_check(ui, on, &label, "") {
switch = Some(i);
ui.close();
}
}
});
});
switch
}
/// The menu of presentation tabs that did not fit: the one picked (not the one already shown).
fn present_overflow_menu(app: &FigApp, resp: &egui::Response, present_on: Option<u64>) -> Option<u64> {
let mut to_present = None;
crate::right::menu_popup(resp).show(|ui| {
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
for tab in &app.ui.presents {
let on = present_on == Some(tab.id);
if crate::right::menu_check(ui, on, &present_tab_name(tab), "") {
if !on {
to_present = Some(tab.id);
}
ui.close();
}
}
});
});
to_present
}
/// Moves tab `i` to a window of its own; `at` is where the pointer let go, in the tab bar.
fn detach_tab(app: &mut FigApp, ui: &Ui, i: usize, at: Option<egui::Pos2>) {
// Where the pointer let go, on the screen.
let at = at.and_then(|p| ui.input(|inp| inp.viewport().inner_rect).map(|r| r.min + p.to_vec2() - vec2(60.0, HEIGHT / 2.0)));
if let Err(e) = crate::windows::open(app, ui.ctx(), Some(i), None, at) {
app.toast(e);
}
}
/// How wide document tab `i` wants to be: its name, the live mark and the unsaved dot.
fn preferred_width(app: &FigApp, i: usize) -> f32 {
let d = &app.session.docs[i];
let mark_w = if live_mark(app, d.uid).is_some() { 18.0 } else { 0.0 };
let dot_w = if d.is_dirty() && mark_w > 0.0 { 12.0 } else { 0.0 };
(regular(12.0).size * 0.6 * d.doc.name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0)
}
/// A tab's accessible name: `name`, and in a live session whether others get in.
fn tab_label(name: &str, live: Option<bool>) -> String {
match live {
Some(true) => trf("{name}, in a live session", &[("name", &name)]),
Some(false) => trf("{name}, online, open only to you", &[("name", &name)]),
None => name.to_string(),
}
}
/// What to draw for one document tab.
struct TabPaint<'a> {
name: &'a str,
kind: figcraft_doc::FileKind,
/// The tab, and its close target.
rects: [Rect; 2],
width: f32,
/// [active, body hovered, close hovered, unsaved changes].
state: [bool; 4],
/// In a live session: whether others get in.
live: Option<bool>,
/// How far the opening animation is (0..=1).
progress: f32,
}
/// Draws a document tab: background, icon, name, live mark, unsaved dot and close button.
fn paint_tab(ui: &Ui, t: &Tokens, tab: &TabPaint) {
let TabPaint { name, kind, rects: [r, close_r], width: w, state: [active, hovered, close_hovered, dirty], live, progress } = *tab;
let mark_w = if live.is_some() { 18.0 } else { 0.0 };
let paint_r = r.translate(vec2(-28.0 * (1.0 - progress).powi(2), 0.0));
let painter = ui.painter().with_clip_rect(r);
if active {
painter.rect_filled(paint_r, CornerRadius::ZERO, t.tab_active.linear_multiply(progress));
} else if hovered || close_hovered {
painter.rect_filled(paint_r, CornerRadius::ZERO, t.hover.linear_multiply(progress));
}
icons::paint(
&painter,
Rect::from_center_size(pos2(paint_r.min.x + 18.0, paint_r.center().y), vec2(14.0, 14.0)),
icons::file_mark(kind),
crate::theme::ACCENT.linear_multiply(progress),
);
// The unsaved-changes dot follows the name on screen, whichever way the name reads.
let shown = crate::bidi::paint_name(
&painter,
pos2(paint_r.min.x + 32.0, paint_r.center().y),
name,
regular(12.0),
t.text.linear_multiply(progress),
(w - mark_w - if dirty { 72.0 } else { 60.0 }).max(0.0),
);
if let Some(shared) = live {
let at = Rect::from_center_size(pos2(paint_r.max.x - 38.0, paint_r.center().y), vec2(14.0, 14.0));
icons::paint(&painter, at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary }.linear_multiply(progress));
}
if dirty {
painter.text(pos2(shown.max.x + 4.0, paint_r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text.linear_multiply(progress));
}
let cr = Rect::from_center_size(close_r.center() + vec2(paint_r.min.x - r.min.x, 0.0), vec2(18.0, 18.0));
// The close target never overlaps the draggable body, including on inactive tabs.
if hovered || close_hovered || active {
if close_hovered {
painter.rect_filled(cr, CornerRadius::same(4), t.pressed.linear_multiply(progress));
}
icons::paint(&painter, cr.shrink(4.0), Icon::Close, t.icon_secondary.linear_multiply(progress));
}
}
/// One presentation tab in `r`: whether it was clicked and whether it was closed (its X or a
/// middle click).
fn present_tab(ui: &mut Ui, t: &Tokens, tab: &PresentTab, r: Rect, active: bool) -> (bool, bool) {