mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 19:42:05 +00:00
Merge main into #1855
This commit is contained in:
commit
a7ba9fb460
12 files changed
+272
-31
No files matched your search
+11
-7
@@ -767,16 +767,20 @@ impl Document {
|
||||
/// children only within their mask). A group, or an unclipped frame past its box, is what its
|
||||
/// children show grown by its effects, which apply to all of it.
|
||||
pub fn world_render_bounds(&self, id: NodeId) -> Option<Rect> {
|
||||
self.render_bounds_at(id, 0)
|
||||
// A tree visits each node once; a child list deserialized with repeats (a graph) is cut off
|
||||
// after twice the document's node count rather than walked in full.
|
||||
self.render_bounds_at(id, 0, &std::cell::Cell::new(self.nodes.len().saturating_mul(2).saturating_add(8)))
|
||||
}
|
||||
|
||||
fn render_bounds_at(&self, id: NodeId, depth: usize) -> Option<Rect> {
|
||||
fn render_bounds_at(&self, id: NodeId, depth: usize, left: &std::cell::Cell<usize>) -> Option<Rect> {
|
||||
let n = self.node(id)?;
|
||||
let own = n.own_paint_bounds(self.world_transform(id));
|
||||
if (n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children()) || depth >= MAX_RENDER_DEPTH {
|
||||
let visit = left.get();
|
||||
left.set(visit.saturating_sub(1));
|
||||
if (n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children()) || depth >= MAX_RENDER_DEPTH || visit == 0 {
|
||||
return Some(own);
|
||||
}
|
||||
Some(match (self.children_render_bounds(n, depth + 1), &n.kind) {
|
||||
Some(match (self.children_render_bounds(n, depth + 1, left), &n.kind) {
|
||||
(Some(kids), NodeKind::Group) => n.grow_by_effects(kids),
|
||||
// An unclipped frame's effects apply to its children too, past its box included.
|
||||
(Some(kids), _) => own.union(n.grow_by_effects(kids)),
|
||||
@@ -785,15 +789,15 @@ impl Document {
|
||||
}
|
||||
|
||||
/// What `n`'s visible children show, together: each mask's run within the mask's bounds.
|
||||
fn children_render_bounds(&self, n: &Node, depth: usize) -> Option<Rect> {
|
||||
fn children_render_bounds(&self, n: &Node, depth: usize, left: &std::cell::Cell<usize>) -> Option<Rect> {
|
||||
let shown = |i: &NodeId| self.node(*i).is_some_and(|c| c.visible && !c.is_mask);
|
||||
let mut out: Option<Rect> = None;
|
||||
for run in self.paint_runs(n) {
|
||||
let ids = n.children.get(run).unwrap_or(&[]);
|
||||
let mask = ids.first().copied().filter(|m| self.node(*m).is_some_and(|c| c.is_mask && c.visible));
|
||||
let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.render_bounds_at(*i, depth)).reduce(|a, b| a.union(b));
|
||||
let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.render_bounds_at(*i, depth, left)).reduce(|a, b| a.union(b));
|
||||
let r = match mask {
|
||||
Some(m) => r.zip(self.render_bounds_at(m, depth)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0),
|
||||
Some(m) => r.zip(self.render_bounds_at(m, depth, left)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0),
|
||||
None => r,
|
||||
};
|
||||
out = match (out, r) {
|
||||
|
||||
@@ -596,6 +596,22 @@ fn is_ancestor_holds_for_very_deep_chains() {
|
||||
assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_bounds_of_a_doubled_child_list_end_at_once() {
|
||||
// A file can list a child twice at every level: 2^40 visits if each is walked.
|
||||
let mut d = Document::new("x");
|
||||
let mut parent = page(&d);
|
||||
let mut groups = Vec::new();
|
||||
for _ in 0..40 {
|
||||
parent = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
groups.push(parent);
|
||||
}
|
||||
for w in groups.windows(2) {
|
||||
d.node_mut(w[0]).unwrap().children.push(w[1]);
|
||||
}
|
||||
assert!(d.world_render_bounds(groups[0]).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_bounds_stop_where_drawing_stops_on_very_deep_chains() {
|
||||
// A crafted file nests groups far deeper than anything draws (#1062). The leaf counts while
|
||||
|
||||
@@ -215,6 +215,30 @@ mod tests {
|
||||
assert_eq!(x(&s, small).0, x(&s, big).0);
|
||||
}
|
||||
|
||||
/// "for baseline alignment, the bottoms of the icon and the word home are aligned on the red
|
||||
/// line" (Use the horizontal and vertical flows in auto layout, the figure's caption; #1825): a
|
||||
/// layer without text sits on the text's baseline with its bottom edge, not at the top.
|
||||
#[test]
|
||||
fn baseline_alignment_puts_an_icons_bottom_on_the_text_baseline() {
|
||||
let mut s = Session::new();
|
||||
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100, "props": {"layout": "horizontal", "gap": 8}})));
|
||||
let icon = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 16, "height": 16, "parent": f})));
|
||||
let home = id(&ex(&mut s, "text.create", json!({"text": "Home", "x": 0, "y": 0, "parent": f, "props": {"fontSize": 48}})));
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"crossAlign": "baseline"}}));
|
||||
let base = figcraft_text::layout_node(&node(&s, home)).and_then(|(l, dy)| l.lines.first().map(|line| line.baseline + dy));
|
||||
// Only meaningful when the text was measured (fonts available) and its baseline is below
|
||||
// the icon's height.
|
||||
if let Some(b) = base
|
||||
&& b > 16.0
|
||||
{
|
||||
let bottom = x(&s, icon).1 + 16.0;
|
||||
assert!((bottom - (x(&s, home).1 + b)).abs() < 1e-6, "icon bottom {bottom}, text baseline {}", x(&s, home).1 + b);
|
||||
}
|
||||
// Top alignment puts it back at the top.
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"crossAlign": "min"}}));
|
||||
assert_eq!(x(&s, icon).1, x(&s, home).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_tracks_cells_and_undo() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -20,6 +20,9 @@ const MAX_VARIABLES: usize = 100_000;
|
||||
const MAX_MODES: usize = 1_000;
|
||||
/// Longest string variable value kept (characters).
|
||||
const MAX_STRING: usize = 10_000;
|
||||
/// The copies of fallback values that fill missing modes, in bytes, for one import: variables, modes
|
||||
/// and string length are each capped, but not their product. Past it, modes stay empty.
|
||||
const MAX_FILLED_BYTES: usize = 64 << 20;
|
||||
|
||||
/// The object holding `variableCollections` and `variables`: a local variables response's `meta`,
|
||||
/// or `v` itself.
|
||||
@@ -287,14 +290,10 @@ impl Ctx<'_> {
|
||||
if dropped {
|
||||
self.warn("variable aliases to variables that aren't in the variables JSON (or of another type) were skipped");
|
||||
}
|
||||
let mut budget = MAX_FILLED_BYTES;
|
||||
for v in found.iter_mut() {
|
||||
let Some(modes) = self.var_modes.get(&v.collection) else { continue };
|
||||
let fallback = modes.first().and_then(|m| v.values.get(m)).or_else(|| v.values.values().next()).cloned();
|
||||
if let Some(f) = fallback {
|
||||
for m in modes {
|
||||
v.values.entry(m.clone()).or_insert_with(|| f.clone());
|
||||
}
|
||||
}
|
||||
fill_modes(&mut v.values, modes, &mut budget);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,3 +520,36 @@ fn field_type(field: &str) -> VariableType {
|
||||
_ => VariableType::Number,
|
||||
}
|
||||
}
|
||||
|
||||
/// Give each of `modes` without a value a copy of the first mode's (else any) value, while the
|
||||
/// copies' bytes fit in `budget`.
|
||||
fn fill_modes(values: &mut BTreeMap<String, VariableValue>, modes: &[String], budget: &mut usize) {
|
||||
let Some(f) = modes.first().and_then(|m| values.get(m)).or_else(|| values.values().next()).cloned() else { return };
|
||||
let cost =
|
||||
64 + modes.iter().map(String::len).max().unwrap_or(0) + if let VariableValue::String(t) | VariableValue::Alias(t) = &f { t.len() } else { 0 };
|
||||
for m in modes {
|
||||
if values.contains_key(m) {
|
||||
continue;
|
||||
}
|
||||
let Some(left) = budget.checked_sub(cost) else { return };
|
||||
*budget = left;
|
||||
values.insert(m.clone(), f.clone());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fill_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_modes_are_filled_only_within_the_budget() {
|
||||
let modes: Vec<String> = (0..10).map(|i| format!("m{i}")).collect();
|
||||
let mut v = BTreeMap::from([("m0".to_string(), VariableValue::String("x".repeat(100)))]);
|
||||
let mut budget = 1_000;
|
||||
fill_modes(&mut v, &modes, &mut budget);
|
||||
assert!(v.len() > 1 && v.len() < 10, "{}", v.len());
|
||||
let mut v = BTreeMap::from([("m0".to_string(), VariableValue::Number(1.0))]);
|
||||
fill_modes(&mut v, &modes, &mut { MAX_FILLED_BYTES });
|
||||
assert_eq!(v.len(), 10);
|
||||
}
|
||||
}
|
||||
@@ -535,12 +535,32 @@ fn alignment_rules() {
|
||||
rects: &[(0.0, 10.0, 30.0, 14.0), (35.0, 0.0, 60.0, 28.0)],
|
||||
},
|
||||
Case {
|
||||
// Docs silent on layers without text: they keep to the top of the line.
|
||||
rule: "baseline alignment: a layer without text sits at the top",
|
||||
// "for baseline alignment, the bottoms of the icon and the word home are aligned on
|
||||
// the red line" (the figure's caption): a layer without text sits on the baseline with
|
||||
// its bottom edge. Baseline 20, icon 10 high: the icon moves down 10 (#1825).
|
||||
rule: "baseline alignment: a layer without text puts its bottom on the baseline",
|
||||
frame: Frame { sizing: (Hug, Hug), cross: CrossAlign::Baseline, ..FRAME },
|
||||
kids: &[kid(10.0, 10.0), Kid { text: Some((60.0, 28.0, 20.0)), sizing: (Hug, Hug), ..KID }],
|
||||
want: (70.0, 28.0),
|
||||
rects: &[(0.0, 0.0, 10.0, 10.0), (10.0, 0.0, 60.0, 28.0)],
|
||||
rects: &[(0.0, 10.0, 10.0, 10.0), (10.0, 0.0, 60.0, 28.0)],
|
||||
},
|
||||
Case {
|
||||
// The icon (30 high) reaches below the text's baseline (20): the line's baseline is the
|
||||
// icon's bottom, so the text moves down 10 and the frame hugs 10 + 28.
|
||||
rule: "baseline alignment: a layer taller than the baseline moves the text down",
|
||||
frame: Frame { sizing: (Hug, Hug), cross: CrossAlign::Baseline, ..FRAME },
|
||||
kids: &[kid(10.0, 30.0), Kid { text: Some((60.0, 28.0, 20.0)), sizing: (Hug, Hug), ..KID }],
|
||||
want: (70.0, 38.0),
|
||||
rects: &[(0.0, 0.0, 10.0, 30.0), (10.0, 10.0, 60.0, 28.0)],
|
||||
},
|
||||
Case {
|
||||
// Docs silent on a line with no text at all: every layer's baseline is its bottom
|
||||
// edge (as the icon's), so the bottoms line up, as they do under CSS baseline alignment.
|
||||
rule: "baseline alignment without text: the layers' bottoms line up",
|
||||
frame: Frame { sizing: (Hug, Hug), cross: CrossAlign::Baseline, ..FRAME },
|
||||
kids: &[kid(10.0, 10.0), kid(10.0, 30.0)],
|
||||
want: (20.0, 30.0),
|
||||
rects: &[(0.0, 20.0, 10.0, 10.0), (10.0, 0.0, 10.0, 30.0)],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -332,7 +332,8 @@ struct Item {
|
||||
/// inside stroke): fill children share the free space by their content area, border-box
|
||||
/// fashion, so these come on top of each one's share.
|
||||
inset_main: f64,
|
||||
/// Baseline alignment: distance from the item's top to its first baseline.
|
||||
/// Baseline alignment: distance from the item's top to its first text baseline, or to its
|
||||
/// bottom edge when it has no text (an icon next to a text sits on the line with its bottom).
|
||||
baseline: Option<f64>,
|
||||
/// Baseline alignment: offset from the line's top (computed per line).
|
||||
base_off: f64,
|
||||
@@ -555,8 +556,11 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
|
||||
it.cross = if horizontal { h } else { w };
|
||||
}
|
||||
if baseline {
|
||||
// A layer without a text baseline (an icon, a shape, a frame holding no text) sits on the
|
||||
// line with its bottom edge: "the bottoms of the icon and the word home are aligned on the
|
||||
// red line" (Use the horizontal and vertical flows in auto layout; #1825).
|
||||
for it in items.iter_mut().filter(|it| !it.fill_cross) {
|
||||
it.baseline = item_baseline(doc, it.id, m, 0);
|
||||
it.baseline = item_baseline(doc, it.id, m, 0).or(Some(it.cross));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -302,10 +302,11 @@ fn baseline_alignment_horizontal() {
|
||||
let rect = d.create(f, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 20.0).unwrap();
|
||||
let m = FakeMeasure([(small, (30.0, 14.0, 10.0)), (big, (60.0, 40.0, 30.0))].into_iter().collect());
|
||||
layout_frame(&mut d, f, &m);
|
||||
// Baselines line up at 5 + 30; the rectangle (no baseline) sits at the top.
|
||||
// Baselines line up at 5 + 30; the rectangle (no text baseline) sits on it with its bottom
|
||||
// edge: 35 - 20 (#1825).
|
||||
assert_eq!(pos(&d, small).1, 25.0);
|
||||
assert_eq!(pos(&d, big).1, 5.0);
|
||||
assert_eq!(pos(&d, rect).1, 5.0);
|
||||
assert_eq!(pos(&d, rect).1, 15.0);
|
||||
// Hug height covers the shifted small text: max(20 + 14, 40) + padding.
|
||||
assert_eq!(d.node(f).unwrap().height, 50.0);
|
||||
// Nested auto layout frames align on their first child's baseline.
|
||||
|
||||
@@ -51,6 +51,115 @@ fn sorted(mut v: Vec<NodeId>) -> Vec<NodeId> {
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_hierarchies_have_continuous_rounded_backgrounds() {
|
||||
let mut s = Session::new();
|
||||
let frame = id(s.execute("frame.create", &json!({"name": "Selected frame"})).unwrap());
|
||||
let child = rect(&mut s, "child", 0.0, Some(frame));
|
||||
let group = id(s.execute("object.group", &json!({"ids": [child]})).unwrap());
|
||||
rect(&mut s, "sibling", 30.0, Some(frame));
|
||||
let other = id(s.execute("frame.create", &json!({"name": "Other frame"})).unwrap());
|
||||
rect(&mut s, "other child", 0.0, Some(other));
|
||||
s.execute("select.set", &json!({"ids": [frame, group, other]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.app.ui.expanded.extend([frame, group, other]);
|
||||
let selected_rows = [frame, group, other].map(|id| u.row_at(id));
|
||||
let out = u.frame_output(vec![Event::PointerGone]);
|
||||
let t = crate::theme::Tokens::get(&u.ctx);
|
||||
for pos in selected_rows {
|
||||
let rect_at = |fill| {
|
||||
out.shapes.iter().enumerate().find_map(|(i, s)| match &s.shape {
|
||||
egui::Shape::Rect(r) if r.fill == fill && r.rect.contains(pos) => Some(i),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let background = rect_at(t.selected_row_child).expect("the selected row belongs to the hierarchy background");
|
||||
let highlight = rect_at(t.selected_row).expect("each selected frame or group has its own highlight");
|
||||
assert!(highlight > background, "the selected highlight is painted above the hierarchy background");
|
||||
}
|
||||
let backgrounds: Vec<_> = out
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
egui::Shape::Rect(r) if r.fill == t.selected_row_child => Some(r),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// Other frame + child, then selected frame + sibling + selected group + child.
|
||||
assert_eq!(backgrounds.len(), 6);
|
||||
for band in [&backgrounds[..2], &backgrounds[2..]] {
|
||||
assert_eq!(band.first().unwrap().corner_radius.nw, 5);
|
||||
assert_eq!(band.last().unwrap().corner_radius.sw, 5);
|
||||
for pair in band.windows(2) {
|
||||
assert_eq!(pair[0].rect.bottom(), pair[1].rect.top(), "the highlight fills the inter-row spacing");
|
||||
assert_eq!(pair[0].corner_radius.sw, 0, "no rounded seams inside the hierarchy");
|
||||
assert_eq!(pair[1].corner_radius.nw, 0, "a selected nested group stays in the same block");
|
||||
}
|
||||
}
|
||||
assert!(backgrounds[1].rect.bottom() < backgrounds[2].rect.top(), "separate selected hierarchies stay separate");
|
||||
// A collapsed frame and a filtered leaf each have all four corners rounded.
|
||||
u.app.ui.expanded.clear();
|
||||
let out = u.frame_output(vec![]);
|
||||
let collapsed: Vec<_> = out
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
egui::Shape::Rect(r) if r.fill == t.selected_row_child => Some(r),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(collapsed.len(), 2);
|
||||
assert!(collapsed.iter().all(|r| r.corner_radius == egui::CornerRadius::same(5)));
|
||||
u.app.ui.layer_filter = "child".into();
|
||||
let out = u.frame_output(vec![]);
|
||||
assert!(out.shapes.iter().any(|s| matches!(&s.shape,
|
||||
egui::Shape::Rect(r) if r.fill == t.selected_row_child && r.corner_radius.sw == 5
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_hierarchy_keeps_square_seams_when_scrolled() {
|
||||
let mut s = Session::new();
|
||||
let frame = id(s.execute("frame.create", &json!({"name": "Long hierarchy"})).unwrap());
|
||||
for i in 0..70 {
|
||||
rect(&mut s, &format!("Child {i}"), 0.0, Some(frame));
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": [frame]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.app.ui.expanded.insert(frame);
|
||||
let fill = crate::theme::Tokens::get(&u.ctx).selected_row_child;
|
||||
let corners = |out: &egui::FullOutput| {
|
||||
out.shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
egui::Shape::Rect(r) if r.fill == fill && r.rect.intersects(s.clip_rect) => Some(r.corner_radius),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let wheel = |y| Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, y),
|
||||
modifiers: Modifiers::NONE,
|
||||
phase: egui::TouchPhase::Move,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
};
|
||||
u.frame(vec![Event::PointerMoved(egui::pos2(150.0, 500.0)), wheel(-700.0)]);
|
||||
for _ in 0..30 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
let middle = corners(&u.frame_output(vec![]));
|
||||
assert!(!middle.is_empty());
|
||||
assert!(middle.iter().all(|c| *c == egui::CornerRadius::ZERO), "viewport edges inside a subtree are square seams");
|
||||
u.frame(vec![wheel(-10000.0)]);
|
||||
for _ in 0..30 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
let end = corners(&u.frame_output(vec![]));
|
||||
assert_eq!(end.first().unwrap().nw, 0, "the hierarchy starts above the viewport");
|
||||
assert_eq!(end.last().unwrap().sw, 5, "the hierarchy's real end stays rounded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_rows_selects_by_the_documented_rules() {
|
||||
let (mut u, [a, b, c, d]) = four();
|
||||
|
||||
@@ -962,15 +962,34 @@ fn reveal_selection(app: &mut FigApp, doc: &Document, page: NodeId, sel: &[NodeI
|
||||
reveal
|
||||
}
|
||||
|
||||
/// The outermost selected layer owning a row's continuous hierarchy highlight.
|
||||
fn selection_root(doc: &Document, sel: &BTreeSet<NodeId>, id: NodeId) -> Option<NodeId> {
|
||||
if sel.is_empty() {
|
||||
return None;
|
||||
}
|
||||
doc.ancestors(id).into_iter().rev().find(|a| sel.contains(a)).or_else(|| sel.contains(&id).then_some(id))
|
||||
}
|
||||
|
||||
fn hierarchy_bg(roots: &[Option<NodeId>], i: usize, mut rr: Rect, gap: f32) -> Option<(Rect, CornerRadius)> {
|
||||
let root = roots.get(i).copied().flatten()?;
|
||||
let joins = |neighbor: &Option<NodeId>| *neighbor == Some(root);
|
||||
let above = i.checked_sub(1).and_then(|j| roots.get(j)).is_some_and(joins);
|
||||
let below = roots.get(i + 1).is_some_and(joins);
|
||||
if below {
|
||||
rr.max.y += gap;
|
||||
}
|
||||
let top = if above { 0 } else { 5 };
|
||||
let bottom = if below { 0 } else { 5 };
|
||||
Some((rr, CornerRadius { nw: top, ne: top, sw: bottom, se: bottom }))
|
||||
}
|
||||
|
||||
/// A row's background: selected, inside a selected layer, or outlined when hovered.
|
||||
/// `state` is [selected, in_selected, component].
|
||||
fn paint_row_bg(ui: &Ui, t: &Tokens, rr: Rect, state: [bool; 3], hovered: bool) {
|
||||
let [selected, in_selected, is_comp] = state;
|
||||
if selected {
|
||||
ui.painter().rect_filled(rr, CornerRadius::same(5), if is_comp { t.component_row } else { t.selected_row });
|
||||
} else if in_selected {
|
||||
ui.painter().rect_filled(rr, CornerRadius::ZERO, t.selected_row_child);
|
||||
} else if hovered {
|
||||
} else if !in_selected && hovered {
|
||||
ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(1.0, if is_comp { COMPONENT } else { ACCENT }), egui::StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
@@ -1071,15 +1090,20 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
drag_autoscroll(ui, dragging.is_some());
|
||||
let first = ((viewport.min.y / pitch).floor().max(0.0) as usize).min(rows.len());
|
||||
let last = ((viewport.max.y / pitch).ceil().max(0.0) as usize).saturating_add(1).min(rows.len());
|
||||
// Compute each visible row's root once, plus one neighbor outside each end
|
||||
// so scrolling never rounds a seam in the middle of a selected subtree.
|
||||
let selected_ids: BTreeSet<_> = sel.iter().copied().collect();
|
||||
let roots_start = first.saturating_sub(1);
|
||||
let roots: Vec<_> = rows.iter().take(last.saturating_add(1)).skip(roots_start).map(|r| selection_root(&doc, &selected_ids, r.id)).collect();
|
||||
let band = Rect::from_x_y_ranges(ui.max_rect().x_range(), (top + first as f32 * pitch)..=(top + last as f32 * pitch));
|
||||
let mut band_ui = ui.new_child(egui::UiBuilder::new().max_rect(band));
|
||||
// Stable widget ids for a row wherever the list is scrolled.
|
||||
band_ui.skip_ahead_auto_ids(first);
|
||||
let ui = &mut band_ui;
|
||||
for row in rows.iter().take(last).skip(first) {
|
||||
for (i, row) in rows.iter().enumerate().take(last).skip(first) {
|
||||
let Some(n) = doc.node(row.id) else { continue };
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), Sense::click_and_drag());
|
||||
let selected = sel.contains(&row.id);
|
||||
let selected = selected_ids.contains(&row.id);
|
||||
a11y::layer_row(&mut app.ui.nav, &resp);
|
||||
if sel.last() == Some(&row.id) {
|
||||
a11y::prefer(&mut app.ui.nav, Region::Layers, resp.id);
|
||||
@@ -1093,9 +1117,13 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
locked: n.locked,
|
||||
};
|
||||
a11y::tree_item(&resp, if n.name.is_empty() { tr(n.kind.label()) } else { &n.name }, &info);
|
||||
let in_selected = !selected && doc.ancestors(row.id).iter().any(|a| sel.contains(a));
|
||||
let is_comp = matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. });
|
||||
let rr = r.shrink2(vec2(8.0, 0.0));
|
||||
let bg = hierarchy_bg(&roots, i - roots_start, rr, pitch - ROW);
|
||||
let in_selected = !selected && bg.is_some();
|
||||
if let Some((rect, corners)) = bg {
|
||||
ui.painter().rect_filled(rect, corners, t.selected_row_child);
|
||||
}
|
||||
a11y::ring_around(ui, rr, &resp);
|
||||
// Over the row, its eye and padlock included (they sit on top of it).
|
||||
let over = resp.contains_pointer();
|
||||
|
||||
@@ -494,7 +494,7 @@ mod tests {
|
||||
let theme = if t.dark { "dark" } else { "light" };
|
||||
// Disabled text (a disabled button's label, placeholders) on panels, fields and
|
||||
// selected rows, still dimmer than secondary text.
|
||||
for bg in [t.panel, t.field, t.hover, t.selected_row] {
|
||||
for bg in [t.panel, t.field, t.hover, t.selected_row, t.selected_row_child] {
|
||||
let ratio = c(t.text_tertiary).contrast(c(bg));
|
||||
assert!(ratio >= 3.0, "{theme}: dim text {:?} on {bg:?} is {ratio:.2}:1", t.text_tertiary);
|
||||
assert!(ratio < c(t.text_secondary).contrast(c(bg)), "{theme}: dim text reads dimmer than secondary text");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Auto layout and constraints parity
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** patch (fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a child resized to the full available space of its auto layout frame becomes Fill container on that axis, as the Guide to auto layout says; a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** patch (baseline alignment puts a layer without text with its bottom on the line, as the help center's figure shows an icon next to text (#1825); fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a child resized to the full available space of its auto layout frame becomes Fill container on that axis, as the Guide to auto layout says; a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Auto layout is the most used layout feature in Figma and the one where small differences (a
|
||||
hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%,
|
||||
@@ -60,7 +60,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
|
||||
| An auto gap stops at 0 when children don't fit (no overlap) | **fixed** | went negative before |
|
||||
| Alignment 3×3; with an auto gap only the cross alignment; a hugging frame shows no difference | pass | `alignment_rules` |
|
||||
| Text baseline alignment, texts of different sizes | pass | `alignment_rules` |
|
||||
| Baseline alignment of a layer without text (an icon next to text) | silent | it keeps to the top of the line |
|
||||
| Baseline alignment of a layer without text (an icon next to text) | **fixed** | its bottom sits on the baseline, as the figure's caption says ("the bottoms of the icon and the word home are aligned on the red line"); it kept to the top of the line (#1825) |
|
||||
| Horizontal wrap: a new row when the width runs out; gap between rows | pass | `wrap_rules` |
|
||||
| Vertical wrap: a new column when the height runs out; not masonry; gap between columns | **added** | layout, Design panel wrap toggle, column gap field, on-canvas gap handles, drag reorder and arrow keys by column |
|
||||
| Wrapped rows/columns as a block: packed at the start, centre or end the cross alignment names; stretched when every child fills across the flow; or the free space between them (one line or no free space: like packed) | **added** | `content_align_tests` (13 cases), `al_content_align_tests`; `contentAlign`, the Row/Column gap mode in the Design panel, REST and `.fig` both ways, `align-content` in CSS |
|
||||
@@ -100,6 +100,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | patch | M2, #1825: baseline alignment of a layer without text (an icon next to a text) puts its bottom on the line's baseline, as the help center's figure shows ("the bottoms of the icon and the word home are aligned on the red line"); it kept to the top of the line. A layer reaching below the text's baseline moves the text down, and a line with no text lines the layers' bottoms up (the documentation is silent; CSS baseline alignment does the same). Tests: `alignment_rules` (three cases), engine `baseline_alignment_puts_an_icons_bottom_on_the_text_baseline`. Numbers unchanged. |
|
||||
| 2026-11-02 | patch | M2, #1824: fill container children share the free space by their content area: each one's own insets (padding and an included inside stroke, from the same `insets` the frame uses) come off the free space before the equal split and go back on its share, so a child with more padding or a thicker inside stroke takes a little more and the content areas match ("Figma distributes space amongst fill container children by the children's content area instead of by their size, matching the CSS border-box model", Use auto layout with CSS Flexbox in mind). Min and max still clamp the outer sizes and the frozen-share loop is as it was; children without insets split as before. Test: `nested_tests::fill_children_share_by_content_area` |
|
||||
| 2026-11-02 | patch | M2, #1838: a double-click on an edge of an auto layout frame's bounding box sets Hug contents on that axis, Alt+double-click on an edge of a child's Fill container ("Double-click the vertical or horizontal edge of an auto layout object's bounding box to set it to Hug contents", "Hold ⌥ Option or Alt and double-click ... to set it to Fill container", Guide to auto layout); a vertical edge sizes the width, a horizontal one the height; the Keyboard shortcuts panel lists both gestures. Test: `double_click_tests::a_double_click_on_an_auto_layout_edge_hugs_and_with_alt_fills`. Numbers unchanged. |
|
||||
| 2026-11-02 | patch | M2: a child of an auto layout frame resized to the full space available to it on an axis (the frame's size less its padding: across the flow for any child, along the flow for a lone child) becomes Fill container on that axis, whether by `transform.resize`, the W/H fields or a canvas handle ("Child objects of an auto layout frame will also be set to Fill container if they are manually resized to the full available space of the parent frame", Guide to auto layout). Any other size keeps the axis Fixed as before; beside siblings along the flow, in a hugging or wrapping frame, in a grid and for a layer that ignores the layout nothing changes. Test: `al_behaviour_tests::a_child_resized_to_the_full_available_space_becomes_fill` |
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; 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 valueLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; 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 wiLine truncated
|
||||
|
||||
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:
|
||||
@@ -39,7 +39,7 @@ interaction feel not yet compared.
|
||||
|
||||
| Item | Status | Notes |
|
||||
|---|---|---|
|
||||
| Rows: click, Shift-click range, Cmd/Ctrl-click toggle; the selection follows on the canvas and a canvas selection is revealed | done | `layers_behaviour_tests` |
|
||||
| Rows: click, Shift-click range, Cmd/Ctrl-click toggle; the selection follows on the canvas and a canvas selection is revealed | done | `layers_behaviour_tests`; selected hierarchies share a continuous background with rounded outer corners, including nested selections |
|
||||
| Expand and collapse: Alt-click opens or closes everything inside; Collapse layers (Alt+L) keeps the way to the selection open | done | Alt-click pinned (the documentation is silent) |
|
||||
| Drag to reorder and into frames, groups and auto layout: insertion line, spring-loaded open, several layers together, Escape cancels | done | several layers dropped together land together (they split apart before); instances and a layer's own children refuse drops |
|
||||
| Eye and padlock: click, or drag across rows to set several in one step; layers in a locked frame show a faint padlock | done | the toggles no longer vanish under the pointer (clicks on them could miss) |
|
||||
@@ -180,6 +180,8 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged |
|
||||
| 2026-11-02 | minor | M8: layer hierarchy backgrounds compute each visible row's selection root once, plus its immediate offscreen neighbors, rather than walking ancestors three times per row. Selection membership uses a set instead of a linear search. The scrolling regression keeps the internal seams square at viewport boundaries. Numbers unchanged. |
|
||||
| 2026-11-02 | minor | M8: selected layer hierarchies have a continuous background across row spacing, with rounded outer corners; nested selections stay in the same block and separate selected trees keep their own corners. Regressions cover continuous fills, the selected-row highlight above them, scrolling and the original palette's dim-text contrast. Numbers unchanged. |
|
||||
| 2026-11-06 | patch | M7, #2032: closing a tab whose file has unsaved changes, by its X, a middle click, File ▸ Close tab or its shortcut, only showed a toast ("save first, or close again with Alt to discard") and left the tab open, so closing seemed not to work. It now asks the unsaved-changes question the window asks (`windows::TabClose`): Don't Save closes the tab, Cancel or Escape keeps it, Save saves the file and then closes it (a file without a path, or saved as .fig, asks where with Save as first and stays open if that is cancelled); Alt+close still drops the changes at once, and `file.close`'s `dialog.unsaved` request asks too. The three toasts left the catalogs. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Reordering tabs by dragging, the issue's other half, landed on 2026-10-19 (`dragging_a_tab_along_the_bar_reorders_it_without_detaching`) |
|
||||
| 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. |
|
||||
| 2026-11-05 | minor | M4, #1830: a paragraph's base direction can be forced (`TextDirection`: Auto, Ltr, Rtl on `ParaList`): Text ▸ Text direction ▸ Auto / Left to right / Right to left (`text.directionAuto` / `Ltr` / `Rtl`) and `text.direction {direction, ids?}` set the edited paragraphs while editing, else the layer's; `node.set textDirection`; the Type settings control once a right-to-left script is in the text; inspect `textDirection` / `lineDirections`; CSS `direction`; `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` runs the algorithm per paragraph, as it starts over at each). Before, direction came from the letters only ("controls are available to override text direction per paragraph", Add right-to-left text). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests::a_lines_source_directionality_is_its_paragraphs_direction_both_ways`. |
|
||||
|
||||
Reference in new issue
Block a user