mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
M12: Boards: board toolbar, contextual bar, per-kind file icons, home and recents
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -109,6 +109,8 @@ pub fn file_thumbnail(path: &Path, fit: u32) -> Option<Thumbnail> {
|
||||
pub struct Preview {
|
||||
pub page: Option<[f32; 4]>,
|
||||
pub picture: Option<Thumbnail>,
|
||||
/// 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<R: Read + Seek>(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<u64>,
|
||||
#[serde(default)]
|
||||
nodes: std::collections::BTreeMap<String, Node>,
|
||||
#[serde(default)]
|
||||
kind: figcraft_doc::FileKind,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Node {
|
||||
#[serde(default)]
|
||||
background: Option<serde_json::Value>,
|
||||
}
|
||||
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::<Head>(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`).
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<Choice> {
|
||||
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<BoardShape>,
|
||||
text: bool,
|
||||
connector: Option<(Routing, bool)>,
|
||||
}
|
||||
|
||||
fn has(app: &FigApp) -> Option<Has> {
|
||||
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<Pos2>) {
|
||||
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);
|
||||
}
|
||||
@@ -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))));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -252,6 +252,8 @@ pub struct Shown {
|
||||
pub name: String,
|
||||
/// `None` until the file has been looked at.
|
||||
pub stat: Option<Stat>,
|
||||
/// 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<u64> {
|
||||
@@ -311,6 +313,7 @@ struct Ready {
|
||||
modified: Option<u64>,
|
||||
page: Option<Color32>,
|
||||
texture: Option<egui::TextureHandle>,
|
||||
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<Shown> {
|
||||
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),
|
||||
|
||||
@@ -74,7 +74,7 @@ fn columns_follow_the_width() {
|
||||
|
||||
fn shown(path: &str, modified: Option<u64>, created: Option<u64>) -> 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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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)
|
||||
|
||||
@@ -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<String> = None;
|
||||
|
||||
@@ -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, _)) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
+128
-34
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user