Merge main into #1729

This commit is contained in:
bflatastic
2026-11-07 12:47:51 +07:00
10 changed files with 247 additions and 6 deletions
+4
View File
@@ -360,6 +360,10 @@ pub static GESTURES: &[(Group, &str, &str, &str)] = &[
(Zoom, "Zoom around the pointer", "⌘ scroll", "Ctrl+scroll"),
(Selection, "Deep select", "⌘ click", "Ctrl+click"),
(Selection, "Measure to the selection", "Hold ⌥", "Hold Alt"),
(Selection, "Selection box: switch to pick select", "⇥ or ⌥ Space again", "Tab or Ctrl+Space again"),
(Selection, "Pick select: next or previous layer", "⇥ / ⇧⇥", "Tab / Shift+Tab"),
(Selection, "Pick select: into a layer, out to its parent", "↵ / ⇧↵", "Enter / Shift+Enter"),
(Selection, "Pick select: select the picked layer", "Space", "Space"),
(Edit, "Duplicate by dragging", "⌥ drag", "Alt+drag"),
(Transform, "Nudge (Shift: big nudge)", "← ↑ → ↓", "← ↑ → ↓"),
(Transform, "Resize by the nudge amount", "⌘ arrows", "Ctrl+arrows"),
+3 -1
View File
@@ -235,6 +235,8 @@ static ROWS: &[Row] = &[
"then the arrows move it, Cmd/Ctrl+arrows draw it out (Shift: farther), Enter selects, Escape closes; F8 kept")
.reserved("Ctrl+Space switches the input method with some Chinese, Japanese and Korean input methods on Windows and Linux"),
doc(Selection, "Select nested layers", &["Cmd"], &[], Gesture("Deep select"), "held while clicking or dragging"),
doc(Selection, "Pick select (while the keyboard box selection shows)", &["Tab"], &[], Gesture("Selection box: switch to pick select"),
"or the chord again; then Tab and Shift+Tab move the pick cursor between layers, Enter and Shift+Enter into and out of a layer, Space selects, Escape ends"),
pin(Selection, "object.rename", &["F2"], &[]),
pin(Selection, "select.area", &["F8"], &[]),
// Edit
@@ -414,7 +416,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), (135, 134, 1, 0, 2, 57));
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (136, 135, 1, 0, 2, 57));
}
#[test]
+11
View File
@@ -2009,3 +2009,14 @@ name = "English"
"⌘ click" = "⌘ click"
"⌘ scroll" = "⌘ scroll"
"⌥ drag" = "⌥ drag"
"Selection box: switch to pick select" = "Selection box: switch to pick select"
"Pick select: next or previous layer" = "Pick select: next or previous layer"
"Pick select: into a layer, out to its parent" = "Pick select: into a layer, out to its parent"
"Pick select: select the picked layer" = "Pick select: select the picked layer"
"⇥ or ⌥ Space again" = "⇥ or ⌥ Space again"
"Tab or Ctrl+Space again" = "Tab or Ctrl+Space again"
"↵ / ⇧↵" = "↵ / ⇧↵"
"Enter / Shift+Enter" = "Enter / Shift+Enter"
"Space" = "Space"
"Nothing to pick: the page is empty" = "Nothing to pick: the page is empty"
"Pick select: Tab and Shift+Tab move between layers, Enter goes into one, Shift+Enter out, Space selects, Escape cancels" = "Pick select: Tab and Shift+Tab move between layers, Enter goes into one, Shift+Enter out, Space selects, Escape cancels"
+11
View File
@@ -148,6 +148,14 @@ fn import_dropped(app: &mut FigApp, ctx: &egui::Context, hover_pos: Option<egui:
}
}
/// A press ends the keyboard selection box and pick select.
fn end_keyboard_box(app: &mut FigApp, ctx: &egui::Context) {
if ctx.input(|i| i.pointer.any_pressed()) {
app.ui.key_box = None;
app.ui.pick = None;
}
}
/// One frame of a pan (the middle or a side button held, or Space + drag): the view follows the
/// pointer from where the pan began, and the pan ends once no button that pans is held.
fn pan_view(app: &mut FigApp, ctx: &egui::Context, pos: Pos2, mods: &egui::Modifiers, middle_down: bool) {
@@ -180,6 +188,8 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo
if crate::proto_canvas::input(app, ui, resp) {
// A prototype gesture is a press on the canvas too: the Pages list gives up the keyboard.
app.ui.pages_focus = None;
// And it ends the keyboard selection box and pick select, as any canvas press does.
end_keyboard_box(app, ui.ctx());
return true;
}
let ctx = ui.ctx().clone();
@@ -225,6 +235,7 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo
app.ui.layers_focus = false;
app.ui.pages_focus = None;
app.ui.key_box = None;
app.ui.pick = None;
}
// Zoom (pinch, Cmd/Ctrl+scroll; a plain mouse wheel with Scroll wheel zoom) about the pointer;
// other scrolls pan.
+8
View File
@@ -55,6 +55,7 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
{
draw_outline(app, painter, &doc, h, stroke(outline_colour(&doc, h, accent(painter)), 1.5));
}
pick_outline(app, painter, &doc);
// Inside a boolean group (an operand selected or in vector edit mode): every operand's outline,
// so the shapes making the live result stay visible while one of them changes.
let editing = ds.vector_edit.as_ref().map(|ve| ve.id);
@@ -774,3 +775,10 @@ fn vector_overlay(
pub(super) fn accent(p: &egui::Painter) -> Color32 {
crate::theme::canvas_accent(p.ctx())
}
/// Pick select: the pink outline on the layer the keys walked to (#1836).
fn pick_outline(app: &FigApp, painter: &egui::Painter, doc: &Document) {
if let Some(p) = app.picked().filter(|p| doc.node(*p).is_some()) {
draw_outline(app, painter, doc, p, stroke(crate::handles::PINK, 2.0));
}
}
+7
View File
@@ -1008,6 +1008,13 @@ fn ui_key(app: &mut FigApp, ctx: &Context, p: &Value) -> Result<Value, String> {
figcraft_engine::textedit::key(&mut app.session, key, m.shift, word, line).map_err(|e| e.to_string())?;
return Ok(json!({"ok": true}));
}
// The keyboard selection box and pick select take their keys first, as on the keyboard.
if let Some(k) = normalize(key).and_then(|(name, _)| egui::Key::from_name(name)) {
let mods = egui::Modifiers { alt: m.alt, ctrl: m.ctrl, shift: m.shift, mac_cmd: false, command: m.cmd || m.ctrl };
if crate::shortcuts::key_pick(app, k, mods) || crate::shortcuts::key_box(app, k, mods) {
return Ok(json!({"ok": true}));
}
}
// Vector edit mode and shape builder keys (tools, nudging points, delete, join, Enter).
if app.session.tool_key(key, m.shift, m.alt, m.cmd).map_err(|e| e.to_string())? {
return Ok(json!({"ok": true}));
+105
View File
@@ -222,3 +222,108 @@ impl crate::FigApp {
self.session.doc().unwrap().node(id).unwrap().transform.translation().x
}
}
/// Pick select (#1836): Tab turns the selection box into a pink outline on the selected layer (or
/// the page's top layer); Tab walks siblings, Enter and Shift+Enter go down and up the tree, Space
/// selects, and Esc or a click on the canvas leaves it.
#[test]
fn tab_switches_the_selection_box_to_pick_select() {
let (mut u, a, b) = setup();
// A frame with a child, on top of A and B in the layers list.
let f = NodeId(u.app.run("frame.create", json!({"x": 200, "y": 0, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap());
let c = NodeId(u.app.run("shape.rectangle", json!({"x": 210, "y": 10, "width": 20, "height": 20, "parent": f})).unwrap()["id"].as_u64().unwrap());
u.app.run("select.set", json!({"ids": [a]}));
press(&mut u, Key::F8, Modifiers::NONE);
press(&mut u, Key::Tab, Modifiers::NONE);
assert!(u.app.ui.key_box.is_none(), "the box becomes the pick cursor");
assert_eq!(u.app.picked(), Some(a), "on the selected layer");
assert_eq!(u.app.session.selection(), vec![a], "nothing selected yet");
// Tab walks the siblings as the Layers panel lists them (the frame on top, then B, then A),
// wrapping; Shift+Tab goes the other way.
press(&mut u, Key::Tab, Modifiers::NONE);
assert_eq!(u.app.picked(), Some(f), "below A comes the top of the list");
press(&mut u, Key::Tab, Modifiers::SHIFT);
assert_eq!(u.app.picked(), Some(a));
press(&mut u, Key::Tab, Modifiers::SHIFT);
assert_eq!(u.app.picked(), Some(b));
// Enter goes into a frame's top child, Shift+Enter back out; the page is never picked.
press(&mut u, Key::Tab, Modifiers::SHIFT);
assert_eq!(u.app.picked(), Some(f));
press(&mut u, Key::Enter, Modifiers::NONE);
assert_eq!(u.app.picked(), Some(c));
press(&mut u, Key::Enter, Modifiers::SHIFT);
press(&mut u, Key::Enter, Modifiers::SHIFT);
assert_eq!(u.app.picked(), Some(f));
assert_eq!(u.app.session.selection(), vec![a], "walking selects nothing");
// Space selects the picked layer and ends pick select.
press(&mut u, Key::Space, Modifiers::NONE);
assert_eq!(u.app.session.selection(), vec![f]);
assert!(u.app.picked().is_none());
// The chord again while the box shows switches too; Escape ends it, the selection kept.
press(&mut u, Key::F8, Modifiers::NONE);
press(&mut u, Key::F8, Modifiers::NONE);
assert!(u.app.ui.key_box.is_none());
assert_eq!(u.app.picked(), Some(f));
press(&mut u, Key::Escape, Modifiers::NONE);
assert!(u.app.picked().is_none());
assert_eq!(u.app.session.selection(), vec![f]);
// Nothing selected: the pick starts on the page's top layer; a press on the canvas ends it.
u.app.run("select.none", json!({}));
press(&mut u, Key::F8, Modifiers::NONE);
press(&mut u, Key::Tab, Modifiers::NONE);
assert_eq!(u.app.picked(), Some(f));
let p = u.app.canvas.rect.center();
u.button(p, true);
u.button(p, false);
assert!(u.app.picked().is_none());
// Picked in one document, the cursor stays behind when another comes to the front, even where
// that one has a layer with the same id (each document numbers its layers from 1): Space
// there selects nothing, and the stale pick is dropped.
u.app.run("select.set", json!({"ids": [a]}));
press(&mut u, Key::F8, Modifiers::NONE);
press(&mut u, Key::Tab, Modifiers::NONE);
assert_eq!(u.app.picked(), Some(a));
// On another page of the same document the cursor isn't there either: Space selects nothing.
let first_page = u.app.session.page().unwrap();
u.app.run("page.add", json!({}));
u.app.run("page.select", json!({"index": 1}));
assert_ne!(u.app.session.page().unwrap(), first_page);
assert_eq!(u.app.picked(), None);
press(&mut u, Key::Space, Modifiers::NONE);
assert!(u.app.session.selection().is_empty(), "nothing selected on the other page");
assert!(u.app.ui.pick.is_none());
u.app.run("page.select", json!({"index": 0}));
u.app.run("select.set", json!({"ids": [a]}));
press(&mut u, Key::F8, Modifiers::NONE);
press(&mut u, Key::Tab, Modifiers::NONE);
assert_eq!(u.app.picked(), Some(a));
u.app.run("file.new", json!({}));
for _ in 0..3 {
u.app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}));
}
u.app.run("select.none", json!({}));
assert!(u.app.session.doc().unwrap().node(a).is_some(), "the new document has a layer with the picked id");
assert_eq!(u.app.picked(), None);
press(&mut u, Key::Space, Modifiers::NONE);
assert!(u.app.session.selection().is_empty(), "nothing selected in the new document");
assert!(u.app.ui.pick.is_none());
}
/// A press on a prototype connection handle is a press on the canvas too: it ends pick select
/// like any other canvas press, rather than leaving the pink outline up.
#[test]
fn a_prototype_handle_press_ends_pick_select() {
let (mut u, a, _) = setup();
u.app.run("select.set", json!({"ids": [a]}));
press(&mut u, Key::F8, Modifiers::NONE);
press(&mut u, Key::Tab, Modifiers::NONE);
assert_eq!(u.app.picked(), Some(a));
u.app.ui.right_tab = crate::RightTab::Prototype;
u.frame(vec![]);
assert_eq!(u.app.picked(), Some(a), "still picking in the Prototype tab");
let b = u.app.session.doc().unwrap().world_bounds(a).unwrap();
let handle = u.app.to_screen(b.x1, (b.y0 + b.y1) / 2.0) + egui::vec2(11.0, 0.0);
u.button(handle, true);
u.button(handle, false);
assert!(u.app.ui.pick.is_none(), "the handle press ends pick select");
}
+6
View File
@@ -499,6 +499,11 @@ pub struct UiState {
/// A selection box drawn from the keyboard (`select.area`), page `[x0, y0, x1, y1]` with the
/// marquee's start at `x0, y0`.
pub key_box: Option<[f64; 4]>,
/// Pick select (#1836): the layer the pink outline is on while the keys walk the layer tree
/// (Tab or the selection-box chord again while the box shows; Space selects it), with the uid
/// of its document: layer ids are only unique within a document, and the pick holds only on
/// the page shown ([`FigApp::picked`]).
pub pick: Option<(u64, NodeId)>,
/// Layers panel search: open, the filter text, the shift-click range anchor and the selection
/// the tree last revealed (ancestors expand and the row scrolls into view when it changes).
pub layer_search: bool,
@@ -679,6 +684,7 @@ impl Default for UiState {
context_menu: None,
key_menu: false,
key_box: None,
pick: None,
layer_search: false,
layer_filter: String::new(),
layer_anchor: None,
+85 -2
View File
@@ -428,7 +428,7 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re
if key == Key::Escape && !egui::Popup::is_any_open(ctx) && crate::colorpicker::close_if_open(app) {
return true;
}
if key_box(app, key, m) {
if key_pick(app, key, m) || key_box(app, key, m) {
return true;
}
// Enter or Escape finishes crop mode (the crop is already applied).
@@ -521,8 +521,14 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re
/// F8): the arrows move it, Cmd/Ctrl+arrows or Shift+arrows move its far corner, sizing it (Shift
/// with Cmd/Ctrl: five times as far), by 10 screen pixels; Enter selects what it covers like a
/// marquee (Shift toggles, Cmd/Ctrl takes nested layers), Escape drops it.
fn key_box(app: &mut FigApp, key: Key, m: Modifiers) -> bool {
pub(crate) fn key_box(app: &mut FigApp, key: Key, m: Modifiers) -> bool {
let Some(mut b) = app.ui.key_box else { return false };
// Tab: the box becomes the pick cursor.
if key == Key::Tab && m.is_none() {
app.ui.key_box = None;
app.start_pick_select();
return true;
}
let size = m.shift || m.command;
let step = if m.shift && m.command { 50.0 } else { 10.0 } / app.session.ds().map_or(1.0, |d| d.view.zoom.max(1e-6));
let (dx, dy) = match key {
@@ -557,9 +563,86 @@ fn key_box(app: &mut FigApp, key: Key, m: Modifiers) -> bool {
}
}
/// The sibling `dir` rows away from layer `id` in the Layers panel (down for 1, up for -1,
/// wrapping), as `select.next` and `select.previous` walk: the list shows the last child on top.
fn pick_sibling(doc: &figcraft_doc::Document, id: figcraft_doc::NodeId, dir: i64) -> Option<figcraft_doc::NodeId> {
let siblings = &doc.node(doc.node(id)?.parent?)?.children;
let n = i64::try_from(siblings.len()).ok()?;
let idx = i64::try_from(siblings.iter().position(|c| *c == id)?).ok()?;
siblings.get(usize::try_from((idx - dir).rem_euclid(n)).ok()?).copied()
}
/// Keys while the pick cursor shows ([`FigApp::start_pick_select`]): Tab and Shift+Tab move it between
/// sibling layers in Layers-panel order, Enter into the top child of a frame or group, Shift+Enter
/// out to the parent (never the page), Space selects the layer under it, Escape or a click on the canvas drops it (#1836).
pub(crate) fn key_pick(app: &mut FigApp, key: Key, m: Modifiers) -> bool {
if app.ui.pick.is_none() {
return false;
}
// Picked in another document (one switched away from or closed since) or deleted: gone.
let Some(at) = app.picked() else {
app.ui.pick = None;
return false;
};
let Ok(uid) = app.session.ds().map(|d| d.uid) else { return false };
let Ok(doc) = app.session.doc() else { return false };
let Some(node) = doc.node(at) else { return false };
let plain = !m.command && !m.alt && !m.ctrl;
let next = match key {
Key::Tab if plain => pick_sibling(doc, at, if m.shift { -1 } else { 1 }),
Key::Enter if plain && m.shift => node.parent.filter(|p| doc.node(*p).is_some_and(|n| !n.is_page())),
Key::Enter if plain => node.children.last().copied(),
Key::Space if m.is_none() => {
app.ui.pick = None;
app.run("select.set", json!({"ids": [at]}));
return true;
}
Key::Escape => {
app.ui.pick = None;
return true;
}
_ => return false,
};
if let Some(n) = next {
app.ui.pick = Some((uid, n));
}
true
}
impl FigApp {
/// The layer the pick cursor is on, while its document is the one in front and the layer is
/// still there, on the page shown (#1836).
pub(crate) fn picked(&self) -> Option<figcraft_doc::NodeId> {
let (uid, id) = self.ui.pick?;
let ds = self.session.ds().ok()?;
(ds.uid == uid && ds.doc.page_of(id) == Some(ds.page)).then_some(id)
}
/// Pick select: the pink outline on the first selected layer, or on the page's top layer when
/// nothing is selected; the keys walk the
/// layer tree from there ([`key_pick`]).
pub(crate) fn start_pick_select(&mut self) {
let Ok(ds) = self.session.ds() else { return };
let top = self.session.page().ok().and_then(|p| ds.doc.node(p)).and_then(|n| n.children.last().copied());
let Some(at) = ds.selection.first().copied().or(top) else {
self.toast(crate::i18n::tr("Nothing to pick: the page is empty"));
return;
};
self.ui.pick = Some((ds.uid, at));
self.toast(crate::i18n::tr(
"Pick select: Tab and Shift+Tab move between layers, Enter goes into one, Shift+Enter out, Space selects, Escape cancels",
));
}
/// Show a selection box for the keyboard: around the selection, else in the middle of the view.
/// The chord again while the box shows switches to pick select, and while the pick cursor
/// shows brings the box back.
pub(crate) fn start_key_box(&mut self) {
if self.ui.key_box.take().is_some() {
self.start_pick_select();
return;
}
self.ui.pick = None;
let Ok(ds) = self.session.ds() else { return };
let zoom = ds.view.zoom.max(1e-6);
let around = ds.selection.iter().filter_map(|i| ds.doc.world_bounds(*i)).reduce(|a, b| a.union(b));
+7 -3
View File
File diff suppressed because one or more lines are too long