mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
Merge main into #1652
This commit is contained in:
+3
-1
File diff suppressed because one or more lines are too long
+109
-5
@@ -1,11 +1,12 @@
|
||||
//! Text layout: shaping styled spans, fallback fonts, bidirectional text, line breaking,
|
||||
//! alignment and decorations.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::{
|
||||
DecorationLength, DecorationStyle, LeadingTrim, ListStyle, ParaList, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle,
|
||||
valid_feature_tag,
|
||||
DecorationLength, DecorationStyle, LeadingTrim, LetterSpacing, LineHeight, ListStyle, ParaList, TextAlignH, TextAutoResize, TextCase, TextData,
|
||||
TextDecoration, TextStyle, color::PaintKind, valid_feature_tag,
|
||||
};
|
||||
use figcraft_geom::{Affine, BezPath, Rect};
|
||||
use harfrust::{Direction, Feature, ShapeOptions, ShaperInstance, Tag, UnicodeBuffer, Variation};
|
||||
@@ -335,6 +336,88 @@ struct ShapedText {
|
||||
word_breaks: Vec<bool>,
|
||||
}
|
||||
|
||||
/// Zero-sized; only its address is used: a per-process seed for style fingerprints, so
|
||||
/// document-supplied names can't precompute hash collisions (ASLR varies it across runs).
|
||||
struct FingerprintSeed;
|
||||
static FINGERPRINT_SEED: FingerprintSeed = FingerprintSeed;
|
||||
|
||||
/// `x`'s bits, with −0.0 folded onto 0.0 so the fingerprint merges what `PartialEq` does
|
||||
/// (`-0.0 == 0.0` is true, their bits differ).
|
||||
fn float_key(x: f64) -> u64 {
|
||||
if x == 0.0 { 0 } else { x.to_bits() }
|
||||
}
|
||||
|
||||
/// The FNV-1a 64-bit offset basis and prime (Fowler–Noll–Vo), a published pair that spreads
|
||||
/// short inputs evenly with one multiply and one xor per byte. Any well-mixed pair would do;
|
||||
/// these are the standard ones, so the arithmetic is recognisable.
|
||||
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
const FNV_PRIME: u64 = 0x0100_0000_01b3;
|
||||
|
||||
/// Mix `w` into the FNV-1a state `h`.
|
||||
fn mix(h: u64, w: u64) -> u64 {
|
||||
(h ^ w).wrapping_mul(FNV_PRIME)
|
||||
}
|
||||
|
||||
/// Base for the "some other paint kind" tags below: the values mean nothing, they only differ.
|
||||
const PAINT_KIND_TAG: u64 = 0x5157_0000;
|
||||
|
||||
/// A cheap fingerprint of the fields that usually tell two styles apart. Only the equality run
|
||||
/// after it decides matches, so this may skip any field: skipping one just sends two different
|
||||
/// styles to the same bucket. Keep the cheap discriminators here so the common distinct styles
|
||||
/// land in different buckets — and when `TextStyle` grows a usually-discriminating field, mix
|
||||
/// it in too.
|
||||
fn style_fingerprint(s: &TextStyle, seed: u64) -> u64 {
|
||||
let mut h = seed ^ FNV_OFFSET_BASIS;
|
||||
// A separator between the two names, so ("ab", "c") and ("a", "bc") differ; any byte a
|
||||
// family or style name cannot contain would do.
|
||||
const SEPARATOR: u64 = 0xff;
|
||||
for b in s.font_family.bytes() {
|
||||
h = mix(h, b as u64);
|
||||
}
|
||||
h = mix(h, SEPARATOR);
|
||||
for b in s.font_style.bytes() {
|
||||
h = mix(h, b as u64);
|
||||
}
|
||||
h = mix(h, SEPARATOR);
|
||||
h = mix(h, float_key(s.font_size));
|
||||
// The enum variants below key their payload's number into disjoint ranges (Auto 0, odd,
|
||||
// even) so one variant's payload never reads as another's.
|
||||
h = mix(
|
||||
h,
|
||||
match &s.line_height {
|
||||
LineHeight::Auto => 0,
|
||||
LineHeight::Pixels(px) => float_key(*px).wrapping_mul(2).wrapping_add(1),
|
||||
LineHeight::Percent(p) => float_key(*p).wrapping_mul(2).wrapping_add(2),
|
||||
},
|
||||
);
|
||||
h = mix(
|
||||
h,
|
||||
match &s.letter_spacing {
|
||||
LetterSpacing::Pixels(px) => float_key(*px).wrapping_mul(2).wrapping_add(1),
|
||||
LetterSpacing::Percent(p) => float_key(*p).wrapping_mul(2).wrapping_add(2),
|
||||
},
|
||||
);
|
||||
h = mix(h, s.case as u64);
|
||||
h = mix(h, s.decoration as u64);
|
||||
h = mix(h, s.fills.as_ref().map_or(0, Vec::len) as u64);
|
||||
if let Some(fills) = &s.fills {
|
||||
// Solid colours discriminate the everyday case; other paint kinds by their kind tag.
|
||||
for p in fills {
|
||||
match &p.kind {
|
||||
PaintKind::Solid { color } => {
|
||||
let [r, g, b, a] = color.to_rgba8();
|
||||
h = mix(h, ((r as u64) << 24) | ((g as u64) << 16) | ((b as u64) << 8) | a as u64);
|
||||
}
|
||||
PaintKind::Gradient(_) => h = mix(h, PAINT_KIND_TAG + 1),
|
||||
PaintKind::Image(_) => h = mix(h, PAINT_KIND_TAG + 2),
|
||||
PaintKind::Pattern(_) => h = mix(h, PAINT_KIND_TAG + 3),
|
||||
}
|
||||
}
|
||||
}
|
||||
h = mix(h, s.variations.len() as u64);
|
||||
h
|
||||
}
|
||||
|
||||
/// Shape `text`: each span split by paragraph, bidi run and face into glyphs (items, in logical
|
||||
/// order), with the grapheme cluster starts and word breaks line breaking needs.
|
||||
fn shape_text(text: &TextData) -> ShapedText {
|
||||
@@ -342,14 +425,35 @@ fn shape_text(text: &TextData) -> ShapedText {
|
||||
let spans = text.spans();
|
||||
let mut styles: Vec<TextStyle> = Vec::new();
|
||||
let mut span_style: Vec<usize> = Vec::with_capacity(spans.len());
|
||||
// Dedupe spans into styles: the last two styles again (spans run in blocks of one style, and
|
||||
// alternate between a few), then buckets keyed by a cheap fingerprint of discriminating
|
||||
// fields, each bucket resolved with the full `PartialEq`. Bucketing never merges what the
|
||||
// equality wouldn't (a collision only splits the scan it replaces), so the result is the
|
||||
// scan's, in O(spans) for names and sizes that differ (the audit's O(spans × styles),
|
||||
// #1102). The seed comes from the address space, so document-supplied
|
||||
// family names can't precompute collisions; the worst an adversarial document does is fall
|
||||
// back to the full scan it started with.
|
||||
let seed = std::ptr::from_ref(&FINGERPRINT_SEED) as u64;
|
||||
let mut last: [Option<usize>; 2] = [None, None];
|
||||
let mut buckets: HashMap<u64, Vec<usize>> = HashMap::new();
|
||||
for (_, _, s) in &spans {
|
||||
let idx = match styles.iter().position(|x| x == *s) {
|
||||
let idx = match last.iter().find_map(|i| i.filter(|&i| &styles[i] == *s)) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
styles.push((*s).clone());
|
||||
styles.len() - 1
|
||||
let fp = style_fingerprint(s, seed);
|
||||
let bucket = buckets.entry(fp).or_default();
|
||||
match bucket.iter().find(|&&i| &styles[i] == *s) {
|
||||
Some(&i) => i,
|
||||
None => {
|
||||
styles.push((*s).clone());
|
||||
let i = styles.len() - 1;
|
||||
bucket.push(i);
|
||||
i
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
last = [Some(idx), last[0]];
|
||||
span_style.push(idx);
|
||||
}
|
||||
let mut dbw = db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Style dedupe in `shape_text` (#1102): equal spans share a style entry whatever their order
|
||||
//! (blocks, alternation, pathological alternation), fields the fingerprint skips still split by
|
||||
//! equality, and −0.0 merges like `PartialEq` does.
|
||||
|
||||
use figcraft_doc::{StyleRun, TextData, TextStyle};
|
||||
|
||||
use crate::layout::layout;
|
||||
|
||||
fn solid(hex: &str) -> Option<Vec<figcraft_doc::color::Paint>> {
|
||||
Some(vec![figcraft_doc::color::Paint::solid(figcraft_doc::color::Color::from_hex(hex).unwrap())])
|
||||
}
|
||||
|
||||
fn style_with(fills: Option<Vec<figcraft_doc::color::Paint>>) -> TextStyle {
|
||||
TextStyle { fills, ..Default::default() }
|
||||
}
|
||||
|
||||
fn text_with_runs(runs: Vec<(usize, usize, TextStyle)>) -> TextData {
|
||||
let mut t = TextData::new("x".repeat(runs.iter().map(|r| r.1).max().unwrap_or(0)));
|
||||
t.runs = runs.into_iter().map(|(s, e, st)| StyleRun { start: s, end: e, style: st }).collect();
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_runs_share_one_style_entry() {
|
||||
// A run styled exactly like the base style adds no second entry, in either order.
|
||||
let mut t = TextData::new("Hello World");
|
||||
t.runs.push(StyleRun { start: 0, end: 5, style: TextStyle::default() });
|
||||
assert_eq!(layout(&t, None, None).styles.len(), 1);
|
||||
t.runs.clear();
|
||||
t.runs.push(StyleRun { start: 6, end: 11, style: TextStyle::default() });
|
||||
assert_eq!(layout(&t, None, None).styles.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_of_one_fingerprint_dedupe_to_one_entry() {
|
||||
// Five alternating runs of the same style: the last-style cache and the buckets merge them.
|
||||
let s = style_with(solid("#00AA44"));
|
||||
let t = text_with_runs(vec![(0, 1, s.clone()), (1, 2, style_with(None)), (2, 3, s.clone()), (3, 4, style_with(None)), (4, 5, s)]);
|
||||
let l = layout(&t, None, None);
|
||||
assert_eq!(l.styles.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_the_fingerprint_skips_still_split_by_equality() {
|
||||
// The fingerprint skips `link` and `style_id`; equality after it must still split them.
|
||||
let mut a = style_with(None);
|
||||
a.link = Some("https://a".into());
|
||||
let mut b = style_with(None);
|
||||
b.link = Some("https://b".into());
|
||||
let t = text_with_runs(vec![(0, 1, a), (1, 2, b)]);
|
||||
assert_eq!(layout(&t, None, None).styles.len(), 2);
|
||||
|
||||
let mut c = style_with(None);
|
||||
c.style_id = Some("style-1".into());
|
||||
let mut d = style_with(None);
|
||||
d.style_id = Some("style-2".into());
|
||||
let t = text_with_runs(vec![(0, 1, c), (1, 2, d)]);
|
||||
assert_eq!(layout(&t, None, None).styles.len(), 2);
|
||||
|
||||
// Two gradients hash to the same tag; their stops still split them.
|
||||
let grad = |base: &str| {
|
||||
Some(vec![figcraft_doc::color::Paint::gradient(figcraft_doc::color::Gradient::default_for(
|
||||
figcraft_doc::color::GradientKind::Linear,
|
||||
figcraft_doc::color::Color::from_hex(base).unwrap(),
|
||||
))])
|
||||
};
|
||||
let t = text_with_runs(vec![(0, 1, style_with(grad("#FF0000"))), (1, 2, style_with(grad("#00FF00")))]);
|
||||
assert_eq!(layout(&t, None, None).styles.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_zero_merges_like_equality() {
|
||||
// PartialEq merges −0.0 and 0.0; the fingerprint folds the sign of zero the same way.
|
||||
let mut a = style_with(None);
|
||||
a.font_size = 0.0;
|
||||
let mut b = style_with(None);
|
||||
b.font_size = -0.0;
|
||||
let t = text_with_runs(vec![(0, 1, a), (1, 2, b)]);
|
||||
assert_eq!(layout(&t, None, None).styles.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathological_alternation_dedupes_like_the_scan() {
|
||||
// 50 styles alternating twice: 100 runs dedupe to 50 entries, first-seen order kept.
|
||||
let styles: Vec<TextStyle> = (0..50)
|
||||
.map(|i| {
|
||||
let mut s = style_with(None);
|
||||
s.font_size = 12.0 + i as f64;
|
||||
s
|
||||
})
|
||||
.collect();
|
||||
let runs: Vec<(usize, usize, TextStyle)> = (0..100).map(|i| (i, i + 1, styles[i % 50].clone())).collect();
|
||||
let l = layout(&text_with_runs(runs), None, None);
|
||||
assert_eq!(l.styles.len(), 50, "alternating runs dedupe to the distinct styles");
|
||||
for (k, st) in l.styles.iter().enumerate() {
|
||||
assert_eq!(st.font_size, 12.0 + k as f64, "first-seen order kept at {k}");
|
||||
}
|
||||
// Every glyph points at the style of its run.
|
||||
for (i, g) in l.glyphs.iter().enumerate() {
|
||||
assert_eq!(l.styles[g.style].font_size, 12.0 + (i % 50) as f64, "glyph {i} points at its run's style");
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,8 @@ mod behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod cache_tests;
|
||||
#[cfg(test)]
|
||||
mod layout_dedupe_tests;
|
||||
#[cfg(test)]
|
||||
mod line_separator_tests;
|
||||
#[cfg(test)]
|
||||
mod list_tests;
|
||||
|
||||
@@ -1153,3 +1153,44 @@ fn prepared_save_cannot_enter_a_replacement(route: &str, save_as: bool, discard:
|
||||
assert!(u.app.ui.after_field.is_none());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Replacing a painted value with its editor must preserve its baseline, including inside
|
||||
/// the horizontal rows used by the sidebar.
|
||||
#[test]
|
||||
fn sidebar_field_text_stays_in_place_on_focus() {
|
||||
for numeric in [true, false] {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::apply(&ctx, crate::theme::Tokens::light());
|
||||
let id = Id::unique("baseline_field");
|
||||
let draw = |events| {
|
||||
let mut out = ctx.run_ui(
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(400.0, 200.0))), events, ..Default::default() },
|
||||
|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if numeric {
|
||||
crate::widgets::number_field(ui, id, crate::widgets::Lead::None, Some(123.0), "", 100.0, 1.0);
|
||||
} else {
|
||||
crate::widgets::text_field(ui, id, "123", 100.0, "");
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
};
|
||||
draw(vec![]);
|
||||
let before = draw(vec![]);
|
||||
let bounds = |out: &egui::FullOutput| painted_texts(&out.shapes).into_iter().find(|(text, _, _)| text == "123").expect("field text").2;
|
||||
let before = bounds(&before);
|
||||
let p = before.center();
|
||||
for pressed in [true, false] {
|
||||
draw(vec![
|
||||
Event::PointerMoved(p),
|
||||
Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
}
|
||||
assert!(ctx.memory(|m| m.has_focus(id.with("te"))), "editor is focused");
|
||||
let after = bounds(&draw(vec![]));
|
||||
assert!((before.center().y - after.center().y).abs() <= 0.5, "numeric={numeric}: before={before:?}, after={after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ pub(super) fn spacing_field(
|
||||
out
|
||||
}
|
||||
|
||||
/// A spacing value beside its scrub icon, centered in the same field while idle and editing.
|
||||
/// Commits typed text on Enter or focus loss; Escape cancels the draft.
|
||||
fn lead_field(ui: &mut Ui, id: Id, glyph: LeadGlyph, value: &str, width: f32, tip: &str) -> Option<String> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
|
||||
@@ -112,12 +114,13 @@ fn lead_field(ui: &mut Ui, id: Id, glyph: LeadGlyph, value: &str, width: f32, ti
|
||||
let mut out = None;
|
||||
match editing {
|
||||
Some(mut text) => {
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_rect.shrink2(vec2(2.0, 4.0))));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_rect.shrink2(vec2(2.0, 0.0))));
|
||||
let r = child.add(
|
||||
egui::TextEdit::singleline(&mut text)
|
||||
.id(id.with("te"))
|
||||
.frame(egui::Frame::NONE)
|
||||
.font(regular(11.0))
|
||||
.align(Align2::LEFT_CENTER)
|
||||
.desired_width(text_rect.width() - 6.0),
|
||||
);
|
||||
crate::a11y::own_focus(&r);
|
||||
|
||||
@@ -30,6 +30,10 @@ impl Fields {
|
||||
f
|
||||
}
|
||||
fn frame(&mut self, events: Vec<Event>, modifiers: Modifiers) -> egui::CursorIcon {
|
||||
self.frame_output(events, modifiers).platform_output.cursor_icon
|
||||
}
|
||||
/// Draws the real typography controls, retaining their shapes for position assertions.
|
||||
fn frame_output(&mut self, events: Vec<Event>, modifiers: Modifiers) -> egui::FullOutput {
|
||||
self.time += 1.0 / 60.0;
|
||||
let mut events = events;
|
||||
events.insert(0, Event::ModifiersChanged(modifiers));
|
||||
@@ -42,7 +46,7 @@ impl Fields {
|
||||
crate::typography::typography(&mut self.app, ui, &doc, &sel);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out.platform_output.cursor_icon
|
||||
out
|
||||
}
|
||||
/// Screen position of a typography field's label/lead, used to start a scrub.
|
||||
fn lead(&self, key: &str) -> Pos2 {
|
||||
@@ -159,20 +163,34 @@ fn typography_icon_scrub_changes_only_the_selected_characters_without_ending_tex
|
||||
}
|
||||
|
||||
/// Asserts typography spacing fields accept typed percent units, commit on Enter, and
|
||||
/// Escape restores the previous value without adding an undo step.
|
||||
/// Escape restores the previous value without adding an undo step. Focusing the editor must
|
||||
/// also leave the painted value at the same vertical position.
|
||||
#[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);
|
||||
let field = f.ctx.read_response(Id::unique("ty_lh").with("click")).unwrap().rect;
|
||||
let text_bounds = |out: egui::FullOutput| {
|
||||
out.shapes
|
||||
.iter()
|
||||
.find_map(|shape| match &shape.shape {
|
||||
egui::Shape::Text(text) if text.galley.text() == "24" && field.contains(text.pos) => Some(text.visual_bounding_rect()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the line-height value is painted inside its field")
|
||||
};
|
||||
let before = text_bounds(f.frame_output(vec![], Modifiers::NONE));
|
||||
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::unique("ty_lh").with("editing"))).is_some(), "click starts typing");
|
||||
f.ctx.memory_mut(|m| m.request_focus(Id::unique("ty_lh").with("te")));
|
||||
let after = text_bounds(f.frame_output(vec![], Modifiers::NONE));
|
||||
assert!((before.top() - after.top()).abs() <= 0.5 && (before.bottom() - after.bottom()).abs() <= 0.5, "before={before:?}, after={after:?}");
|
||||
assert!(f.ctx.memory(|m| m.has_focus(Id::unique("ty_lh").with("te"))), "line-height editor is focused");
|
||||
assert!(f.ctx.data(|d| d.get_temp::<String>(Id::unique("ty_lh").with("editing"))).is_some(), "click starts typing");
|
||||
f.frame(
|
||||
vec![Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }],
|
||||
Modifiers::COMMAND,
|
||||
|
||||
@@ -361,7 +361,9 @@ fn number_field_impl(
|
||||
.font(regular(11.0))
|
||||
.desired_width(edit_rect.width() - 6.0)
|
||||
.align(egui::Align2::LEFT_CENTER);
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(edit_rect.shrink2(vec2(0.0, 4.0))));
|
||||
// TextEdit already reserves the theme's 24 pt interaction height. Insetting its
|
||||
// top would move its baseline below the centered value painted while idle.
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(edit_rect));
|
||||
let r = child.add(te);
|
||||
a11y::own_focus(&r);
|
||||
keep_focus(&r, started);
|
||||
@@ -628,12 +630,13 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str
|
||||
/// its own (the field draws it), showing `hint` while empty; `password` shows dots.
|
||||
fn field_editor(ui: &mut Ui, rect: Rect, id: Id, text: &mut String, hint: &str, password: bool) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(8.0, 4.0))));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(8.0, 0.0))));
|
||||
let r = child.add(
|
||||
egui::TextEdit::singleline(text)
|
||||
.id(id)
|
||||
.frame(egui::Frame::NONE)
|
||||
.password(password)
|
||||
.align(Align2::LEFT_CENTER)
|
||||
.font(regular(11.0))
|
||||
.hint_text(egui::RichText::new(hint).font(regular(11.0)).color(t.text_tertiary))
|
||||
.desired_width(rect.width() - 16.0),
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **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:
|
||||
@@ -28,7 +28,7 @@ interaction feel not yet compared.
|
||||
| Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored |
|
||||
| Multiplayer avatars, Share button | missing | waits for multiplayer |
|
||||
| Colour names in hex fields | done | every hex field (fills, strokes, text ranges, effects, layout grids, colour variables, page and prototype backgrounds, the colour picker's Hex) takes a CSS colour name as well: `red` becomes `FF0000`; case, spaces and hyphens are ignored (`Light Blue`); names are opaque and keep the colour's opacity; hex wins where both could match. The picker's CSS field takes every CSS name too (`Color::from_hex_or_name`, `color::named`) |
|
||||
| Fields select their text on focus | done | a click or Tab into a one-line field selects all of its text, so typing replaces the value: hex and colour fields, number fields, inline fields (a fill's hex, variable values, renames), names, search boxes and dialog fields; a drag from the press selects only what it covers; multi-line fields (descriptions, comments) keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard; it took a second click. A rename (double-click, F2, File ▸ Rename, a page) selects the whole name before its field first draws, so a key typed in that same frame replaces the name; it was appended (`widgets::select_all_on_focus`, `select_all_tests`, `layers_rename_tests`) |
|
||||
| Fields select their text on focus | done | Number and text values keep their vertical position when their editor gains focus (including typography spacing; `sidebar_field_text_stays_in_place_on_focus`). a click or Tab into a one-line field selects all of its text, so typing replaces the value: hex and colour fields, number fields, inline fields (a fill's hex, variable values, renames), names, search boxes and dialog fields; a drag from the press selects only what it covers; multi-line fields (descriptions, comments) keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard; it took a second click. A rename (double-click, F2, File ▸ Rename, a page) selects the whole name before its field first draws, so a key typed in that same frame replaces the name; it was appended (`widgets::select_all_on_focus`, `select_all_tests`, `layers_rename_tests`) |
|
||||
| Colour picker models | done | Hex, RGB, HSL, HSB, LCH, OKLCH and CSS; LCH and OKLCH keep lightness and hue when a colour leaves sRGB (chroma comes down); the values typed stay while the colour is theirs (a hue typed on a grey, a chroma sRGB can't hold); CSS reads `lch()` and `oklch()` with any hue unit and `none` (`figcraft_color::{Lch, Oklch}`). Values are the stored channels read as sRGB |
|
||||
| Colour picker placement and dismissal | done | Opens 8 pt beside the sidebar at the opening control, lifted to fit the screen; outside presses (including the sidebar), Escape and X close it. Reopening resets a dragged position; eyedropper sampling keeps it open. |
|
||||
| Colour picker on a new fill or stroke | done | + opens the picker on the new paint when the selection shares one paint list; on a mixed selection + adds a paint to each layer and no picker opens (#404) |
|
||||
@@ -175,6 +175,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M1: sidebar number and text fields keep their text vertically centered when focused, including typography spacing. Editors use the full 24 pt field height instead of adding a 4 pt top inset to an editor already at least 24 pt tall. Regression: `field_tests::sidebar_field_text_stays_in_place_on_focus` compares painted text positions before and after focus. Readiness estimates unchanged. |
|
||||
| 2026-10-30 | minor | Pen into vector edit mode (#1430): Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on; Escape keeps the pen. Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M8, #1574: the Actions palette (Ctrl+K) offered engine commands that need parameters it can't give, such as `image.place` ("Place image", needing the image's `data` or `path`), which failed with "Invalid parameters" and sat above the menu's Place image… row with the same label. A search now leaves out a command no menu lists when its params doc names a parameter without `?` (`figcraft_engine::cmd::needs_params`), and a row no menu item gives whose label reads like a menu item's (any case, without the ellipsis). Place image runs File ▸ Place image…, which opens the file dialog. Tests: `palette_tests.rs` (no row needs parameters unless a menu item runs it, no duplicate label across several searches), `unknown_params_tests.rs`. |
|
||||
| 2026-10-30 | minor | Preferences ▸ Scroll wheel zoom (#1390): a mouse wheel zooms about the pointer and Cmd/Ctrl+wheel scrolls; Shift+wheel, trackpads and pinches unchanged. Numbers unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user