mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-07 12:13:14 +00:00
Merge main into #1729
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user