Merge fix/766 into batch 16

This commit is contained in:
bflatastic
2026-10-18 11:16:41 +07:00
7 changed files with 146 additions and 3 deletions
+23 -1
View File
@@ -1,6 +1,7 @@
//! Frame name labels as pointer targets: a press on a label (its area as the canvas drew it,
//! turned with a rotated frame) selects the frame and drags it in one undo step, hovering it
//! highlights the frame, and labels of locked, hidden, deleted or other pages' frames do nothing.
//! highlights the frame, a double-click asks the UI to rename it there, and labels of locked,
//! hidden, deleted or other pages' frames do nothing.
use figcraft_doc::NodeId;
use figcraft_geom::Point;
@@ -90,3 +91,24 @@ fn labels_of_frames_that_cant_be_picked_do_nothing() {
let flat = [Point::new(1.0, 1.0); 4];
assert_eq!(label_at(s.doc().unwrap(), page, &[(f, nan), (f, flat)], Point::new(1.0, 1.0)), None);
}
#[test]
fn double_clicking_a_label_asks_to_rename_its_frame() {
let (mut s, f, q) = setup();
let c = center(&q);
let before = s.doc().unwrap().world_bounds(f).unwrap();
let undo = s.ds().unwrap().history.undo.len();
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::DoubleClick] {
s.pointer(&ev(kind, c)).unwrap();
}
assert_eq!(s.selection(), vec![f]);
let asked = s.ui_requests.iter().find(|r| r.kind == "canvas.renameLabel").map(|r| id_of(&r.data["id"]));
assert_eq!(asked, Some(Some(f)), "the UI renames the frame at its label");
assert_eq!(s.doc().unwrap().world_bounds(f), Some(before), "not moved");
assert_eq!(s.ds().unwrap().history.undo.len(), undo, "nothing to undo yet");
// Other tools ignore the label.
s.ui_requests.clear();
s.set_tool(crate::tools::ToolId::Hand);
s.pointer(&ev(PointerKind::DoubleClick, c)).unwrap();
assert!(s.ui_requests.iter().all(|r| r.kind != "canvas.renameLabel"));
}
+14
View File
@@ -1859,6 +1859,20 @@ impl Session {
if self.tool.tool == ToolId::Pen {
return self.pen_finish();
}
// A frame's or section's name label: rename it there (the canvas puts a field over it).
// Labels come before widgets, as on a press; the second press began a move: undo it.
if matches!(self.tool.tool, ToolId::Move | ToolId::Scale)
&& !(ev.shift || ev.cmd || ev.alt)
&& let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p())
{
self.tool.gesture = Gesture::None;
self.cancel_interaction()?;
if sel != [id] {
self.set_selection(vec![id]);
}
self.request_ui("canvas.renameLabel", serde_json::json!({"id": id}));
return Ok(());
}
// Quick clicks on a widget's clickable part are clicks, one by one.
if matches!(self.tool.tool, ToolId::Move | ToolId::Scale)
&& !(ev.shift || ev.cmd || ev.alt)
+1
View File
@@ -242,6 +242,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
painter.image(tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
app.session.tool.labels = overlays(app, &painter, &t);
crate::frame_labels::rename_field(app, ui);
// The zoom tool's area.
if let (Some(start), Some(p)) = (app.canvas.zoom_drag, ctx.input(|i| i.pointer.hover_pos())) {
let area = Rect::from_two_pos(start, p);
+78 -1
View File
@@ -1,6 +1,6 @@
//! Frame name labels on the canvas, driven through egui headless: an upright frame's label sits
//! just above its top-left corner, a rotated frame's runs along its turned top edge, and pressing
//! either label selects and drags its frame.
//! either label selects and drags its frame; double-clicking one renames the frame or section there.
use egui::{Event, Pos2, pos2, vec2};
use figcraft_doc::NodeId;
@@ -79,3 +79,80 @@ fn pressing_a_rotated_label_selects_and_drags_the_frame() {
assert!(after.x0 > before.x0 + 1.0 && after.y0 > before.y0 + 1.0, "{before:?} → {after:?}");
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), undo + 1);
}
fn key(k: egui::Key) -> Vec<Event> {
vec![
Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() },
Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() },
]
}
/// Double-click `f`'s label and wait for the field to take the keyboard.
fn double_click_label(u: &mut Ui, f: NodeId) {
let at = mid(&label(u, f));
for _ in 0..2 {
u.button(at, true);
u.button(at, false);
}
u.frame(vec![]);
u.frame(vec![]);
}
fn name(u: &Ui, f: NodeId) -> String {
u.app.session.doc().unwrap().node(f).unwrap().name.clone()
}
#[test]
fn double_clicking_a_label_renames_the_frame_there() {
let (mut u, a, b) = setup();
let before = u.app.session.doc().unwrap().world_bounds(a).unwrap();
double_click_label(&mut u, a);
assert_eq!(u.app.session.selection(), vec![a]);
assert_eq!(u.app.ui.rename_label.map(|r| r.id), Some(a), "a field over the label");
// The name starts selected: typing replaces it, Enter keeps it.
u.frame(vec![Event::Text("Sign in".into())]);
u.frame(key(egui::Key::Enter));
u.frame(vec![]);
assert_eq!(name(&u, a), "Sign in");
assert!(u.app.ui.rename_label.is_none());
// The double-click didn't move the frame or rename the other one.
assert_eq!(u.app.session.doc().unwrap().world_bounds(a).unwrap(), before);
assert_eq!(name(&u, b), "Turned");
// One undo step puts the old name back.
u.app.session.undo().unwrap();
assert_eq!(name(&u, a), "Upright");
}
#[test]
fn escape_leaves_a_label_rename_with_the_old_name() {
let (mut u, _, b) = setup();
double_click_label(&mut u, b);
assert_eq!(u.app.ui.rename_label.map(|r| r.id), Some(b), "rotated labels rename too");
u.frame(vec![Event::Text("Nope".into())]);
u.frame(key(egui::Key::Escape));
u.frame(vec![]);
assert_eq!(name(&u, b), "Turned");
assert!(u.app.ui.rename_label.is_none());
}
#[test]
fn a_sections_label_renames_it_and_a_click_elsewhere_commits() {
let mut s = Session::new();
let a = id(s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 300, "height": 200, "name": "Area"})).unwrap());
s.execute("select.none", &json!({})).unwrap();
let mut u = Ui::new(s);
u.frame(vec![]);
let undo = u.app.session.ds().unwrap().history.undo.len();
double_click_label(&mut u, a);
assert_eq!(u.app.ui.rename_label.map(|r| r.id), Some(a), "a field over the section's label");
assert_eq!(u.app.session.selection(), vec![a]);
u.frame(vec![Event::Text("Zone".into())]);
let at = mid(&label(&u, a));
let away = pos2(at.x + 200.0, at.y + 500.0);
u.button(away, true);
u.button(away, false);
u.frame(vec![]);
assert_eq!(name(&u, a), "Zone", "a click on the canvas commits");
assert!(u.app.ui.rename_label.is_none());
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), undo + 1, "one undo step");
}
+22
View File
@@ -151,6 +151,28 @@ pub fn draw(app: &FigApp, painter: &Painter, t: &Tokens, doc: &Document, id: Nod
Some([area.left_top(), area.right_top(), area.right_bottom(), area.left_bottom()].map(|p| tl + rot * (p - tl)))
}
/// The field over a frame's or section's name label being renamed (`UiState::rename_label`, from a
/// double-click on the label): Enter or a click elsewhere renames the layer, Escape keeps the name.
/// The rename ends when the field lets go of the keyboard or the label is gone.
pub fn rename_field(app: &mut FigApp, ui: &mut egui::Ui) {
let Some(r) = app.ui.rename_label else { return };
let quad = app.session.tool.labels.iter().find(|(i, _)| *i == r.id).map(|(_, q)| q.map(|p| app.to_screen(p.x, p.y)));
let name = app.session.doc().ok().and_then(|d| d.node(r.id)).map(|n| n.name.clone());
let (Some(quad), Some(name)) = (quad, name) else {
app.ui.rename_label = None;
return;
};
let area = Rect::from_points(&quad);
let font = regular(11.0);
let text_w = ui.painter().layout_no_wrap(name.clone(), font.clone(), Color32::WHITE).size().x;
let rect = Rect::from_min_size(pos2(area.min.x, area.max.y - 16.0), vec2((text_w + 24.0).max(area.width()).clamp(80.0, 480.0), 16.0));
let (done, open) = crate::widgets::rename_field(ui, egui::Id::new(("frame_label_rename", r.id)), &name, rect, font, r.start);
app.ui.rename_label = open.then_some(crate::layers::Rename { id: r.id, start: false });
if let Some(new) = done {
app.run("object.rename", serde_json::json!({"ids": [r.id], "name": new}));
}
}
#[cfg(test)]
mod tests {
use super::*;
+6
View File
@@ -434,6 +434,8 @@ pub struct UiState {
/// The layer being renamed in the layers panel.
pub rename: Option<layers::Rename>,
pub rename_page: Option<(NodeId, String)>,
/// The frame or section whose name label on the canvas is being renamed (a double-click on it).
pub rename_label: Option<layers::Rename>,
pub main_menu: bool,
pub palette: Option<String>,
/// The Actions palette's tab.
@@ -591,6 +593,7 @@ impl Default for UiState {
expanded: BTreeSet::new(),
rename: None,
rename_page: None,
rename_label: None,
main_menu: false,
palette: None,
palette_tab: menus::PaletteTab::All,
@@ -965,6 +968,9 @@ impl FigApp {
"layers.collapseAll" => layers::collapse_all(self),
"contextMenu" => self.ui.key_menu = true,
"selectArea" => self.start_key_box(),
"canvas.renameLabel" => {
self.ui.rename_label = r.data.get("id").and_then(figcraft_engine::params::id_of).map(|id| layers::Rename { id, start: true });
}
"layers.rename" => {
let ids: Vec<NodeId> =
r.data.get("ids").and_then(Value::as_array).into_iter().flatten().filter_map(figcraft_engine::params::id_of).collect();
+2 -1
View File
@@ -198,7 +198,8 @@ A mouse gesture with the current tool. `kind`: `down`, `move`, `up`, `click`, `d
(`x`, `y`) or screen pixels (`sx`, `sy`). `pressure` (0–1) is pen pressure (the Pencil records
it); a drag's pressure goes from `pressure` to `pressureTo`. Returns `{selection, tool}`. With the Move and Scale
tools, a press on a frame's or section's name label (as the canvas last drew it, along a rotated
frame's top edge) selects and drags that frame.
frame's top edge) selects and drags that frame; a double-click there renames the frame in place in
the desktop app (the name field opens over the label).
```json
{"id":4,"method":"engine.execute","params":{"command":"tool.set","params":{"tool":"ellipse"}}}