diff --git a/crates/engine/src/board_tests.rs b/crates/engine/src/board_tests.rs index dbd00566..f7a57db8 100644 --- a/crates/engine/src/board_tests.rs +++ b/crates/engine/src/board_tests.rs @@ -313,3 +313,20 @@ fn board_commands_survive_junk() { } } } + +#[test] +fn a_press_on_a_stickys_background_picks_and_drags_it() { + let mut s = board(); + let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "text": "Hi"})).unwrap(), "id"); + let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id"); + let c = id(&s.execute("board.connector", &json!({"from": a, "to": b})).unwrap(), "id"); + s.execute("select.none", &json!({})).unwrap(); + // Below the note, on the sticky's own fill (a top-level frame's background is the canvas). + s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 150.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 250.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 450.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 450.0, 1.0)).unwrap(); + assert_eq!(s.selection(), vec![a]); + assert_eq!(node(&s, a).transform.translation().y, 300.0); + assert!(near(drawn(&s, c)[0], Point::new(200.0, 400.0)), "the connector followed the drag: {:?}", drawn(&s, c)); +} diff --git a/crates/engine/src/shortcut_parity_tests.rs b/crates/engine/src/shortcut_parity_tests.rs index cf7d5d47..c0d7e65e 100644 --- a/crates/engine/src/shortcut_parity_tests.rs +++ b/crates/engine/src/shortcut_parity_tests.rs @@ -120,6 +120,15 @@ static ROWS: &[Row] = &[ pin(Tools, "tool.slice", &["S"], &[]), pin(Tools, "file.placeImage", &["Shift+Cmd+K"], &[]), pin(Tools, "tool.shapeBuilder", &["Shift+M"], &[]), + // Board tools (boards only), on keys no design-file shortcut uses. + pin(Tools, "tool.sticky", &["Q"], &[]), + pin(Tools, "tool.shape", &["B"], &[]), + pin(Tools, "tool.connector", &["X"], &[]), + pin(Tools, "tool.marker", &["M"], &[]), + pin(Tools, "tool.highlighter", &["U"], &[]), + pin(Tools, "tool.eraser", &["E"], &[]), + pin(Tools, "tool.stamp", &["W"], &[]), + pin(Tools, "board.nextSticky", &["Shift+Cmd+Enter"], &[]), // Vector edit mode doc(Selection, "Edit the selected shape's points", &["Enter"], &[], Bound("select.children"), "Enter on a shape edits it; on a frame or group it selects the children"), doc(Shape, "Vector edit: move", &["V"], &[], VectorTool(VTool::Move), ""), @@ -398,7 +407,7 @@ fn counts_match_the_docs() { let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count(); let pinned = ROWS.len() - documented.len(); // docs/ui-parity.md quotes these. - assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (129, 128, 1, 0, 2, 49)); + assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (129, 128, 1, 0, 2, 57)); } #[test] diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index a2bc6a2c..f64590b5 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1233,12 +1233,13 @@ impl Session { } // A frame's name label picks the frame itself. The background of a top-level frame with // layers in it is like the canvas (a press starts a marquee inside it) unless the frame is - // selected; an empty frame (or a widget) is picked and dragged. + // selected; an empty frame (or a widget, or a board object such as a sticky) is picked and + // dragged. let board_background = |leaf: NodeId| { !sel.contains(&leaf) - && doc - .node(leaf) - .is_some_and(|n| n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n)) + && doc.node(leaf).is_some_and(|n| { + n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n) && !n.is_board_atom() + }) }; let target = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| { self.hit(&doc, page, ev.p(), ev.tol() * 2.0) diff --git a/crates/format/src/thumbnail.rs b/crates/format/src/thumbnail.rs index a5e7e2b3..76ebcf4e 100644 --- a/crates/format/src/thumbnail.rs +++ b/crates/format/src/thumbnail.rs @@ -109,6 +109,8 @@ pub fn file_thumbnail(path: &Path, fit: u32) -> Option { pub struct Preview { pub page: Option<[f32; 4]>, pub picture: Option, + /// A design file or a board (`.fig` files and images: design). + pub kind: figcraft_doc::FileKind, } /// The [`Preview`] of the file at `path`, its picture at most `fit` pixels on its longer side. @@ -119,7 +121,7 @@ pub fn file_preview(path: &Path, fit: u32) -> Preview { let Ok(file) = std::fs::File::open(path) else { return Preview::default() }; archive_preview(std::io::BufReader::new(file), fit) } - FileKind::Image => Preview { page: None, picture: file_thumbnail(path, fit) }, + FileKind::Image => Preview { picture: file_thumbnail(path, fit), ..Preview::default() }, FileKind::Svg | FileKind::Other => Preview::default(), } } @@ -129,31 +131,33 @@ pub fn file_preview(path: &Path, fit: u32) -> Preview { pub fn archive_preview(archive: R, fit: u32) -> Preview { let Ok(mut z) = zip::ZipArchive::new(archive) else { return Preview::default() }; let picture = crate::read_member(&mut z, "thumbnail.png", MAX_THUMBNAIL_BYTES).ok().and_then(|png| decode_thumbnail(&png, fit)); - let page = if z.file_names().any(|n| n == "document.json") { - crate::read_member(&mut z, "document.json", MAX_DOCUMENT_BYTES).ok().and_then(|json| figcraft_page(&json)) + let (page, kind) = if z.file_names().any(|n| n == "document.json") { + crate::read_member(&mut z, "document.json", MAX_DOCUMENT_BYTES).ok().map(|json| figcraft_page(&json)).unwrap_or_default() } else { - crate::read_member(&mut z, "meta.json", MAX_META_BYTES).ok().and_then(|json| fig_page(&json)) + (crate::read_member(&mut z, "meta.json", MAX_META_BYTES).ok().and_then(|json| fig_page(&json)), figcraft_doc::FileKind::Design) }; - Preview { page, picture } + Preview { page, picture, kind } } -/// The first page's background in a `.figcraft` `document.json`. -fn figcraft_page(json: &[u8]) -> Option<[f32; 4]> { +/// The first page's background in a `.figcraft` `document.json`, and the file's kind. +fn figcraft_page(json: &[u8]) -> (Option<[f32; 4]>, figcraft_doc::FileKind) { #[derive(serde::Deserialize)] struct Head { #[serde(default)] pages: Vec, #[serde(default)] nodes: std::collections::BTreeMap, + #[serde(default)] + kind: figcraft_doc::FileKind, } #[derive(serde::Deserialize)] struct Node { #[serde(default)] background: Option, } - let head: Head = serde_json::from_slice(json).ok()?; - let first = head.pages.first()?; - rgba(head.nodes.get(&first.to_string())?.background.as_ref()?) + let Ok(head) = serde_json::from_slice::(json) else { return (None, figcraft_doc::FileKind::Design) }; + let page = head.pages.first().and_then(|first| head.nodes.get(&first.to_string())).and_then(|n| n.background.as_ref()).and_then(rgba); + (page, head.kind) } /// The canvas colour a `.fig` file's `meta.json` gives (`client_meta.background_color`). diff --git a/crates/format/src/thumbnail_tests.rs b/crates/format/src/thumbnail_tests.rs index 864c0f24..eeaeaba5 100644 --- a/crates/format/src/thumbnail_tests.rs +++ b/crates/format/src/thumbnail_tests.rs @@ -148,7 +148,12 @@ fn previews_carry_the_first_pages_colour() { assert!(p.picture.is_some()); // A file with nothing on it: its colour, no picture. let bytes = crate::save_bytes(&doc_on([0.9, 0.9, 0.9]), None).unwrap(); - assert_eq!(archive_preview(Cursor::new(&bytes), 640), Preview { page: Some([0.9, 0.9, 0.9, 1.0]), picture: None }); + assert_eq!(archive_preview(Cursor::new(&bytes), 640), Preview { page: Some([0.9, 0.9, 0.9, 1.0]), picture: None, kind: Default::default() }); + // A board says so (the home screen marks it). + let mut board = doc_on([0.9, 0.9, 0.9]); + board.kind = figcraft_doc::FileKind::Board; + let bytes = crate::save_bytes(&board, None).unwrap(); + assert_eq!(archive_preview(Cursor::new(&bytes), 640).kind, figcraft_doc::FileKind::Board); // Damaged or odd metadata: no colour, never a panic. for meta in [&b"{"[..], b"{\"client_meta\":{\"background_color\":{\"r\":\"x\",\"g\":0,\"b\":0}}}", b"[]", b"{\"client_meta\":7}"] { assert_eq!(archive_preview(Cursor::new(zip(&[("meta.json", meta)])), 640).page, None, "{}", String::from_utf8_lossy(meta)); diff --git a/crates/ui-egui/src/board_bar.rs b/crates/ui-egui/src/board_bar.rs new file mode 100644 index 00000000..d042b603 --- /dev/null +++ b/crates/ui-egui/src/board_bar.rs @@ -0,0 +1,255 @@ +//! Boards in the UI: the small bar floating over a board's selection (colour, shape, text size, +//! connector style; boards hide the Design panel until asked for), and the swatches it and the +//! board tools' flyouts share. + +use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use figcraft_doc::{BOARD_COLORS, BoardItem, BoardShape, NodeKind, Routing, Stamp, StrokeCap}; +use figcraft_engine::board::{BoardTool, TEXT_SIZES}; +use serde_json::{Value, json}; + +use crate::FigApp; +use crate::a11y; +use crate::i18n::tr; +use crate::icons::{self, Icon}; +use crate::theme::{Tokens, semibold}; +use crate::toolbar::Options; + +/// What a swatch shows. +pub(crate) enum Mark { + Color(figcraft_color::Color), + Shape(BoardShape), + Stamp(Stamp), +} + +/// One choice of a board tool's flyout: its name, the tool's params, whether it is the tool's now. +pub(crate) struct Choice { + pub label: &'static str, + pub params: Value, + pub on: bool, + pub mark: Mark, +} + +/// The choices a board tool's flyout offers. +pub(crate) fn choices(options: Options, board: &BoardTool) -> Vec { + match options { + Options::Colors => BOARD_COLORS + .iter() + .map(|(n, c)| Choice { label: color_label(n), params: json!({"color": n}), on: board.sticky == *c, mark: Mark::Color(*c) }) + .collect(), + Options::Shapes => BoardShape::ALL + .iter() + .map(|b| Choice { label: b.label(), params: json!({"shape": b.id()}), on: board.shape == *b, mark: Mark::Shape(*b) }) + .collect(), + Options::Stamps => Stamp::ALL + .iter() + .map(|b| Choice { label: b.label(), params: json!({"stamp": b.id()}), on: board.stamp == *b, mark: Mark::Stamp(*b) }) + .collect(), + Options::None => Vec::new(), + } +} + +/// A board colour's name as the UI says it. +fn color_label(name: &str) -> &'static str { + match name { + "yellow" => "Yellow", + "orange" => "Orange", + "red" => "Red", + "pink" => "Pink", + "violet" => "Violet", + "blue" => "Blue", + "green" => "Green", + _ => "Gray", + } +} + +/// Paint `mark` in `r`: a colour chip, a shape's outline in `ink`, or a stamp badge. +pub(crate) fn paint_choice(p: &egui::Painter, r: Rect, mark: &Mark, ink: Color32) { + match mark { + Mark::Color(c) => { + p.rect_filled(r, CornerRadius::same(4), c32(*c)); + p.rect_stroke(r, CornerRadius::same(4), Stroke::new(1.0, Color32::from_black_alpha(40)), StrokeKind::Inside); + } + Mark::Shape(s) => { + let path = s.path(14.0, 14.0); + let path = figcraft_geom::Affine::translate((1.0, 1.0)) * path; + icons::paint_outlines(p, r, 16.0, &[(&path, false)], ink, 1.2); + } + Mark::Stamp(s) => { + p.circle_filled(r.center(), r.width().min(r.height()) / 2.0, c32(s.color())); + let parts: Vec<(figcraft_geom::BezPath, bool)> = + s.mark().iter().filter_map(|m| figcraft_geom::BezPath::from_svg(m.d).ok().map(|b| (b, m.filled))).collect(); + let refs: Vec<(&figcraft_geom::BezPath, bool)> = parts.iter().map(|(b, f)| (b, *f)).collect(); + icons::paint_outlines(p, r, figcraft_doc::STAMP_SIZE, &refs, Color32::WHITE, 3.0); + } + } +} + +fn c32(c: figcraft_color::Color) -> Color32 { + let [r, g, b, a] = c.to_rgba8(); + Color32::from_rgba_unmultiplied(r, g, b, a) +} + +/// What the selection has that the bar can change. +#[derive(Default)] +struct Has { + color: bool, + shape: Option, + text: bool, + connector: Option<(Routing, bool)>, +} + +fn has(app: &FigApp) -> Option { + let ds = app.session.ds().ok()?; + let mut h = Has::default(); + for id in &ds.selection { + let n = ds.doc.node(*id)?; + match n.board.as_ref()? { + BoardItem::Sticky { .. } | BoardItem::Ink => h.color = true, + BoardItem::Shape { shape } => { + h.color = true; + h.text = true; + h.shape = Some(*shape); + } + BoardItem::Connector(c) => { + h.color = true; + h.connector = Some((c.routing, n.stroke.cap_end != StrokeCap::None)); + } + BoardItem::Label { .. } => h.text = true, + BoardItem::Stamp { .. } => {} + } + } + (h.color || h.text || h.connector.is_some()).then_some(h) +} + +const CELL: f32 = 28.0; +const SWATCH: f32 = 20.0; +const GAP: f32 = 4.0; + +/// The bar above a board's selection, while nothing is being dragged. +pub fn show(app: &mut FigApp, ctx: &egui::Context) { + if app.session.tool.busy() || !app.session.doc().is_ok_and(figcraft_engine::board::is_board) { + return; + } + let Some(h) = has(app) else { return }; + let Some(b) = app.session.ds().ok().and_then(|ds| ds.doc.bounds_of(&ds.selection)) else { return }; + let (top, bottom) = (app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); + let mut w = 8.0; + if h.color { + w += BOARD_COLORS.len() as f32 * (SWATCH + GAP) + 8.0; + } + if h.shape.is_some() { + w += CELL + 12.0 + 8.0; + } + if h.text { + w += TEXT_SIZES.len() as f32 * (CELL + 2.0) + 8.0; + } + if h.connector.is_some() { + w += 3.0 * (CELL + 2.0) + 8.0; + } + let size = vec2(w, 40.0); + let canvas = app.canvas.rect; + let cx = ((top.x + bottom.x) / 2.0).clamp(canvas.min.x + w / 2.0 + 8.0, (canvas.max.x - w / 2.0 - 8.0).max(canvas.min.x + w / 2.0 + 8.0)); + let above = top.y - 14.0 - size.y; + let y = if above > canvas.min.y + 8.0 { above } else { (bottom.y + 14.0).min(canvas.max.y - size.y - 80.0) }; + let at = pos2(cx - w / 2.0, y); + let t = Tokens::get(ctx); + egui::Area::new(egui::Id::new("board_bar")).fixed_pos(at).order(egui::Order::Middle).show(ctx, |ui| { + let (rect, _) = ui.allocate_exact_size(size, Sense::click()); + a11y::container_bounds(ui, rect); + let p = ui.painter(); + p.rect_filled(rect.expand(2.0).translate(vec2(0.0, 1.0)), CornerRadius::same(12), Color32::from_black_alpha(18)); + p.rect_filled(rect, CornerRadius::same(10), t.panel); + p.rect_stroke(rect, CornerRadius::same(10), Stroke::new(1.0, t.border), StrokeKind::Inside); + let mut x = rect.min.x + 8.0; + let cy = rect.center().y; + if h.color { + for (i, (name, c)) in BOARD_COLORS.iter().enumerate() { + let r = Rect::from_center_size(pos2(x + SWATCH / 2.0, cy), vec2(SWATCH, SWATCH)); + let resp = ui.interact(r, ui.id().with(("bb_color", i)), Sense::click()).on_hover_text(tr(color_label(name))); + a11y::button(&resp, tr(color_label(name))); + if resp.hovered() { + ui.painter().rect_filled(r.expand(2.0), CornerRadius::same(6), t.hover); + } + paint_choice(ui.painter(), r, &Mark::Color(*c), t.icon); + if resp.clicked() { + app.run("board.setColor", json!({"color": name})); + } + x += SWATCH + GAP; + } + x = divider(ui, &t, x, cy); + } + if let Some(shape) = h.shape { + let r = Rect::from_min_size(pos2(x, cy - CELL / 2.0), vec2(CELL + 12.0, CELL)); + let resp = ui.interact(r, ui.id().with("bb_shape"), Sense::click()).on_hover_text(tr("Shape")); + a11y::menu_button(&resp, tr("Shape"), a11y::menu_open(&resp)); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + paint_choice(ui.painter(), Rect::from_center_size(pos2(r.min.x + CELL / 2.0, cy), vec2(18.0, 18.0)), &Mark::Shape(shape), t.icon); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 7.0, cy), vec2(12.0, 12.0)), Icon::Chevron, t.icon_secondary); + crate::right::menu_popup(&resp).show(|ui| { + for b in BoardShape::ALL { + let art = |p: &egui::Painter, row: Rect, ink: Color32| { + paint_choice(p, Rect::from_center_size(pos2(row.max.x - 16.0, row.center().y), vec2(16.0, 16.0)), &Mark::Shape(b), ink) + }; + if crate::right::menu_item_with(ui, tr(b.label()), "", 24.0, art) { + app.run("board.setStyle", json!({"shape": b.id()})); + ui.close(); + } + } + }); + x = divider(ui, &t, x + CELL + 12.0 + GAP, cy); + } + if h.text { + for (label, size) in TEXT_SIZES { + let r = Rect::from_center_size(pos2(x + CELL / 2.0, cy), vec2(CELL, CELL)); + let resp = ui.interact(r, ui.id().with(("bb_size", label)), Sense::click()).on_hover_text(tr("Text size")); + a11y::button(&resp, &format!("{} {label}", tr("Text size"))); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, label, semibold(11.0), t.text); + if resp.clicked() { + app.run("board.setStyle", json!({"fontSize": size})); + } + x += CELL + 2.0; + } + x = divider(ui, &t, x, cy); + } + if let Some((routing, arrow)) = h.connector { + let cells = [ + (Icon::Line, tr("Straight"), routing == Routing::Straight, json!({"style": "straight"})), + (Icon::Connector, tr("Elbow"), routing == Routing::Elbow, json!({"style": "elbow"})), + (Icon::Arrow, tr("Arrow at the end"), arrow, json!({"endCap": if arrow { "none" } else { "triangleArrow" }})), + ]; + for (i, (icon, tip, on, params)) in cells.into_iter().enumerate() { + let r = Rect::from_center_size(pos2(x + CELL / 2.0, cy), vec2(CELL, CELL)); + if crate::toolbar::tool_cell(ui, &t, r, ("bb_line", i), icon, on, tip).clicked() { + app.run("board.setStyle", params); + } + x += CELL + 2.0; + } + } + }); +} + +/// A thin separator after `x`; where the next part starts. +fn divider(ui: &Ui, t: &Tokens, x: f32, cy: f32) -> f32 { + ui.painter().line_segment([pos2(x + 2.0, cy - 10.0), pos2(x + 2.0, cy + 10.0)], Stroke::new(1.0, t.border)); + x + 8.0 +} + +/// The name of the person who placed the stamp under the pointer, beside it. +pub fn stamp_author(app: &FigApp, painter: &egui::Painter, pointer: Option) { + let (Some(at), Ok(ds)) = (pointer, app.session.ds()) else { return }; + let Some(n) = ds.hover.and_then(|h| ds.doc.node(h)) else { return }; + let Some(BoardItem::Stamp { author, .. }) = &n.board else { return }; + if author.is_empty() || !matches!(n.kind, NodeKind::Frame) { + return; + } + let t = Tokens::get(painter.ctx()); + let galley = painter.layout_no_wrap(author.clone(), semibold(11.0), t.text); + let r = Rect::from_min_size(at + vec2(14.0, 14.0), galley.size() + vec2(12.0, 6.0)); + painter.rect_filled(r, CornerRadius::same(4), t.menu); + painter.galley(r.min + vec2(6.0, 3.0), galley, t.menu_text); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 46451ae0..1ad3d611 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -256,6 +256,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { } crate::dev_mode::draw(app, &painter); crate::handles::draw(app, &painter); + crate::board_bar::stamp_author(app, &painter, ctx.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p))); + crate::board_bar::show(app, &ctx); crate::selection_spacing::draw(app, &painter); crate::comments::draw(app, &painter); crate::live_text::draw(app, &painter); @@ -939,7 +941,8 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ } } // The focus view's bar names its frame. - let labelled = focus.is_none() && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })); + // Board objects (stickies, shapes with text, stamps) are frames without a name label. + let labelled = focus.is_none() && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) && !n.is_board_atom(); if labelled && let Some(q) = crate::frame_labels::draw(app, painter, t, &doc, *c, sel.contains(c), ds.hover == Some(*c)) { labels.push((*c, q.map(|p| app.to_page(p)))); } diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index b532d9a7..326a3b14 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -321,13 +321,14 @@ fn action_card(ui: &mut Ui, t: &Tokens, icon: Icon, title: &str, sub: &str) -> b fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { heading(ui, t, tr("Recents")); - let (mut new_file, mut open) = (false, false); - ui.horizontal(|ui| { - new_file = action_card(ui, t, Icon::Plus, tr("New design file"), tr("Start from a blank canvas")); + let (mut new_file, mut new_board, mut open) = (false, false, false); + ui.horizontal_wrapped(|ui| { + new_file = action_card(ui, t, Icon::PenNib, tr("New design file"), tr("Start from a blank canvas")); + new_board = action_card(ui, t, Icon::Board, tr("New board"), tr("Sticky notes, shapes and connectors")); open = action_card(ui, t, Icon::Page, tr("Open file"), tr("Open .figcraft, .svg or images")); }); - if new_file { - app.run("file.new", json!({})); + if new_file || new_board { + app.run(if new_board { "file.newBoard" } else { "file.new" }, json!({})); app.ui.home = false; } if open { @@ -376,8 +377,14 @@ fn open_files(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize { let mut switch = None; for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| hit(d)) { let detail = d.path.as_deref().unwrap_or_default(); - let row = - FileRow { mark: Some((Icon::Logo, ACCENT)), name: &d.doc.name, ink: t.text, detail, detail_size: 11.0, detail_ink: t.text_secondary }; + let row = FileRow { + mark: Some((icons::file_mark(d.doc.kind), ACCENT)), + name: &d.doc.name, + ink: t.text, + detail, + detail_size: 11.0, + detail_ink: t.text_secondary, + }; if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) { switch = Some(i); } diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index ca50d78f..2d39c6fe 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -252,6 +252,8 @@ pub struct Shown { pub name: String, /// `None` until the file has been looked at. pub stat: Option, + /// A design file or a board (design until its picture has been read). + pub kind: figcraft_doc::FileKind, } impl Shown { @@ -259,7 +261,7 @@ impl Shown { // Shown without its extension, like a document's name; the full path is the tooltip. let p = Path::new(path); let name = p.file_stem().or_else(|| p.file_name()).map_or_else(|| path.to_string(), |f| f.to_string_lossy().into_owned()); - Shown { path: path.to_string(), name, stat } + Shown { path: path.to_string(), name, stat, kind: figcraft_doc::FileKind::Design } } fn date(&self, key: SortKey) -> Option { @@ -311,6 +313,7 @@ struct Ready { modified: Option, page: Option, texture: Option, + kind: figcraft_doc::FileKind, /// When it came in (for its fade). arrived: f64, } @@ -479,7 +482,7 @@ impl Recents { let page = preview.page.map(|[r, g, b, a]| crate::theme::c32(figcraft_color::Color { r, g, b, a })); // A changed file's new picture shows at once; a first one fades in. let arrived = if k.shown.is_some() { f64::NEG_INFINITY } else { time }; - k.shown = Some(Ready { modified, page, texture, arrived }); + k.shown = Some(Ready { modified, page, texture, kind: preview.kind, arrived }); k.wanted = None; } } @@ -519,7 +522,13 @@ impl Recents { /// The files as listed, with what is known about them. fn shown(&self, paths: &[String]) -> Vec { - paths.iter().map(|p| Shown::new(p, self.files.get(p).and_then(|k| k.stat))).collect() + paths + .iter() + .map(|p| { + let k = self.files.get(p); + Shown { kind: k.and_then(|k| k.shown.as_ref()).map_or_else(Default::default, |r| r.kind), ..Shown::new(p, k.and_then(|k| k.stat)) } + }) + .collect() } /// For `ui.inspect`: each file listed, whether it is there and whether its picture is in. @@ -751,13 +760,13 @@ fn arrows(ui: &Ui, recents: &mut Recents, files: &[Shown], across: usize, down: } } -/// The square file-type mark before a name. -fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, path: &str) { +/// The square file-type mark before a name (a design file's pen nib, a board's note). +fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, file: &Shown) { let r = Rect::from_center_size(center, vec2(16.0, 16.0)); - match FileKind::of(Path::new(path)) { + match FileKind::of(Path::new(&file.path)) { FileKind::FigCraft | FileKind::Fig => { ui.painter().rect_filled(r, CornerRadius::same(4), t.accent); - icons::paint(ui.painter(), r.shrink(3.0), Icon::Logo, t.accent_on); + icons::paint(ui.painter(), r.shrink(3.0), icons::file_mark(file.kind), t.accent_on); } kind => { ui.painter().rect_filled(r, CornerRadius::same(4), t.field); @@ -867,7 +876,7 @@ fn card( } p.line_segment([pos2(rect.min.x, area.max.y + 0.5), pos2(rect.max.x, area.max.y + 0.5)], Stroke::new(1.0, t.border)); let y = area.max.y + 1.0 + FOOTER_H / 2.0; - file_icon(ui, t, pos2(rect.min.x + 24.0, y), &file.path); + file_icon(ui, t, pos2(rect.min.x + 24.0, y), file); let (x, w) = (rect.min.x + 44.0, rect.width() - 56.0); let name = widgets::elide_font(ui, &file.name, w, semibold(12.5)); p.text(pos2(x, y - 8.0), Align2::LEFT_CENTER, name, semibold(12.5), t.text); @@ -935,6 +944,8 @@ fn list(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6 if !ui.is_rect_visible(r) { continue; } + // The row's mark says what kind of file it is: read it like a card's picture. + recents.picture(&file.path, true, ui.input(|i| i.time)); let selected = recents.selected.as_deref() == Some(file.path.as_str()); let p = ui.painter(); if selected { @@ -945,7 +956,7 @@ fn list(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6 if resp.has_focus() && !selected { p.rect_stroke(r, CornerRadius::same(5), Stroke::new(2.0, t.focus), StrokeKind::Inside); } - file_icon(ui, t, pos2(r.min.x + 20.0, r.center().y), &file.path); + file_icon(ui, t, pos2(r.min.x + 20.0, r.center().y), file); let name_w = date_x(1) - 52.0; p.text( pos2(r.min.x + 40.0, r.center().y), diff --git a/crates/ui-egui/src/home_recents_tests.rs b/crates/ui-egui/src/home_recents_tests.rs index e49866ab..d01e5222 100644 --- a/crates/ui-egui/src/home_recents_tests.rs +++ b/crates/ui-egui/src/home_recents_tests.rs @@ -74,7 +74,7 @@ fn columns_follow_the_width() { fn shown(path: &str, modified: Option, created: Option) -> Shown { let stat = Some(Stat { exists: modified.is_some(), modified, created }); - Shown { path: path.into(), name: Path::new(path).file_stem().unwrap().to_string_lossy().into(), stat } + Shown { path: path.into(), name: Path::new(path).file_stem().unwrap().to_string_lossy().into(), stat, kind: Default::default() } } #[test] diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 5eb7d209..c7b10388 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -193,6 +193,17 @@ icons! { LiveShared, /// A file of yours on a server that only you get into: a point inside a closed ring. LivePrivate, + /// A design file: a pen nib leaning to the left, its grip filled. + PenNib, + /// A board file (and the sticky note tool): a note with its bottom corner folded up. + Board, + /// Board tools: a chisel-tipped marker; a broad highlighter over its band; a connector + /// between two boxes; shapes (a square behind a circle); a stamp (a badge with a check). + Marker, + Highlighter, + Connector, + Shapes, + Stamp, } /// Points along a circular arc from angle `a0` to `a1` (radians, y down: increasing is clockwise). @@ -1118,6 +1129,49 @@ fn draw(p: &P, icon: Icon) { p.line(8.0, 6.0, 8.0, 9.5); p.dot(8.0, 11.5, 0.9); } + PenNib => { + // Drawn pointing down, then leant 35° to the left about the middle. + let (sin, cos) = (0.61f32, 0.79f32); + let at = |x: f32, y: f32| (8.0 + x * cos - y * sin, 8.0 + x * sin + y * cos); + let nib: Vec<(f32, f32)> = [(0.0, 6.5), (3.4, 1.0), (2.4, -2.5), (-2.4, -2.5), (-3.4, 1.0)].iter().map(|&(x, y)| at(x, y)).collect(); + p.path(&nib, true); + let (a, b) = (at(0.0, 6.5), at(0.0, 2.0)); + p.path(&[a, b], false); + let (dx, dy) = at(0.0, 0.6); + p.dot(dx, dy, 1.0); + let grip: Vec<(f32, f32)> = [(-2.4, -3.4), (2.4, -3.4), (2.4, -5.6), (-2.4, -5.6)].iter().map(|&(x, y)| at(x, y)).collect(); + p.fill(&grip); + } + Board => { + p.poly(&[(2.5, 2.5), (13.5, 2.5), (13.5, 9.5), (9.5, 13.5), (2.5, 13.5)], true); + p.fill(&[(9.5, 13.5), (9.5, 9.5), (13.5, 9.5)]); + p.line(5.0, 6.5, 11.0, 6.5); + p.line(5.0, 9.5, 8.0, 9.5); + } + Marker => { + p.poly(&[(3.0, 13.0), (3.0, 10.5), (10.0, 3.5), (12.5, 6.0), (5.5, 13.0)], true); + p.line(8.5, 5.0, 11.0, 7.5); + p.fill(&[(2.0, 14.0), (2.0, 11.5), (4.5, 14.0)]); + } + Highlighter => { + p.poly(&[(4.0, 10.0), (10.5, 3.5), (13.5, 6.5), (7.0, 13.0)], true); + p.poly(&[(4.0, 10.0), (2.5, 12.5), (4.5, 12.5), (7.0, 13.0)], false); + p.fill_rect(1.0, 14.0, 15.0, 15.5, 0.0); + } + Connector => { + p.rect(1.5, 1.5, 6.5, 6.5, 1.0); + p.rect(9.5, 9.5, 14.5, 14.5, 1.0); + p.poly(&[(6.5, 4.0), (12.0, 4.0), (12.0, 9.0)], false); + p.head((12.0, 9.0), FRAC_PI_2, 2.4); + } + Shapes => { + p.rect(1.5, 5.5, 9.5, 13.5, 1.0); + p.circle(10.5, 5.5, 4.0); + } + Stamp => { + p.circle(8.0, 8.0, 6.0); + p.poly(&[(5.0, 8.3), (7.2, 10.5), (11.0, 6.0)], false); + } Logo => { // FigCraft's own mark: the curled tail of the chameleon on the app icon. One bold // stroke: a shaft along the bottom, then three half turns, each tighter than the last. @@ -1162,6 +1216,46 @@ pub fn button(ui: &mut egui::Ui, icon: Icon, size: f32, active: bool, tooltip: & resp } +/// The mark of a file of `kind` on tabs and file rows: a pen nib for design files, a sticky note +/// for boards. +pub fn file_mark(kind: figcraft_doc::FileKind) -> Icon { + match kind { + figcraft_doc::FileKind::Design => Icon::PenNib, + figcraft_doc::FileKind::Board => Icon::Board, + } +} + +/// SVG path data outlines on a `size`-unit box, painted into `rect`: each part filled, or +/// stroked `width` box units wide. Board shapes and stamp marks are drawn this way in the UI from +/// the same outlines the canvas has. +pub fn paint_outlines(painter: &Painter, rect: Rect, size: f64, parts: &[(&figcraft_geom::BezPath, bool)], color: Color32, width: f32) { + let s = rect.width().min(rect.height()) / size as f32; + let px = 1.0 / painter.ctx().pixels_per_point(); + let o = rect.center() - Vec2::splat(size as f32 * s / 2.0); + let p = P { painter, o, s, px, stroke: Stroke::new((width * s).max(px), color), color }; + for (path, filled) in parts { + let mut sub: Vec<(f32, f32)> = Vec::new(); + let flush = |sub: &mut Vec<(f32, f32)>, closed: bool| { + if sub.len() >= 2 { + if *filled { p.fill(sub) } else { p.path(sub, closed) } + } + sub.clear(); + }; + let mut closed = false; + figcraft_geom::kurbo::flatten(path.iter(), 0.25, |el| match el { + figcraft_geom::kurbo::PathEl::MoveTo(q) => { + flush(&mut sub, closed); + closed = false; + sub.push((q.x as f32, q.y as f32)); + } + figcraft_geom::kurbo::PathEl::LineTo(q) => sub.push((q.x as f32, q.y as f32)), + figcraft_geom::kurbo::PathEl::ClosePath => closed = true, + _ => {} + }); + flush(&mut sub, closed); + } +} + /// Every icon at the sizes the UI uses, labelled: for reviewing the set (`ui.set {iconGallery}`). pub fn gallery(ctx: &egui::Context) { let t = crate::theme::Tokens::get(ctx); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c93333b3..596ddc95 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ pub mod a11y; pub mod assets_panel; pub mod bidi; +pub mod board_bar; pub mod branch_ui; pub mod canvas; pub mod cjk; @@ -577,6 +578,8 @@ pub struct UiState { pub dark: bool, pub follow_system: bool, pub tool_flyout: Option<&'static str>, + /// On a board: the Design panel is shown (boards hide it until asked for). + pub board_panel: bool, pub last_error: Option, /// Files dropped on the home screen or the canvas, opened once their card has been on screen /// ([`pending_open::pending_opens`]), and the frames it has been drawn. @@ -716,6 +719,7 @@ impl Default for UiState { dark: false, follow_system: false, tool_flyout: None, + board_panel: false, last_error: None, pending_opens: Vec::new(), pending_open_frames: 0, @@ -981,7 +985,15 @@ impl FigApp { "dialog.missingFonts" => missing_fonts::request(self), "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, "palette.actions" => menus::toggle_palette(&mut self.ui), + "panel.design" if self.session.doc().is_ok_and(figcraft_engine::board::is_board) => self.ui.board_panel = !self.ui.board_panel, "panel.design" => self.ui.right_tab = RightTab::Design, + "board.editText" => { + if let Some(id) = r.data.get("id").and_then(figcraft_engine::params::id_of) + && let Err(e) = figcraft_engine::board::edit_text(&mut self.session, id) + { + self.toast(e.to_string()); + } + } "panel.prototype" => self.ui.right_tab = RightTab::Prototype, "panel.designPrototype" => { self.ui.right_tab = if self.ui.right_tab == RightTab::Prototype { RightTab::Design } else { RightTab::Prototype }; @@ -1602,6 +1614,12 @@ impl FigApp { return; } let hidden = self.session.ds().map(|d| d.view.ui_hidden).unwrap_or(false); + // Boards have no prototypes or Dev Mode, and show the Design panel only when asked. + let board = self.session.doc().is_ok_and(figcraft_engine::board::is_board); + if board { + self.ui.right_tab = RightTab::Design; + } + let right_open = !hidden && (!board || self.ui.board_panel); if !hidden { egui::Panel::left("left_rail") .exact_size(56.0) @@ -1615,6 +1633,7 @@ impl FigApp { // The panels are as wide as asked, less in a window too small for them and the canvas. let left_open = !hidden && !self.ui.left_collapsed; let (left_w, right_w) = fit_panels(ui.available_width(), left_open.then_some(self.ui.left_width), self.ui.right_width); + let right_w = if right_open { right_w } else { 0.0 }; let rows = ui.available_rect_before_wrap().y_range(); let mut edges: Vec<(&str, f32, f32, f32)> = Vec::new(); if left_open { @@ -1633,7 +1652,7 @@ impl FigApp { layers::left_panel(self, ui) }); } - if !hidden { + if right_open { edges.push(("right", ui.available_rect_before_wrap().max.x - right_w, right_w, -1.0)); egui::Panel::right("right_panel") .resizable(false) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9659c6b3..f01b9ff6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -133,6 +133,10 @@ pub fn invoke(app: &mut FigApp, id: &str) { app.run(id, json!({})); } "file.open" => app.open_dialog(), + // From the keyboard or a menu, typing goes on in the new sticky. + "board.nextSticky" => { + app.run(id, json!({"edit": true})); + } "plugin.import" => crate::plugins_ui::import_dialog(app), "file.import" => app.import_dialog(false), "file.placeImage" => app.import_dialog(true), @@ -1032,7 +1036,7 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); // Same centre and width as the toolbar pill (it centres on the canvas, not the window), // floating 8 px above it. Fixed size: switching tabs never resizes the panel. - let bar_w = crate::toolbar::main_width(); + let bar_w = crate::toolbar::bar_width(app); let bar = crate::toolbar::pill_rect(app.canvas.rect, bar_w); let area = Rect::from_min_max(pos2(bar.min.x, bar.min.y - 8.0 - (PALETTE_H + 16.0)), pos2(bar.max.x, bar.min.y - 8.0)); let mut run: Option = None; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index c86cfdf1..f8bede69 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -413,6 +413,15 @@ fn enter_text(app: &mut FigApp) -> bool { return false; } let Some(id) = sel.first().copied() else { return false }; + // A board object (a sticky, a shape with text, a label): type in its text. + match figcraft_engine::board::edit_text(&mut app.session, id) { + Ok(true) => return true, + Ok(false) => {} + Err(e) => { + app.toast(e.to_string()); + return true; + } + } let kind = app.session.doc().ok().and_then(|d| d.node(id)).map(|n| (matches!(n.kind, figcraft_doc::NodeKind::Text(_)), n.kind.is_shape())); match kind { Some((true, _)) => { diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index c10edcfd..3a4e9ecb 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -212,7 +212,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { icons::paint( &painter, Rect::from_center_size(pos2(paint_r.min.x + 18.0, paint_r.center().y), vec2(14.0, 14.0)), - Icon::Logo, + 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. diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index d92cdf06..4b4052c1 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -28,23 +28,35 @@ struct Group { /// The flyout button's name for screen readers. name: &'static str, tools: &'static [(ToolId, Icon)], + /// What a one-tool group's flyout offers instead of tools (boards: colours, shapes, stamps). + options: Options, +} + +/// The choices a board tool's flyout shows. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Options { + None, + Colors, + Shapes, + Stamps, +} + +impl Group { + const fn tools(key: &'static str, name: &'static str, tools: &'static [(ToolId, Icon)]) -> Group { + Group { key, name, tools, options: Options::None } + } + fn has_flyout(&self) -> bool { + self.tools.len() > 1 || self.options != Options::None + } } const GROUPS: &[Group] = &[ - Group { - key: "move", - name: tr_noop("Move tools"), - tools: &[(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand), (ToolId::Scale, Icon::Scale)], - }, - Group { - key: "frame", - name: tr_noop("Frame tools"), - tools: &[(ToolId::Frame, Icon::Frame), (ToolId::Section, Icon::Section), (ToolId::Slice, Icon::Slice)], - }, - Group { - key: "shape", - name: tr_noop("Shape tools"), - tools: &[ + Group::tools("move", tr_noop("Move tools"), &[(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand), (ToolId::Scale, Icon::Scale)]), + Group::tools("frame", tr_noop("Frame tools"), &[(ToolId::Frame, Icon::Frame), (ToolId::Section, Icon::Section), (ToolId::Slice, Icon::Slice)]), + Group::tools( + "shape", + tr_noop("Shape tools"), + &[ (ToolId::Rectangle, Icon::Rectangle), (ToolId::Line, Icon::Line), (ToolId::Arrow, Icon::Arrow), @@ -52,14 +64,31 @@ const GROUPS: &[Group] = &[ (ToolId::Polygon, Icon::Polygon), (ToolId::Star, Icon::Star), ], - }, - Group { - key: "pen", - name: tr_noop("Drawing tools"), - tools: &[(ToolId::Pen, Icon::Pen), (ToolId::Pencil, Icon::Pencil), (ToolId::ShapeBuilder, Icon::ShapeBuilder)], - }, - Group { key: "text", name: tr_noop("Text"), tools: &[(ToolId::Text, Icon::Text)] }, - Group { key: "comment", name: tr_noop("Comment"), tools: &[(ToolId::Comment, Icon::Comment)] }, + ), + Group::tools( + "pen", + tr_noop("Drawing tools"), + &[(ToolId::Pen, Icon::Pen), (ToolId::Pencil, Icon::Pencil), (ToolId::ShapeBuilder, Icon::ShapeBuilder)], + ), + Group::tools("text", tr_noop("Text"), &[(ToolId::Text, Icon::Text)]), + Group::tools("comment", tr_noop("Comment"), &[(ToolId::Comment, Icon::Comment)]), +]; + +/// A board's toolbar: moving around, ink, stickies, shapes, connectors, text, sections, stamps. +const BOARD_GROUPS: &[Group] = &[ + Group::tools("bmove", tr_noop("Move tools"), &[(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand)]), + Group::tools( + "ink", + tr_noop("Drawing tools"), + &[(ToolId::Marker, Icon::Marker), (ToolId::Highlighter, Icon::Highlighter), (ToolId::Eraser, Icon::Eraser)], + ), + Group { key: "sticky", name: tr_noop("Sticky note colors"), tools: &[(ToolId::Sticky, Icon::Board)], options: Options::Colors }, + Group { key: "bshape", name: tr_noop("Shapes"), tools: &[(ToolId::BoardShape, Icon::Shapes)], options: Options::Shapes }, + Group::tools("connector", tr_noop("Connector"), &[(ToolId::Connector, Icon::Connector)]), + Group::tools("text", tr_noop("Text"), &[(ToolId::Text, Icon::Text)]), + Group::tools("section", tr_noop("Section"), &[(ToolId::Section, Icon::Section)]), + Group { key: "stamp", name: tr_noop("Stamps"), tools: &[(ToolId::Stamp, Icon::Stamp)], options: Options::Stamps }, + Group::tools("comment", tr_noop("Comment"), &[(ToolId::Comment, Icon::Comment)]), ]; fn remembered(ui: &Ui, key: &str, default: ToolId) -> ToolId { @@ -85,25 +114,37 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { builder_toolbar(app, ui); return; } - main_toolbar(app, ui, canvas, &t); + let board = app.session.doc().is_ok_and(figcraft_engine::board::is_board); + main_toolbar(app, ui, canvas, &t, board); } -/// The main toolbar pill's width: the Actions palette matches it so the two read as one bar. -pub(crate) fn main_width() -> f32 { +/// The main toolbar pill's width for the active file: the Actions palette matches it so the two +/// read as one bar. +pub(crate) fn bar_width(app: &FigApp) -> f32 { + let board = app.session.doc().is_ok_and(figcraft_engine::board::is_board); + main_width(if board { BOARD_GROUPS } else { GROUPS }, board) +} + +/// The main toolbar pill's width: the tool groups with their flyout carets, Resources and +/// Actions, and the mode switcher (boards: the Design panel's button instead). +fn main_width(groups: &[Group], board: bool) -> f32 { let (cell, gap) = (32.0, 4.0); - GROUPS.len() as f32 * (cell + gap) + gap * 10.0 + 2.0 * (cell + gap) + 16.0 + 4.0 * 34.0 + 24.0 + let carets = groups.iter().filter(|g| g.has_flyout()).count() as f32 * 10.0; + let end = if board { cell + 16.0 } else { 4.0 * 34.0 + 24.0 }; + groups.len() as f32 * (cell + gap) + carets + 2.0 * (cell + gap) + 16.0 + end } /// The main toolbar pill: the tool groups with their flyouts, Resources and Actions, then the -/// Draw · Design · Prototype · Inspect mode switcher. -fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { +/// Draw · Design · Prototype · Inspect mode switcher (on a board, the Design panel's button). +fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens, board: bool) { let (cell, gap) = (32.0, 4.0); - let width = main_width(); + let groups = if board { BOARD_GROUPS } else { GROUPS }; + let width = main_width(groups, board); let rect = pill(ui, &mut app.ui.nav, canvas, width, t); let current = app.session.tool.tool; let mut x = rect.min.x + 8.0; let cy = rect.center().y; - for g in GROUPS { + for g in groups { let in_group = g.tools.iter().any(|(tid, _)| *tid == current); let shown = if in_group { current } else { remembered(ui, g.key, g.tools.first().map(|x| x.0).unwrap_or(ToolId::Move)) }; let icon = g.tools.iter().find(|(tid, _)| *tid == shown).map(|(_, i)| *i).unwrap_or(Icon::Move); @@ -124,7 +165,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { } x += cell; // Flyout chevron. - if g.tools.len() > 1 { + if g.has_flyout() { let cr = Rect::from_center_size(pos2(x + 5.0, cy), vec2(10.0, cell)); let cresp = ui.interact(cr, ui.id().with(("flyout", g.key)), Sense::click()); let open = app.ui.tool_flyout == Some(g.key); @@ -143,7 +184,11 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { } x += gap; if app.ui.tool_flyout == Some(g.key) { - flyout(app, ui, g, pos2(r.min.x, rect.min.y - 8.0)); + if g.options == Options::None { + flyout(app, ui, g, pos2(r.min.x, rect.min.y - 8.0)); + } else { + options_flyout(app, ui, g, pos2(r.min.x, rect.min.y - 8.0)); + } } } // Resources (components, plugins) opens the Assets panel; Actions palette. @@ -175,6 +220,16 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { } divider(ui, t, x, cy); x += 8.0; + if board { + // Boards have no prototypes or Dev Mode; the Design panel is hidden until asked for. + let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell)); + let open = app.ui.board_panel; + if tool_cell(ui, t, r, "board_panel", Icon::Panels, open, crate::shortcuts::tip(tr("Design panel"), "view.design")).clicked() { + app.run("view.design", json!({})); + } + help_button(app, ui, canvas, t); + return; + } // Mode switcher: Draw · Design · Prototype · Inspect (code). let modes = [(Icon::Pencil, tr("Draw")), (Icon::Frame, tr("Design")), (Icon::Prototype, tr("Prototype")), (Icon::Code, tr("Inspect"))]; let active = match app.ui.right_tab { @@ -213,7 +268,11 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { } } } - // Floating help button at the bottom right of the canvas. + help_button(app, ui, canvas, t); +} + +/// The floating help button at the bottom right of the canvas. +fn help_button(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { let hr = Rect::from_center_size(pos2(canvas.max.x - 32.0, canvas.max.y - 32.0), vec2(32.0, 32.0)); let hresp = ui.interact(hr, ui.id().with("help"), Sense::click()).tip(tr("Help and resources")); ui.painter().circle_filled(hr.center() + vec2(0.0, 1.0), 17.0, Color32::from_black_alpha(20)); @@ -380,7 +439,7 @@ fn pill(ui: &Ui, nav: &mut Nav, canvas: Rect, width: f32, t: &Tokens) -> Rect { } /// A square tool button: accent when active, tinted on hover; `tip` is its tooltip and name. -fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSalt, icon: Icon, active: bool, tip: &str) -> egui::Response { +pub(crate) fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSalt, icon: Icon, active: bool, tip: &str) -> egui::Response { let resp = ui.interact(r, ui.id().with(id), Sense::click()).on_hover_ui(|ui| { ui.label(tip); }); @@ -414,6 +473,41 @@ fn done_button(ui: &mut Ui, t: &Tokens, x: f32, cy: f32, tip: &str) -> bool { resp.clicked() } +/// A board tool's flyout: the colours, shapes or stamps it places, in a row. A pick chooses the +/// tool with it. +fn options_flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) { + let t = Tokens::get(ui.ctx()); + let Some(&(tool, _)) = g.tools.first() else { return }; + let items = crate::board_bar::choices(g.options, &app.session.tool.board); + let (cell, pad) = (32.0, 8.0); + let size = vec2(items.len() as f32 * (cell + 4.0) - 4.0 + 2.0 * pad, cell + 2.0 * pad); + let mut chosen = None; + egui::Area::new(flyout_id(g.key)).fixed_pos(pos2(bottom_left.x, bottom_left.y - size.y)).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + ui.painter().rect_filled(rect, CornerRadius::same(13), t.menu); + for (i, c) in items.iter().enumerate() { + let r = Rect::from_min_size(pos2(rect.min.x + pad + i as f32 * (cell + 4.0), rect.min.y + pad), vec2(cell, cell)); + let resp = ui.interact(r, ui.id().with(("opt", g.key, i)), Sense::click()).on_hover_text(tr(c.label)); + a11y::menu_item(&resp, tr(c.label), "", Some(c.on)); + a11y::own_focus(&resp); + if c.on || resp.hovered() || resp.has_focus() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); + } + crate::board_bar::paint_choice(ui.painter(), r.shrink(5.0), &c.mark, t.menu_text); + if resp.clicked() { + chosen = Some(c.params.clone()); + } + } + }); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.tool_flyout = None; + } + if let Some(params) = chosen { + app.run(&format!("tool.{}", tool.id()), params); + app.ui.tool_flyout = None; + } +} + fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) { let t = Tokens::get(ui.ctx()); let item_h = 32.0;