M7: scrub typography spacing from leading icons (#373)

This commit is contained in:
Ivan Shishkanov
2026-10-15 19:28:46 +03:00
parent 102404e8c3
commit 55db465d8e
6 changed files with 305 additions and 44 deletions
+2 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Typography spacing icons support scrubbing (#373); system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -313,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. |
| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. |
| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged |
+3
View File
@@ -1225,3 +1225,6 @@ impl FigApp {
})
}
}
#[cfg(test)]
mod typography_scrub_tests;
+60 -14
View File
@@ -252,18 +252,18 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle]
let lh = uniform(styles, |s| s.line_height);
let lh_label = lh.map(|l| l.label()).unwrap_or_else(|| tr(MIXED).into());
let field = SpacingField { field: "lineHeight", id: Id::new("ty_lh"), glyph: LeadGlyph::LineHeight, tip: tr("Line height") };
if let Some(v) = spacing_field(app, ui, ids, &field, &lh_label, half, editing)
&& let Some(v) = parse_line_height(&v, lh)
if let Some(edit) = spacing_field(app, ui, ids, &field, &lh_label, half, editing)
&& let Some(v) = parse_line_height(&edit.text(), lh)
{
apply_style(app, "lineHeight", v);
commit_spacing(app, ids, range, &field, &edit, v);
}
let ls = uniform(styles, |s| s.letter_spacing);
let ls_label = ls.map(|l| l.label()).unwrap_or_else(|| tr(MIXED).into());
let field = SpacingField { field: "letterSpacing", id: Id::new("ty_ls"), glyph: LeadGlyph::LetterSpacing, tip: tr("Letter spacing") };
if let Some(v) = spacing_field(app, ui, ids, &field, &ls_label, half, editing)
&& let Some(v) = parse_letter_spacing(&v, ls)
if let Some(edit) = spacing_field(app, ui, ids, &field, &ls_label, half, editing)
&& let Some(v) = parse_letter_spacing(&edit.text(), ls)
{
apply_style(app, "letterSpacing", v);
commit_spacing(app, ids, range, &field, &edit, v);
}
});
}
@@ -283,10 +283,12 @@ fn paragraph_rows(
let resize = common(doc, ids, |n| n.kind.text().map(|t| t.auto_resize)).flatten();
row(ui, 32.0, |ui| {
let ps_label = ps.map(trim_num).unwrap_or_else(|| tr(MIXED).into());
if let Some(v) = lead_field(ui, Id::new("ty_ps"), LeadGlyph::ParagraphSpacing, &ps_label, half, tr("Paragraph spacing"))
&& let Some(v) = widgets::eval(&v, ps)
let field =
SpacingField { field: "paragraphSpacing", id: Id::new("ty_ps"), glyph: LeadGlyph::ParagraphSpacing, tip: tr("Paragraph spacing") };
if let Some(edit) = spacing_input(ui, &field, &ps_label, half)
&& let Some(v) = widgets::eval(&edit.text(), ps)
{
app.set_props(ids, json!({"paragraphSpacing": v.clamp(0.0, 10_000.0)}));
commit_spacing(app, ids, None, &field, &edit, json!(v.clamp(0.0, 10_000.0)));
}
let items =
[(Icon::Hug, tr("Auto width")), (Icon::LayoutV, tr("Auto height")), (Icon::Fixed, tr("Fixed size")), (Icon::Dots, tr("Truncate text"))];
@@ -938,8 +940,7 @@ fn paint_lead(p: &egui::Painter, r: Rect, g: LeadGlyph, col: Color32) {
}
}
/// A text field with a leading glyph (line height, letter spacing…). Returns the committed text.
/// Line height or letter spacing: which property, its field id, glyph and tooltip.
/// Line height, letter spacing or paragraph spacing: property, field id, glyph and tooltip.
struct SpacingField {
field: &'static str,
id: Id,
@@ -947,16 +948,61 @@ struct SpacingField {
tip: &'static str,
}
enum SpacingEdit {
Typed(String),
Scrub(f64, bool),
}
impl SpacingEdit {
fn text(&self) -> String {
match self {
Self::Typed(text) => text.clone(),
Self::Scrub(value, _) => trim_num(*value),
}
}
}
/// Keep typed unit parsing and character-range targeting; only a scrub outside a text-editing
/// session needs its own interaction. The editing session already owns its undo step.
fn commit_spacing(app: &mut FigApp, ids: &[NodeId], range: Option<(NodeId, usize, usize)>, field: &SpacingField, edit: &SpacingEdit, value: Value) {
let props = json!({field.field: value});
match edit {
SpacingEdit::Scrub(_, done) if figcraft_engine::textedit::selected_range(&app.session).is_none() => {
live(app, field.tip, "node.set", json!({"ids": ids, "props": props}), *done);
}
_ => commit_text(app, ids, range, props),
}
}
/// The original text editor plus the same leading-icon scrub gesture as ordinary number fields.
/// Auto and Mixed have no numeric starting value and remain unchanged by dragging.
fn spacing_input(ui: &mut Ui, field: &SpacingField, value: &str, width: f32) -> Option<SpacingEdit> {
let rect = Rect::from_min_size(ui.cursor().min, vec2(24.0, FIELD_H));
let typed = lead_field(ui, field.id, field.glyph, value, width, field.tip);
if ui.data(|d| d.get_temp::<String>(field.id.with("editing")).is_some()) {
return typed.map(SpacingEdit::Typed);
}
let response = ui.interact(rect, field.id.with("lead"), Sense::drag());
if response.hovered() || response.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
let numeric = value.trim_end_matches('%').parse::<f64>().ok();
match widgets::scrub(ui, field.id, &response, numeric, 1.0) {
Some(FieldEdit::Scrub(v, done)) => Some(SpacingEdit::Scrub(v, done)),
_ => typed.map(SpacingEdit::Typed),
}
}
/// A spacing field that whole layers can bind to a number variable (in pixels): the variable's
/// chip when bound, else the editable field with a variable button on hover. While a text range is
/// selected it only takes values. Returns the typed text.
fn spacing_field(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], f: &SpacingField, value: &str, width: f32, range: bool) -> Option<String> {
/// selected it only takes values. Returns a typed commit or a live scrub.
fn spacing_field(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], f: &SpacingField, value: &str, width: f32, range: bool) -> Option<SpacingEdit> {
let rect = Rect::from_min_size(ui.cursor().min, vec2(width, FIELD_H));
if !range && crate::variable_ui::bound_chip(app, ui, ids, f.field, VariableType::Number, rect) {
ui.allocate_rect(rect, Sense::hover());
return None;
}
let out = lead_field(ui, f.id, f.glyph, value, width, f.tip);
let out = spacing_input(ui, f, value, width);
if !range && ui.data(|d| d.get_temp::<String>(f.id.with("editing")).is_none()) {
crate::variable_ui::bind_button(ui, rect, ids, f.field, VariableType::Number);
}
@@ -0,0 +1,203 @@
//! Regression for #373: actual pointer gestures on the Typography section's three leading icons.
use egui::{Event, Id, Modifiers, Pos2, RawInput, Rect, vec2};
use figcraft_doc::{LetterSpacing, LineHeight, NodeId};
use figcraft_engine::Session;
use serde_json::json;
use crate::{FigApp, NoServices};
struct Fields {
app: FigApp,
ctx: egui::Context,
time: f64,
text: NodeId,
}
impl Fields {
fn new() -> Self {
let mut s = Session::new();
let text = NodeId(
s.execute("text.create", &json!({"text":"Typography","props":{"lineHeight":24,"letterSpacing":0,"paragraphSpacing":12}})).unwrap()["id"]
.as_u64()
.unwrap(),
);
s.execute("select.set", &json!({"ids":[text]})).unwrap();
let mut f = Self { app: FigApp::new(s, Box::new(NoServices)), ctx: egui::Context::default(), time: 0.0, text };
crate::theme::install_fonts(&f.ctx, false, 0);
crate::theme::apply(&f.ctx, crate::theme::Tokens::light());
for _ in 0..3 {
f.frame(vec![], Modifiers::NONE);
}
f
}
fn frame(&mut self, events: Vec<Event>, modifiers: Modifiers) -> egui::CursorIcon {
self.time += 1.0 / 60.0;
let mut events = events;
events.insert(0, Event::ModifiersChanged(modifiers));
let input =
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 600.0))), time: Some(self.time), events, ..Default::default() };
let mut out = self.ctx.run_ui(input, |ui| {
let ds = self.app.session.ds().unwrap();
let doc = ds.doc.clone();
let sel = ds.selection.clone();
crate::typography::typography(&mut self.app, ui, &doc, &sel);
});
out.textures_delta.clear();
out.platform_output.cursor_icon
}
fn lead(&self, key: &str) -> Pos2 {
let rect = self.ctx.read_response(Id::new(key).with("click")).unwrap().rect;
Pos2::new(rect.min.x + 12.0, rect.center().y)
}
fn drag(&mut self, key: &str, by: f32, modifiers: Modifiers) {
let p = self.lead(key);
self.frame(vec![Event::PointerMoved(p)], modifiers);
self.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers }], modifiers);
for step in 1..=4 {
self.frame(vec![Event::PointerMoved(p + vec2(by * step as f32 / 4.0, 0.0))], modifiers);
}
self.frame(vec![Event::PointerButton { pos: p + vec2(by, 0.0), button: egui::PointerButton::Primary, pressed: false, modifiers }], modifiers);
self.frame(vec![], Modifiers::NONE);
}
fn value(&self, key: &str) -> f64 {
let t = self.app.session.doc().unwrap().node(self.text).unwrap().kind.text().unwrap();
match key {
"ty_lh" => match t.style.line_height {
LineHeight::Pixels(v) | LineHeight::Percent(v) => v,
_ => -1.0,
},
"ty_ls" => match t.style.letter_spacing {
LetterSpacing::Pixels(v) | LetterSpacing::Percent(v) => v,
},
_ => t.paragraph_spacing,
}
}
}
#[test]
fn typography_icon_drag_changes_each_spacing_field_in_one_undo_step() {
for (key, before) in [("ty_lh", 24.0), ("ty_ls", 0.0), ("ty_ps", 12.0)] {
let mut f = Fields::new();
let undo = f.app.session.undo_depth().0;
f.drag(key, 16.0, Modifiers::NONE);
assert_eq!(f.value(key), before + 16.0, "{key}: dragging the icon must change the value");
assert_eq!(f.app.session.undo_depth().0, undo + 1, "{key}: one gesture, one undo step");
f.app.session.undo().unwrap();
assert_eq!(f.value(key), before);
f.app.session.redo().unwrap();
assert_eq!(f.value(key), before + 16.0);
}
}
#[test]
fn typography_icon_scrub_uses_shift_and_alt_rates_and_the_resize_cursor() {
for (modifiers, delta) in [(Modifiers::SHIFT, 160.0), (Modifiers::ALT, 1.6)] {
for (key, start) in [("ty_lh", 24.0), ("ty_ls", 0.0), ("ty_ps", 12.0)] {
let mut f = Fields::new();
let p = f.lead(key);
assert_eq!(f.frame(vec![Event::PointerMoved(p)], modifiers), egui::CursorIcon::ResizeHorizontal);
f.drag(key, 16.0, modifiers);
assert!((f.value(key) - start - delta).abs() < 1e-8, "{key}: {}", f.value(key));
}
}
}
#[test]
fn typography_icon_scrub_preserves_units_and_clamps_only_nonnegative_fields() {
let mut f = Fields::new();
f.app.run("node.set", json!({"id":f.text,"props":{"lineHeight":"150%","letterSpacing":"2px"}}));
f.frame(vec![], Modifiers::NONE);
f.drag("ty_lh", 16.0, Modifiers::NONE);
f.drag("ty_ls", -16.0, Modifiers::NONE);
let t = f.app.session.doc().unwrap().node(f.text).unwrap().kind.text().unwrap();
assert_eq!(t.style.line_height, LineHeight::Percent(166.0));
assert_eq!(t.style.letter_spacing, LetterSpacing::Pixels(-14.0));
f.drag("ty_lh", -200.0, Modifiers::NONE);
f.drag("ty_ps", -200.0, Modifiers::NONE);
assert_eq!(f.value("ty_lh"), 0.0);
assert_eq!(f.value("ty_ps"), 0.0);
assert!(f.app.ui.last_error.is_none(), "{:?}", f.app.ui.last_error);
}
#[test]
fn typography_icon_scrub_does_not_replace_auto_or_mixed_values() {
let mut f = Fields::new();
f.app.run("node.set", json!({"id":f.text,"props":{"lineHeight":"auto"}}));
f.frame(vec![], Modifiers::NONE);
let undo = f.app.session.undo_depth().0;
f.drag("ty_lh", 16.0, Modifiers::NONE);
assert_eq!(f.app.session.doc().unwrap().node(f.text).unwrap().kind.text().unwrap().style.line_height, LineHeight::Auto);
assert_eq!(f.app.session.undo_depth().0, undo);
let other = f.app.run("text.create", json!({"text":"Other","props":{"lineHeight":36}})).unwrap()["id"].clone();
f.app.run("select.set", json!({"ids":[f.text,other]}));
f.frame(vec![], Modifiers::NONE);
let before = f.app.session.doc().unwrap().clone();
let undo = f.app.session.undo_depth().0;
f.drag("ty_lh", 16.0, Modifiers::NONE);
assert_eq!(f.app.session.doc().unwrap(), &before);
assert_eq!(f.app.session.undo_depth().0, undo);
}
#[test]
fn typography_icon_scrub_changes_only_the_selected_characters_without_ending_text_editing() {
let mut f = Fields::new();
let undo = f.app.session.undo_depth().0;
figcraft_engine::textedit::begin(&mut f.app.session, f.text, Some(1), false).unwrap();
figcraft_engine::textedit::set_caret(&mut f.app.session, 4, true).unwrap();
f.frame(vec![], Modifiers::NONE);
f.drag("ty_ls", 16.0, Modifiers::NONE);
let t = f.app.session.doc().unwrap().node(f.text).unwrap().kind.text().unwrap();
assert_eq!(t.style_at(0).letter_spacing, LetterSpacing::Pixels(0.0));
assert_eq!(t.style_at(1).letter_spacing, LetterSpacing::Pixels(16.0));
assert_eq!(t.style_at(4).letter_spacing, LetterSpacing::Pixels(0.0));
assert!(f.app.session.ds().unwrap().text_edit.is_some());
assert_eq!(f.app.session.undo_depth().0, undo);
figcraft_engine::textedit::end(&mut f.app.session).unwrap();
assert_eq!(f.app.session.undo_depth().0, undo + 1);
f.app.session.undo().unwrap();
assert_eq!(f.value("ty_ls"), 0.0);
}
#[test]
fn typography_spacing_fields_still_accept_typed_units_and_escape_cancels() {
let mut f = Fields::new();
let p = f.lead("ty_lh") + vec2(36.0, 0.0);
for pressed in [true, false] {
f.frame(
vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }],
Modifiers::NONE,
);
}
f.frame(vec![], Modifiers::NONE);
assert!(f.ctx.data(|d| d.get_temp::<String>(Id::new("ty_lh").with("editing"))).is_some(), "click starts typing");
f.ctx.memory_mut(|m| m.request_focus(Id::new("ty_lh").with("te")));
f.frame(
vec![Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }],
Modifiers::COMMAND,
);
f.frame(vec![Event::Text("150%".into())], Modifiers::NONE);
assert_eq!(
f.ctx.data(|d| d.get_temp::<String>(Id::new("ty_lh").with("editing"))).as_deref(),
Some("150%"),
"keyboard selects and replaces the field text"
);
f.frame(
vec![Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }],
Modifiers::NONE,
);
assert_eq!(f.app.session.doc().unwrap().node(f.text).unwrap().kind.text().unwrap().style.line_height, LineHeight::Percent(150.0));
let undo = f.app.session.undo_depth().0;
for pressed in [true, false] {
f.frame(
vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }],
Modifiers::NONE,
);
}
f.ctx.memory_mut(|m| m.request_focus(Id::new("ty_lh").with("te")));
f.frame(vec![Event::Text("999".into())], Modifiers::NONE);
f.frame(
vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }],
Modifiers::NONE,
);
assert_eq!(f.value("ty_lh"), 150.0);
assert_eq!(f.app.session.undo_depth().0, undo);
}
+34 -27
View File
@@ -100,6 +100,38 @@ pub fn scrubbed(from: f64, amount: f64, quantum: f64) -> f64 {
if quantum > 0.0 && quantum.is_finite() { stepped(from, (amount / quantum).round(), quantum) } else { from }
}
/// The shared label-scrub gesture, including changing modifier rates and raw edge motion.
/// Callers supply the hit area and apply live values until the final `Scrub(_, true)`.
pub(crate) fn scrub(ui: &Ui, id: Id, response: &Response, value: Option<f64>, step: f64) -> Option<FieldEdit> {
let v = value?;
// Where the scrub began and how far it has taken the value (the modifiers can change on
// the way: each stretch counts at its own rate).
let scrub_id = id.with("scrub");
if response.dragged() || response.drag_stopped() {
let began = response.drag_started();
let (v0, mut amount): (f64, f64) = ui.data(|d| d.get_temp(scrub_id)).filter(|_| !began).unwrap_or((v, 0.0));
let (dx, shift, alt) = ui.input(|i| {
let p = &i.pointer;
// As it begins: all the way from the press (a drag is one only after a few points).
let moved = if began { p.hover_pos().zip(p.press_origin()).map_or(0.0, |(at, from)| at.x - from.x) } else { p.delta().x };
// With the pointer stopped at the edge of the screen (or of a window that fills
// it) the mouse's own motion goes on, where the platform reports it: the scrub
// does too. Elsewhere the scrub just stops there.
let raw = p.motion().map_or(0.0, |m| m.x / i.pixels_per_point);
let at_edge = moved == 0.0 && p.hover_pos().is_none_or(|at| at.x <= 1.0 || at.x >= i.content_rect().max.x - 1.0);
(f64::from(if at_edge { raw } else { moved }), i.modifiers.shift, i.modifiers.alt)
});
// The frame the button goes up moves nothing: the scrub ends where the last one left it.
if response.dragged() {
amount += scrub_amount(dx, step, shift, alt);
ui.data_mut(|d| d.insert_temp(scrub_id, (v0, amount)));
}
let quantum = if alt { step * 0.1 } else { step };
return Some(FieldEdit::Scrub(scrubbed(v0, amount, quantum), response.drag_stopped()));
}
None
}
pub(crate) fn fmt_num(v: f64) -> String {
if !v.is_finite() {
return String::new();
@@ -201,33 +233,8 @@ fn number_field_impl(
if lead_resp.hovered() || lead_resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
// Scrub by dragging the label: a step a point, ten with Shift, a tenth with Alt.
if let Some(v) = value {
// Where the scrub began and how far it has taken the value (the modifiers can change on
// the way: each stretch counts at its own rate).
let scrub_id = id.with("scrub");
if lead_resp.dragged() || lead_resp.drag_stopped() {
let began = lead_resp.drag_started();
let (v0, mut amount): (f64, f64) = ui.data(|d| d.get_temp(scrub_id)).filter(|_| !began).unwrap_or((v, 0.0));
let (dx, shift, alt) = ui.input(|i| {
let p = &i.pointer;
// As it begins: all the way from the press (a drag is one only after a few points).
let moved = if began { p.hover_pos().zip(p.press_origin()).map_or(0.0, |(at, from)| at.x - from.x) } else { p.delta().x };
// With the pointer stopped at the edge of the screen (or of a window that fills
// it) the mouse's own motion goes on, where the platform reports it: the scrub
// does too. Elsewhere the scrub just stops there.
let raw = p.motion().map_or(0.0, |m| m.x / i.pixels_per_point);
let at_edge = moved == 0.0 && p.hover_pos().is_none_or(|at| at.x <= 1.0 || at.x >= i.content_rect().max.x - 1.0);
(f64::from(if at_edge { raw } else { moved }), i.modifiers.shift, i.modifiers.alt)
});
// The frame the button goes up moves nothing: the scrub ends where the last one left it.
if lead_resp.dragged() {
amount += scrub_amount(dx, step, shift, alt);
ui.data_mut(|d| d.insert_temp(scrub_id, (v0, amount)));
}
let quantum = if alt { step * 0.1 } else { step };
out = Some(Edit::All(FieldEdit::Scrub(scrubbed(v0, amount, quantum), lead_resp.drag_stopped())));
}
if let Some(edit) = scrub(ui, id, &lead_resp, value, step) {
out = Some(Edit::All(edit));
}
let (key_step, key_big) = key_steps(ui.ctx(), suffix, step);
// The keyboard is on the field (also in a window that isn't in front: keys reach one only
+3 -2
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Typography spacing icons support scrubbing (#373); the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@@ -63,7 +63,7 @@ interaction feel not yet compared.
| Ctrl to suspend snapping while dragging (macOS) | done | Control on macOS (Cmd keeps working), Ctrl elsewhere; moving, resizing and drawing |
| Measure (Alt hover, Cmd+Alt nested, Shift+Alt padding/gaps) | done | labels never overlap |
| Pixel grid snapping, pixel preview, 2x preview | done | moves, resizes and new shapes land on whole pixels; the pixel grid shows from 400% (`view_behaviour_tests`) |
| Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | one field widget for every panel (`widgets::number_field`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's |
| Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | Numeric panels use `widgets::number_field`; Typography line height, letter spacing and paragraph spacing share its leading-icon scrub logic (#373), retaining their existing unit parsing, Auto/Mixed values, variable chips and selected-character targeting (`typography_scrub_tests`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's |
| Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation |
| Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection |
| Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes |
@@ -164,6 +164,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). |
| 2026-10-14 | minor | Type settings: the Small caps button draws its sample as a capital and a smaller capital, no longer as a box where the UI font lacks the small-capital letter (egui's default fonts) |
| 2026-10-14 | minor | Home screen recent files as cards with thumbnails on their page colour, a sortable table view, selection, keyboard and a context menu |
| 2026-10-13 | minor | Vector edit mode regions: with the Move tool the filled area under the pointer is hatched with "/" lines in the selection colour (about 1.4 px), clipped by the area's own fill rule so holes stay clear, and the hatch goes when the pointer is 3.5 px from a point or 2 px from a segment (screen pixels at any zoom); the spacing follows octave folding, fitted to measurements at several zoom levels (8 px at every power-of-two zoom, the gap growing with the zoom and stepping back at √2 · 2ⁿ), anchored to the vector so it doesn't slide when panning, one continuous pattern across selected areas; a click selects an area with its points (they move with it but keep the unselected look), Shift adds an area or takes a selected one out, a press on a selected area drags everything selected, drags show no hover hatch, a click on an empty spot (such as a hole) clears the areas and stays in edit mode, the last area listed wins where areas overlap, only filled vectors have areas; `vector.edit.select {regions}` and `vector.edit.state` report them (`regions_tests`, `vector_regions_tests`) |