diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index c10b402c..8d3521c2 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -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; diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index 2280f9a1..c54e5f94 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -1,4 +1,4 @@ -use super::*; +use super::FigApp; impl FigApp { /// Put `text` on the system clipboard. Input injected through the control channel never diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 38adf660..4ccb8939 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -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. diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index bbbe9c61..7fd8f139 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -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 diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index ae17b7bc..532cf8ab 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -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) { diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 96225f20..c44d457d 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -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 diff --git a/crates/ui-egui/src/app/ime.rs b/crates/ui-egui/src/app/ime.rs index df0a7fd2..803e6230 100644 --- a/crates/ui-egui/src/app/ime.rs +++ b/crates/ui-egui/src/app/ime.rs @@ -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. diff --git a/crates/ui-egui/src/canvas/builder.rs b/crates/ui-egui/src/canvas/builder.rs index bc2360da..779a580a 100644 --- a/crates/ui-egui/src/canvas/builder.rs +++ b/crates/ui-egui/src/canvas/builder.rs @@ -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; diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index fc518d2f..a6bc55b5 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -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) { 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()) { diff --git a/crates/ui-egui/src/canvas/outline.rs b/crates/ui-egui/src/canvas/outline.rs index 58f5fd20..e5a589d8 100644 --- a/crates/ui-egui/src/canvas/outline.rs +++ b/crates/ui-egui/src/canvas/outline.rs @@ -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) diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 675427c1..d88d6ef6 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -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 = 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 { + 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 = 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 = 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` diff --git a/crates/ui-egui/src/canvas/render.rs b/crates/ui-egui/src/canvas/render.rs index 915e8ea5..c6c4702f 100644 --- a/crates/ui-egui/src/canvas/render.rs +++ b/crates/ui-egui/src/canvas/render.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, CanvasState, Done, FigApp, Job, NodeId, Worker}; #[derive(Clone, PartialEq)] pub(super) struct JobKey { diff --git a/crates/ui-egui/src/canvas/rulers.rs b/crates/ui-egui/src/canvas/rulers.rs index a394ca7c..b43bcc5b 100644 --- a/crates/ui-egui/src/canvas/rulers.rs +++ b/crates/ui-egui/src/canvas/rulers.rs @@ -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 diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index afef387d..23b7a63e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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 { + 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, ¶ms).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 { + 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 { + 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", ¶ms).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 ({: "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 { 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, ¶ms).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 { - 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", ¶ms).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 ({: "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" => { diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 012dd923..fd7b529c 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -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, 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 { diff --git a/crates/ui-egui/src/icons/draw.rs b/crates/ui-egui/src/icons/draw.rs index 1d088c91..a96314cf 100644 --- a/crates/ui-egui/src/icons/draw.rs +++ b/crates/ui-egui/src/icons/draw.rs @@ -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); diff --git a/crates/ui-egui/src/open.rs b/crates/ui-egui/src/open.rs index 4fe8b9bd..47b5d575 100644 --- a/crates/ui-egui/src/open.rs +++ b/crates/ui-egui/src/open.rs @@ -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"]; diff --git a/crates/ui-egui/src/present/actions.rs b/crates/ui-egui/src/present/actions.rs index 97f94eeb..ad2c646e 100644 --- a/crates/ui-egui/src/present/actions.rs +++ b/crates/ui-egui/src/present/actions.rs @@ -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, + 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, 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, 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 } diff --git a/crates/ui-egui/src/present/chrome.rs b/crates/ui-egui/src/present/chrome.rs index 80386eb7..eeaba1ce 100644 --- a/crates/ui-egui/src/present/chrome.rs +++ b/crates/ui-egui/src/present/chrome.rs @@ -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)] diff --git a/crates/ui-egui/src/present/device.rs b/crates/ui-egui/src/present/device.rs index 327fdd66..9827c225 100644 --- a/crates/ui-egui/src/present/device.rs +++ b/crates/ui-egui/src/present/device.rs @@ -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); diff --git a/crates/ui-egui/src/present/draw.rs b/crates/ui-egui/src/present/draw.rs index 271f8af7..b4d67914 100644 --- a/crates/ui-egui/src/present/draw.rs +++ b/crates/ui-egui/src/present/draw.rs @@ -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()); diff --git a/crates/ui-egui/src/present/input.rs b/crates/ui-egui/src/present/input.rs index 42df2286..bfebd4b1 100644 --- a/crates/ui-egui/src/present/input.rs +++ b/crates/ui-egui/src/present/input.rs @@ -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, 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, 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) { + 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). diff --git a/crates/ui-egui/src/present/layout.rs b/crates/ui-egui/src/present/layout.rs index 9584fc81..1844c4f3 100644 --- a/crates/ui-egui/src/present/layout.rs +++ b/crates/ui-egui/src/present/layout.rs @@ -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)] diff --git a/crates/ui-egui/src/present/navigate.rs b/crates/ui-egui/src/present/navigate.rs index a320e80d..75969485 100644 --- a/crates/ui-egui/src/present/navigate.rs +++ b/crates/ui-egui/src/present/navigate.rs @@ -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. diff --git a/crates/ui-egui/src/present/player.rs b/crates/ui-egui/src/present/player.rs index dc47baa7..006d9521 100644 --- a/crates/ui-egui/src/present/player.rs +++ b/crates/ui-egui/src/present/player.rs @@ -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(); } diff --git a/crates/ui-egui/src/present/scroll.rs b/crates/ui-egui/src/present/scroll.rs index 12c682cc..ea3db153 100644 --- a/crates/ui-egui/src/present/scroll.rs +++ b/crates/ui-egui/src/present/scroll.rs @@ -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; diff --git a/crates/ui-egui/src/present/transition.rs b/crates/ui-egui/src/present/transition.rs index e31078d2..09403c1f 100644 --- a/crates/ui-egui/src/present/transition.rs +++ b/crates/ui-egui/src/present/transition.rs @@ -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). diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index d7490de4..e5f915b2 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -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 { diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 6f17910a..1a4c9f9b 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -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 = 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 = 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 = 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 = 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 { + 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 { + 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) -> Option { + 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) { + // 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) -> 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, + /// 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) {