mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 02:34:33 +00:00
M8: K focuses the Scale field, so K, 2, Enter scales by two (#2146)
The Scale tool now opens its factor field focused with its text selected (once per activation), the origin starts at the centre, and Enter or Escape in the field returns the tool to Move with the selection kept. Measured in Figma web, 2026-11-09. Docs: ui-parity Scale tool row and revision. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Model-Effort: Opus 5.5 drove and briefed; Sonnet 5.5 measured and wrote at medium
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
3839fe4a0c
commit
92145e6f8d
@@ -737,6 +737,69 @@ fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() {
|
||||
assert_eq!((w, h, undo_steps(&u)), (60.0, 45.0, steps + 1), "40×30 scaled to a width of 60");
|
||||
}
|
||||
|
||||
/// A 100 x 100 rectangle at the origin, selected, in a Ui with the Move tool.
|
||||
fn scale_setup() -> (Ui, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
(u, r)
|
||||
}
|
||||
|
||||
fn scale_field_focused(u: &Ui) -> bool {
|
||||
u.ctx.memory(|m| m.has_focus(Id::unique("scale_k").with("te")))
|
||||
}
|
||||
|
||||
/// Measured in Figma's web app (2026-11-09): K opens the Scale section with its input focused.
|
||||
#[test]
|
||||
fn pressing_k_focuses_the_scale_field_with_its_text_selected() {
|
||||
let (mut u, _) = scale_setup();
|
||||
assert!(!scale_field_focused(&u));
|
||||
key(&mut u, Key::K, Modifiers::NONE);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.app.session.tool.tool, figcraft_engine::ToolId::Scale);
|
||||
assert!(scale_field_focused(&u), "the scale field has the keyboard");
|
||||
let state = egui::TextEdit::load_state(&u.ctx, Id::unique("scale_k").with("te")).expect("the editor exists");
|
||||
let range = state.cursor.char_range().expect("a selection");
|
||||
assert_ne!(range.primary.index, range.secondary.index, "its text is selected");
|
||||
}
|
||||
|
||||
/// K, 2, Enter: 100 x 100 becomes 200 x 200 about the centre, the tool is Move, one undo step.
|
||||
#[test]
|
||||
fn k_two_enter_scales_the_selection_by_two_about_its_centre() {
|
||||
let (mut u, r) = scale_setup();
|
||||
let steps = undo_steps(&u);
|
||||
key(&mut u, Key::K, Modifiers::NONE);
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![Event::Text("2".to_string())]);
|
||||
key(&mut u, Key::Enter, Modifiers::NONE);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(geom(&u, r), (-50.0, -50.0, 200.0, 200.0));
|
||||
assert_eq!(u.app.session.tool.tool, figcraft_engine::ToolId::Move);
|
||||
assert_eq!(u.app.session.ds().unwrap().selection, vec![r]);
|
||||
assert_eq!(undo_steps(&u), steps + 1);
|
||||
u.app.run("edit.undo", json!({}));
|
||||
assert_eq!(geom(&u, r), (0.0, 0.0, 100.0, 100.0));
|
||||
}
|
||||
|
||||
/// Escape in the scale field closes it and changes nothing.
|
||||
#[test]
|
||||
fn escape_in_the_scale_field_closes_it_and_changes_nothing() {
|
||||
let (mut u, r) = scale_setup();
|
||||
let steps = undo_steps(&u);
|
||||
key(&mut u, Key::K, Modifiers::NONE);
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![Event::Text("3".to_string())]);
|
||||
key(&mut u, Key::Escape, Modifiers::NONE);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(geom(&u, r), (0.0, 0.0, 100.0, 100.0));
|
||||
assert_eq!(u.app.session.tool.tool, figcraft_engine::ToolId::Move);
|
||||
assert_eq!(u.app.session.ds().unwrap().selection, vec![r]);
|
||||
assert_eq!(undo_steps(&u), steps);
|
||||
}
|
||||
|
||||
/// The texts painted in the last frame: what they say, whether they end in an ellipsis, and where
|
||||
/// they show (within their clip).
|
||||
fn painted_texts(shapes: &[egui::epaint::ClippedShape]) -> Vec<(String, bool, egui::Rect)> {
|
||||
|
||||
@@ -281,6 +281,8 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
|
||||
if app.session.tool.tool == figcraft_engine::ToolId::Scale {
|
||||
crate::scale_panel::section(app, ui, &shown, &sel);
|
||||
widgets::separator(ui);
|
||||
} else {
|
||||
crate::scale_panel::closed(ui.ctx());
|
||||
}
|
||||
position(app, ui, &doc, &shown, &sel);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,27 @@ use crate::widgets::{self, FieldEdit, Lead};
|
||||
pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
let Some(b) = doc.bounds_of(sel) else { return };
|
||||
widgets::section_header(ui, tr("Scale"), &[], false);
|
||||
// The origin starts at the centre, as Figma's grid does: set once per app, so a point the
|
||||
// user picked later stays.
|
||||
let anchor_init = Id::unique("scale_anchor_init");
|
||||
if !ui.data(|d| d.get_temp::<bool>(anchor_init)).unwrap_or(false) {
|
||||
ui.data_mut(|d| d.insert_temp(anchor_init, true));
|
||||
app.run("tool.scaleAnchor", json!({"anchor": "c"}));
|
||||
}
|
||||
// The factor field takes the keyboard once, with its text selected, when the tool opens.
|
||||
let k_id = Id::unique("scale_k");
|
||||
let opened = Id::unique("scale_opened");
|
||||
if !ui.data(|d| d.get_temp::<bool>(opened)).unwrap_or(false) {
|
||||
ui.data_mut(|d| d.insert_temp(opened, true));
|
||||
widgets::request_edit(ui, k_id);
|
||||
}
|
||||
// Enter or Escape in the factor field ends the tool (back to Move, the selection kept).
|
||||
// (Focus as it was last frame: the shortcut layer may already have let go of it this frame.)
|
||||
let in_k_id = Id::unique("scale_in_k");
|
||||
let in_k = ui.data(|d| d.get_temp::<bool>(in_k_id)).unwrap_or(false);
|
||||
let focused_now = ui.memory(|m| m.has_focus(k_id.with("te")));
|
||||
ui.data_mut(|d| d.insert_temp(in_k_id, focused_now));
|
||||
let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
|
||||
let col = widgets::col_width(ui.available_width());
|
||||
// Anchor: column and row (0 start, 1 middle, 2 end) of the point that stays put.
|
||||
let anchor = app.session.tool.scale_anchor;
|
||||
@@ -41,9 +62,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
widgets::sublabels(ui, tr("Scale"), Some(tr("Anchor")));
|
||||
crate::right::block(ui, 52.0, 2.0, |ui| {
|
||||
// Relative to the layers as they are: a scrub applies once, when let go.
|
||||
if let Some(FieldEdit::Set(k) | FieldEdit::Scrub(k, true)) =
|
||||
widgets::number_field(ui, Id::unique("scale_k"), Lead::None, Some(1.0), "x", col, 0.1)
|
||||
{
|
||||
if let Some(FieldEdit::Set(k) | FieldEdit::Scrub(k, true)) = widgets::number_field(ui, k_id, Lead::None, Some(1.0), "x", col, 0.1) {
|
||||
factor = Some(k);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
@@ -70,6 +89,14 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if in_k && (enter || esc) {
|
||||
app.run("tool.set", json!({"tool": "move"}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget that the section opened, so the next time the Scale tool opens it takes the keyboard.
|
||||
pub fn closed(ctx: &egui::Context) {
|
||||
ctx.data_mut(|d| d.remove::<bool>(Id::unique("scale_opened")));
|
||||
}
|
||||
|
||||
/// The factor (clamped to 0.01–100) and pivot for scaling `b` by `k` around `anchor`; None when
|
||||
|
||||
@@ -223,6 +223,12 @@ pub fn number_field(ui: &mut Ui, id: Id, lead: Lead, value: Option<f64>, suffix:
|
||||
number_field_mixed(ui, id, lead, value, suffix, width, step)?.settled()
|
||||
}
|
||||
|
||||
/// Ask number field `id` to open for typing on its next frame, its text selected (a tool that
|
||||
/// opens with its field focused, such as Scale).
|
||||
pub fn request_edit(ui: &Ui, id: Id) {
|
||||
ui.data_mut(|d| d.insert_temp(id.with("beginedit"), true));
|
||||
}
|
||||
|
||||
/// Whether the edit of number field `id` began with a Cmd/Ctrl-click. A padding field edited
|
||||
/// that way sets every side (Guide to auto layout, Keyboard shortcut guide).
|
||||
pub fn edit_began_with_command(ui: &Ui, id: Id) -> bool {
|
||||
@@ -400,7 +406,8 @@ fn number_field_impl(
|
||||
}
|
||||
// Typing on a field the keyboard reached (Tab) edits it: the editor takes the typed text.
|
||||
let typed_now = editing.is_none() && on_field && ui.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(_))));
|
||||
let started = editing.is_none() && ((field_resp.clicked() && !alt_now) || typed_now);
|
||||
let requested = ui.data_mut(|d| d.remove_temp::<bool>(id.with("beginedit"))).unwrap_or(false);
|
||||
let started = editing.is_none() && ((field_resp.clicked() && !alt_now) || typed_now || requested);
|
||||
// The text the field would commit to no effect: what it showed when the edit began, or the
|
||||
// value the arrows last stepped it to.
|
||||
let kept_id = id.with("kept");
|
||||
|
||||
+3
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user