Merge main into #2049

This commit is contained in:
bflatastic
2026-11-07 09:10:44 +07:00
52 changed files with 1288 additions and 83 deletions
+2
View File
@@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **2026-11-05** · M4, #1830: a paragraph's base direction can be forced (Text ▸ Text direction: Auto, Left to right, Right to left) as Add right-to-left text says ("controls are available to override text direction per paragraph"). `ParaList::direction`, `text.direction {direction, ids?}` and the three menu commands (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, `textDirection` / `lineDirections` in inspect, the CSS `direction`, and the `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` per paragraph). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests`.
- **2026-11-05** · M1, #2003: the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code from rectangles and polygons (two offset squares, the result filled at reduced alpha); each entry still runs its command.
- **2026-10-30**: M8, #1125: damaged `trak` size-table offsets are checked instead of wrapping on 32-bit targets, and an incomplete normal track is dropped entirely, never returned in part. Font indexing makes its existing bounds explicit, preserving later faces and sparse name-table reads. Synthetic regression tests in `fontdb_tests.rs`. Numbers unchanged.
- **2026-10-30** · Text style dedupe is O(spans) (M8, #1102): `shape_text` deduped spans into styles by a scan over the styles already collected — O(spans × styles), so a styled text's dedupe grew with the square of its runs (a synthetic 50k-run text with 100 alternating styles spent 7.7 ms in it). Now the last two styles again (spans run in blocks), then buckets keyed by a cheap fingerprint (family and style bytes, size bits with −0.0 folded, the enums, fill count and solid colours), each bucket resolved with the full equality: the result is the scan's, never worse than it, and O(spans) when names and sizes differ (the same synthetic text: 2.6 ms). The font-db write lock is untouched: `find()` loads faces, which needs the write. `layout_dedupe_tests`. Numbers unchanged.
@@ -360,6 +361,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | patch | M5, #2027: `proto.settings` refuses an unknown device name with the list of devices (it saved any string, and the presenter showed no device), takes known names in any case, and answers the page's prototype settings, so `proto.settings {}` reads them back over MCP. Test: `proto_present_tests::unknown_devices_are_refused_and_settings_read_back`. Numbers unchanged |
| 2026-11-06 | patch | M8, #1906: under Wayland a minimised window answers control-channel and attached-MCP requests from its logic passes once it has drawn nothing for 0.2 s while a request waits (about half a second each; every request timed out after 60 s before). Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes`. Numbers unchanged |
| 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Numbers unchanged |
| 2026-11-06 | patch | M3: a layer's effect binding (`variable.bind`, `effects/<i>/<field>`) takes only a field its effect has, as an effect style's does: `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have, is refused instead of being kept and doing nothing (a binding an older file has there still does nothing, and unbinds). Tests: `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`. Numbers unchanged |
| 2026-11-06 | patch | M9, #2026: a branch merge counts one conflict per auto layout field changed both ways, not one for every size and place auto layout works out from it; values an instance takes from its main no longer conflict, and auto layout settings changed one way survive a conflict on another field. Tests: `branch_tests::one_conflicting_auto_layout_edit_is_one_conflict`, `branch_tests::auto_layout_fields_changed_one_way_survive_a_conflict`, `branch_tests::sizes_auto_layout_works_out_never_conflict`. Numbers unchanged |
+3
View File
@@ -737,6 +737,9 @@ impl<B: Backend> Hub<B> {
continue;
}
let Some(&conn) = room.ids.get(&id) else { continue };
if matches!(msg, ServerMsg::Presence { .. }) && self.conns.get(&conn).is_some_and(|c| !c.out.takes_presence()) {
continue;
}
let Some(b) = bytes.get_or_insert_with(|| encode(&msg)).clone() else { continue };
if !deliver(&self.conns, conn, &b, &mut held, &mut stuck) {
stuck.push(conn);
@@ -74,6 +74,8 @@ fn a_kicked_connection_with_a_full_queue_is_shut() {
let out = Line {
tx,
queued: Arc::default(),
waiting: Arc::default(),
max_presence: 32,
block: Arc::new(AtomicUsize::new(0)),
all: Arc::new(AtomicUsize::new(0)),
max: 1 << 20,
+2
View File
@@ -235,6 +235,8 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
let out = Line {
tx,
queued: Arc::default(),
waiting: Arc::default(),
max_presence: limits.queue.max(4) / 2,
block: block.queued.clone(),
all: shared.queued.clone(),
max: limits.max_queued_bytes,
+17 -1
View File
@@ -48,6 +48,7 @@ pub(crate) struct Charges {
pub(super) struct Charge {
n: usize,
mine: Arc<AtomicUsize>,
waiting: Arc<AtomicUsize>,
_all: Arc<Held>,
_block: Option<Arc<Held>>,
}
@@ -55,6 +56,7 @@ pub(super) struct Charge {
impl Drop for Charge {
fn drop(&mut self) {
self.mine.fetch_sub(self.n, Ordering::Relaxed);
self.waiting.fetch_sub(1, Ordering::Relaxed);
}
}
@@ -76,6 +78,10 @@ pub(crate) enum Push {
pub(crate) struct Line {
pub(crate) tx: SyncSender<Out>,
pub(crate) queued: Arc<AtomicUsize>,
/// Messages waiting in the queue.
pub(crate) waiting: Arc<AtomicUsize>,
/// With this many waiting, presence is left out (see [`Line::takes_presence`]).
pub(crate) max_presence: usize,
pub(crate) block: Arc<AtomicUsize>,
pub(crate) all: Arc<AtomicUsize>,
pub(crate) max: usize,
@@ -84,6 +90,15 @@ pub(crate) struct Line {
}
impl Line {
/// Whether presence goes in the queue now. A client still taking in a long message (the welcome
/// of a large file on a slow link) would otherwise have its queue filled by everyone's cursors
/// and be dropped, again on every try. Presence is latest-wins, so what's left out is made up
/// for by the next update, and this keeps room for what can't be: other people's edits. A client
/// that reads nothing at all still ends at the write timeout.
pub(crate) fn takes_presence(&self) -> bool {
self.waiting.load(Ordering::Relaxed) < self.max_presence
}
/// Queue `b`. One message always fits an empty queue (a welcome as large as the largest file
/// gets out). `charges` is what the message counts against, shared by its recipients;
/// `over_budget` lets it past the server's budget (the hub just dropped the biggest consumer
@@ -118,7 +133,8 @@ impl Line {
}
};
self.queued.fetch_add(n, Ordering::Relaxed);
let charge = Charge { n, mine: self.queued.clone(), _all: all, _block: block };
self.waiting.fetch_add(1, Ordering::Relaxed);
let charge = Charge { n, mine: self.queued.clone(), waiting: self.waiting.clone(), _all: all, _block: block };
self.tx.try_send(Out::Bytes(b.clone(), Some(charge))).map_err(|e| match e {
TrySendError::Full(_) => Push::Full,
TrySendError::Disconnected(_) => Push::Gone,
+38 -2
View File
@@ -1,7 +1,8 @@
//! The hub's send queues (no writers: nothing is ever sent, so what is queued stays queued): a
//! broadcast counts once against the server's budget however many copies are queued, a block of
//! addresses can't take more than its share, and when the server's budget is used up the
//! connection holding the most is shed, not the next client that needs a little.
//! connection holding the most is shed, not the next client that needs a little. Presence stays out
//! of a busy queue.
use std::collections::HashMap;
use std::net::{IpAddr, TcpListener, TcpStream};
@@ -16,7 +17,20 @@ use super::{BlockUse, Blocks, Charges, Conn, Kind, Line, Out, Push, deliver};
/// A line with its own queue counter, the given block counter and the server's.
fn line(block: &Arc<AtomicUsize>, all: &Arc<AtomicUsize>, max_block: usize, max_all: usize) -> (Line, Receiver<Out>) {
let (tx, rx) = sync_channel(64);
(Line { tx, queued: Arc::default(), block: block.clone(), all: all.clone(), max: 1 << 20, max_block, max_all }, rx)
(
Line {
tx,
queued: Arc::default(),
waiting: Arc::default(),
max_presence: 32,
block: block.clone(),
all: all.clone(),
max: 1 << 20,
max_block,
max_all,
},
rx,
)
}
fn socket() -> TcpStream {
@@ -120,3 +134,25 @@ fn blocks_nobody_uses_are_forgotten() {
let _c = blocks.get("203.0.113.3".parse().unwrap());
assert_eq!(blocks.len(), 2, "203.0.113.1 went");
}
#[test]
fn a_busy_queue_leaves_presence_out_and_keeps_room_for_edits() {
let (l, rx) = line(&Arc::default(), &Arc::default(), usize::MAX, usize::MAX);
let msg = Bytes::from_static(b"x");
for _ in 0..32 {
assert!(l.takes_presence());
l.push(&msg, &mut Charges::default(), false).unwrap();
}
assert!(!l.takes_presence(), "half full");
// Edits still fit in the half kept for them.
for _ in 0..32 {
l.push(&msg, &mut Charges::default(), false).unwrap();
}
assert_eq!(l.push(&msg, &mut Charges::default(), false), Err(Push::Full), "a client that takes in nothing still ends");
// It catches up: presence goes in again.
for _ in 0..33 {
drop(rx.recv().unwrap());
}
assert!(l.takes_presence());
assert_eq!(l.waiting.load(Ordering::Relaxed), 31);
}
+23 -4
View File
@@ -14,6 +14,8 @@
//! space 0. The session server accepts a new object only with an id in its creator's space or
//! space 0, so two people never make the same id.
use std::collections::BTreeSet;
use crate::{CollectionId, Document, ModeId, NodeId, StyleId, VariableId};
/// What style ids start with.
@@ -74,25 +76,42 @@ impl Document {
/// A new style id.
pub fn new_style_id(&mut self) -> StyleId {
let taken: Vec<u64> = self.styles.iter().filter_map(|s| id_number(&s.id, STYLE_ID_PREFIX)).collect();
self.new_style_id_avoiding(&BTreeSet::new())
}
/// A new style id that is none of `avoid` either: ids of styles about to come in with a file
/// being imported.
pub fn new_style_id_avoiding(&mut self, avoid: &BTreeSet<StyleId>) -> StyleId {
let taken: Vec<u64> = self.styles.iter().map(|s| &s.id).chain(avoid).filter_map(|id| id_number(id, STYLE_ID_PREFIX)).collect();
if let Some(n) = self.session_number(taken) {
return format!("{STYLE_ID_PREFIX}{n}");
}
(self.styles.len() + 1..)
.map(|k| format!("{STYLE_ID_PREFIX}{k}"))
.find(|id| self.style(id).is_none())
.find(|id| self.style(id).is_none() && !avoid.contains(id))
.unwrap_or_else(|| format!("{STYLE_ID_PREFIX}0"))
}
/// A new variable id.
pub fn new_variable_id(&mut self) -> VariableId {
let taken: Vec<u64> = self.variables.variables.iter().filter_map(|v| id_number(&v.id, VARIABLE_ID_PREFIX)).collect();
self.new_variable_id_avoiding(&BTreeSet::new())
}
/// A new variable id that is none of `avoid` either (see [`Self::new_style_id_avoiding`]).
pub fn new_variable_id_avoiding(&mut self, avoid: &BTreeSet<VariableId>) -> VariableId {
let taken = self.variables.variables.iter().map(|v| &v.id).chain(avoid).filter_map(|id| id_number(id, VARIABLE_ID_PREFIX)).collect();
self.new_numbered_id(VARIABLE_ID_PREFIX, taken)
}
/// A new variable collection id.
pub fn new_collection_id(&mut self) -> CollectionId {
let taken: Vec<u64> = self.variables.collections.iter().filter_map(|c| id_number(&c.id, COLLECTION_ID_PREFIX)).collect();
self.new_collection_id_avoiding(&BTreeSet::new())
}
/// A new variable collection id that is none of `avoid` either (see
/// [`Self::new_style_id_avoiding`]).
pub fn new_collection_id_avoiding(&mut self, avoid: &BTreeSet<CollectionId>) -> CollectionId {
let taken = self.variables.collections.iter().map(|c| &c.id).chain(avoid).filter_map(|id| id_number(id, COLLECTION_ID_PREFIX)).collect();
self.new_numbered_id(COLLECTION_ID_PREFIX, taken)
}
+16
View File
@@ -1,6 +1,8 @@
//! Ids of styles, variables, collections, modes and comments: unchanged outside a live session,
//! from the client's own space during one.
use std::collections::BTreeSet;
use crate::ids::{COLLECTION_ID_PREFIX, id_number, id_space, mode_number, space_of};
use crate::{Collection, Comment, Document, Message, Mode, NodeId, NodeKind, Style, StyleKind};
@@ -93,6 +95,20 @@ fn ids_of_the_space_already_in_the_document_stay_taken() {
assert_eq!(e.new_style_id(), format!("S:{}", C3 | 1));
}
#[test]
fn new_ids_can_avoid_ids_about_to_come_in() {
let mut d = Document::new("a");
d.styles.push(style("S:1"));
let avoid: BTreeSet<String> = ["S:2", "S:3", "VariableID:4", "VariableCollectionId:2", "lib:VariableID:9"].map(String::from).into();
assert_eq!(d.new_style_id_avoiding(&avoid), "S:4");
assert_eq!(d.new_variable_id_avoiding(&avoid), "VariableID:5");
assert_eq!(d.new_collection_id_avoiding(&avoid), "VariableCollectionId:3");
// In a session the numbers come from the client's space, above those of it to avoid.
let avoid: BTreeSet<String> = [format!("VariableID:{}", C3 | 7)].into();
d.set_client_id(3);
assert_eq!(d.new_variable_id_avoiding(&avoid), format!("VariableID:{}", C3 | 8));
}
#[test]
fn the_highest_space_counts_every_kind_of_id() {
let mut d = Document::new("a");
+55
View File
@@ -681,6 +681,61 @@ fn two_segment_closed_loop_rounded_corners() {
assert!(!path.elements().is_empty());
}
/// A rectangle whose top-right corner was cut off in the vector editor (a diagonal between a point
/// on the top edge and one on the right edge), then rounded: the fill, which the fill and the
/// selection outline draw, is cut by an arc at every point just as the stroke is, however the loop
/// lists its segments and whichever way each one runs (#1951, #1506).
#[test]
fn a_chamfered_rectangles_rounded_fill_is_cut_at_every_point() {
use figcraft_geom::BezPath;
use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
let corners = [Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 50.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)];
let keeps_off = |path: &BezPath, p: Point| path.segments().map(|s| s.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min);
let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
for first in 0..corners.len() {
for backwards in [false, true] {
for flipped in 0..(1u32 << corners.len()) {
let mut net = VectorNetwork::from_points(&corners, true);
for v in &mut net.vertices {
v.radius = 43.0;
}
for (i, s) in net.segments.iter_mut().enumerate() {
if flipped & (1 << i) != 0 {
std::mem::swap(&mut s.a, &mut s.b);
std::mem::swap(&mut s.ta, &mut s.tb);
}
}
let lp = &mut net.regions[0].loops[0];
lp.rotate_left(first);
if backwards {
lp.reverse();
}
let why = format!("loop from segment {first}{}, segments flipped {flipped:05b}", if backwards { " backwards" } else { "" });
let stroke = net.stroke_path();
let fill = net.fill_path();
let region = net.rounded().unwrap().region_path(0).unwrap();
for (i, p) in corners.iter().enumerate() {
assert!(keeps_off(&stroke, *p) > 2.0, "{why}: the stroke is cut at point {i} ({:.2} from it)", keeps_off(&stroke, *p));
assert!(keeps_off(&fill, *p) > 2.0, "{why}: the fill runs into point {i} ({:.2} from it)", keeps_off(&fill, *p));
assert!(keeps_off(&region, *p) > 2.0, "{why}: the region runs into point {i} ({:.2} from it)", keeps_off(&region, *p));
}
assert!(
(length(&fill) - length(&stroke)).abs() < 1e-6,
"{why}: fill {} and stroke {} trace the same outline",
length(&fill),
length(&stroke)
);
assert!(
(length(&region) - length(&stroke)).abs() < 1e-6,
"{why}: region {} and stroke {} trace the same outline",
length(&region),
length(&stroke)
);
}
}
}
}
#[test]
fn stored_variable_ids_resolve_only_their_exact_collection() {
let mut vars = crate::proto_expr_tests::vars();
+23 -6
View File
@@ -259,10 +259,24 @@ pub enum ListStyle {
Numbered,
}
/// A paragraph's base direction: by its letters, or forced ("controls are available to override
/// text direction per paragraph", Add right-to-left text).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum TextDirection {
/// The first letter with a direction decides (Unicode rules P2 and P3).
#[default]
Auto,
/// Left to right, whatever the letters.
Ltr,
/// Right to left, whatever the letters.
Rtl,
}
/// Deepest list nesting (levels 0..=MAX_LIST_INDENT).
pub const MAX_LIST_INDENT: u8 = 8;
/// One paragraph's list settings.
/// One paragraph's list settings and base direction.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ParaList {
@@ -276,6 +290,9 @@ pub struct ParaList {
/// (items that go on counting ignore it; other paragraphs are always 0).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub start_offset: u32,
/// The paragraph's base direction (Text ▸ Text direction); `Auto` follows its letters.
#[serde(skip_serializing_if = "crate::node::is_default")]
pub direction: TextDirection,
}
/// Character style. In runs, every field is an override; `None` means "inherit the base".
@@ -991,20 +1008,20 @@ mod case_tests {
// Files written before start numbers load with none, and write the same bytes.
let old = r#"{"list":"numbered","indent":1}"#;
let pl: ParaList = serde_json::from_str(old).unwrap();
assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 });
assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0, ..ParaList::default() });
assert_eq!(serde_json::to_string(&pl).unwrap(), old);
// One numbered paragraph with a start keeps it (it doesn't fold into `list`).
let mut t = TextData { characters: "Next".into(), ..Default::default() };
t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 });
assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 });
t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1, ..ParaList::default() });
assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1, ..ParaList::default() });
// Enter copies it to the new item; different starts don't make the list mixed.
t.replace_range(4, 4, "\nMore");
assert_eq!(t.para_list(1).start_offset, 1);
t.set_para_lists(5, 5, |p| p.start_offset = 0);
assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N));
// Bullets have no number, so no start.
t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 });
assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 });
t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4, ..ParaList::default() });
assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0, ..ParaList::default() });
// A plain paragraph has none.
t.set_para_lists(0, 0, |p| p.list = P);
assert_eq!(t.para_list(0), ParaList::default());
+12
View File
@@ -53,6 +53,7 @@ pub const NODE_KEYS: &[&str] = &[
"layout",
"leadingTrim",
"letterSpacing",
"lineDirections",
"lineHeight",
"lineIndentations",
"lineTypes",
@@ -96,6 +97,7 @@ pub const NODE_KEYS: &[&str] = &[
"textDecorationSkipInk",
"textDecorationStyle",
"textDecorationThickness",
"textDirection",
"textPath",
"textStyleRanges",
"type",
@@ -432,6 +434,16 @@ fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>, options: &In
insert_prop(o, options, "listStartOffsets", || t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
}
}
// The paragraphs' base direction: one for the layer when they agree, else each one's (Auto
// throughout says nothing).
let dirs: Vec<figcraft_doc::TextDirection> = t.paragraphs.iter().map(|p| p.direction).collect();
if let Some(first) = dirs.first().copied().filter(|d| *d != figcraft_doc::TextDirection::Auto)
&& dirs.iter().all(|d| *d == first)
{
insert_prop(o, options, "textDirection", || json!(first));
} else if dirs.iter().any(|d| *d != figcraft_doc::TextDirection::Auto) {
insert_prop(o, options, "lineDirections", || dirs.iter().map(|d| json!(d)).collect());
}
if t.list_spacing != 0.0 {
insert_prop(o, options, "listSpacing", || json!(t.list_spacing));
}
+65 -2
View File
@@ -658,8 +658,28 @@ pub fn connect(net: &mut VectorNetwork, a: usize, b: usize, ta: (f64, f64), tb:
net.segments.push(Segment { a, b, ta: finite_t(ta), tb: finite_t(tb) });
let si = net.segments.len() - 1;
if ends && let Some(mut lp) = path_between(net, b, a, si) {
let unfilled = !net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
if unfilled {
let filled = net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
if filled {
// A loop broken open at these ends (a point of it deleted, say) closes through the new
// segment, so the fill follows it instead of a straight line across the gap (#1951). The
// segment goes in at the gap, in the loop's own order, so the loop keeps its direction: a
// hole wound against its outline stays a hole whichever end was clicked first. Only a
// loop the segment makes one continuous chain is closed; one listed out of walking order
// is left as it was.
let segs = &net.segments;
let shares = |x: usize, y: usize| segs.get(x).zip(segs.get(y)).is_some_and(|(p, q)| p.a == q.a || p.a == q.b || p.b == q.a || p.b == q.b);
for l in net.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
if l.len() == lp.len() && l.iter().all(|s| lp.contains(s)) {
let gap = (1..l.len()).find(|&k| l.get(k - 1).zip(l.get(k)).is_some_and(|(&x, &y)| !shares(x, y))).unwrap_or(l.len());
let mut closed = l.clone();
closed.insert(gap, si);
let n = closed.len();
if (0..n).all(|k| closed.get(k).zip(closed.get((k + 1) % n)).is_some_and(|(&x, &y)| shares(x, y))) {
*l = closed;
}
}
}
} else {
lp.insert(0, si);
net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero, fills: None, ..Default::default() });
}
@@ -997,6 +1017,49 @@ mod tests {
assert!(connect(&mut n, 1, 99, (0.0, 0.0), (0.0, 0.0)).is_none());
}
#[test]
fn connecting_the_ends_of_a_hole_keeps_its_winding() {
// A ring: the hole's loop walks the other way round from the outline's, so the non-zero
// rule leaves it empty.
let mut n = square();
for (x, y) in [(25.0, 25.0), (75.0, 25.0), (75.0, 75.0), (25.0, 75.0)] {
n.vertices.push(Vertex::new(x, y));
}
for (a, b) in [(4, 5), (5, 6), (6, 7), (7, 4)] {
n.segments.push(Segment::line(a, b));
}
n.regions[0].loops.push(vec![7, 6, 5, 4]);
let (hole, ring, cut) = (Point::new(40.0, 60.0), Point::new(10.0, 50.0), Point::new(70.0, 30.0));
assert!(!n.fill_contains(hole) && n.fill_contains(ring) && !n.fill_contains(cut), "a ring to start with");
// Delete a corner of the hole (the path opens; its fill closes across the gap, so the cut-off
// corner is filled), then connect the two open ends again, either end first: the hole is a
// triangle, still empty.
let at = |m: &VectorNetwork, (x, y): (f64, f64)| m.vertices.iter().position(|v| v.x == x && v.y == y).unwrap();
for (first, second) in [((25.0, 25.0), (75.0, 75.0)), ((75.0, 75.0), (25.0, 25.0))] {
let mut m = n.clone();
delete_vertices(&mut m, &[5], false);
assert!(!m.fill_contains(hole) && m.fill_contains(cut), "opened: {:?}", m.regions);
let (a, b) = (at(&m, first), at(&m, second));
connect(&mut m, a, b, (0.0, 0.0), (0.0, 0.0)).unwrap();
let loops = &m.regions[0].loops;
assert_eq!((m.regions.len(), loops.len(), loops[1].len()), (1, 2, 3), "{first:?} first: {:?}", m.regions);
assert!(!m.fill_contains(hole), "{first:?} first: the hole is filled, {loops:?}");
assert!(m.fill_contains(ring) && m.fill_contains(cut), "{first:?} first: the ring is filled");
}
}
#[test]
fn connecting_leaves_a_loop_out_of_walking_order_alone() {
// The open chain A-B-C-D-E with its loop listed out of walking order: no place for the new
// segment makes it one continuous chain, so the loop is left as it was, and no region is added.
let pts = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0), Point::new(-50.0, 50.0)];
let mut n = VectorNetwork::from_points(&pts, false);
n.regions.push(Region { loops: vec![vec![0, 2, 1, 3]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
let s = connect(&mut n, 0, 4, (0.0, 0.0), (0.0, 0.0)).unwrap();
assert_eq!(n.regions.len(), 1);
assert_eq!(n.regions[0].loops, vec![vec![0, 2, 1, 3]], "left as it was, without segment {s}");
}
#[test]
fn join_merges_overlapping_points_and_connects_ends() {
let mut n = open_line();
@@ -597,3 +597,85 @@ fn the_pen_shows_its_ghost_anchor_on_the_segment_under_it() {
assert_eq!(net(&s, id).vertices.len(), 5, "the press added the point");
assert_eq!(ve(&s).anchor, None, "the click ended the pen's path");
}
/// A rectangle's corner cut off in the editor (a point added on each edge next to it, the corner
/// deleted with Shift+Delete so the two join) and the shape rounded: the fill, which the fill and
/// the selection outline draw, traces the stroke's outline, cut at both ends of the diagonal (#1951).
#[test]
fn a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs() {
for top_first in [true, false] {
let mut s = Session::new();
let id = rect(&mut s);
// Splitting keeps the split edge's index, so the other edge's stays: top 0, right 1.
let (first, second) = if top_first { (0, 1) } else { (1, 0) };
ex(&mut s, "vector.edit.addVertex", json!({"segment": first, "t": 0.5}));
ex(&mut s, "vector.edit.addVertex", json!({"segment": second, "t": 0.5}));
let corner = vertex_at(&net(&s, id), 100.0, 0.0);
ex(&mut s, "vector.edit.select", json!({"vertices": [corner]}));
ex(&mut s, "vector.deleteHeal", json!({}));
let n = net(&s, id);
assert_eq!((n.vertices.len(), n.segments.len(), n.regions.len()), (5, 5, 1), "top first {top_first}: a pentagon");
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
fill_traces_stroke(&s, id, &format!("top first {top_first}"));
// A region with a fill of its own is drawn through the region path and the planar normaliser.
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind {
network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
}
fill_traces_stroke(&s, id, &format!("top first {top_first}, region fill"));
}
}
/// The chamfer made other ways: the corner deleted plainly, which opens the path between the two
/// new points, and the two connected again; or the pentagon drawn with the pen (#1951).
#[test]
fn a_rounded_chamfer_made_other_ways_fills_along_its_arcs() {
let mut s = Session::new();
let id = rect(&mut s);
ex(&mut s, "vector.edit.addVertex", json!({"segment": 0, "t": 0.5}));
ex(&mut s, "vector.edit.addVertex", json!({"segment": 1, "t": 0.5}));
let corner = vertex_at(&net(&s, id), 100.0, 0.0);
ex(&mut s, "vector.edit.select", json!({"vertices": [corner]}));
ex(&mut s, "vector.edit.delete", json!({}));
let n = net(&s, id);
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (4, 4), "the path opened: {:?}", n.regions);
let (top, right) = (vertex_at(&n, 50.0, 0.0), vertex_at(&n, 100.0, 50.0));
ex(&mut s, "vector.edit.connect", json!({"a": top, "b": right}));
assert_eq!(net(&s, id).segments.len(), 5, "connected again");
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
fill_traces_stroke(&s, id, "deleted and connected");
// Drawn with the pen: continue from the end of an open two-point path and close on its start.
let mut s = Session::new();
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [50, 0]], "closed": false}));
let id = crate::params::id_of(&r["id"]).unwrap();
ex(&mut s, "vector.edit.begin", json!({"id": id}));
ex(&mut s, "vector.edit.tool", json!({"tool": "pen"}));
click(&mut s, 50.0, 0.0);
for (x, y) in [(100.0, 50.0), (100.0, 100.0), (0.0, 100.0), (0.0, 0.0)] {
click(&mut s, x, y);
}
let n = net(&s, id);
assert_eq!((n.vertices.len(), n.segments.len(), n.regions.len()), (5, 5, 1), "closed by the pen");
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
fill_traces_stroke(&s, id, "drawn with the pen");
}
/// The rounded chamfered rectangle `id` (five points on the origin's 100×100 box, the top-right
/// corner cut off between (50, 0) and (100, 50)): one region with every segment in its loop, and a
/// fill that keeps off every sharp point and traces the same outline as the stroke.
fn fill_traces_stroke(s: &Session, id: NodeId, why: &str) {
use figcraft_geom::BezPath;
use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
let n = net(s, id);
assert_eq!(n.regions.len(), 1, "{why}: one region, {:?}", n.regions);
assert_eq!(n.regions[0].loops[0].len(), n.segments.len(), "{why}: every segment in the loop, {:?}", n.regions[0].loops);
let doc = s.doc().unwrap();
let (fill, _) = figcraft_render::node_outline(doc, doc.node(id).unwrap()).unwrap();
let stroke = n.stroke_path();
let keeps_off = |path: &BezPath, p: Point| path.segments().map(|s| s.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min);
let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
for p in [pt(50.0, 0.0), pt(100.0, 50.0), pt(0.0, 0.0), pt(100.0, 100.0), pt(0.0, 100.0)] {
assert!(keeps_off(&stroke, p) > 2.0, "{why}: the stroke is cut at {p:?} ({:.2} from it)", keeps_off(&stroke, p));
assert!(keeps_off(&fill, p) > 2.0, "{why}: the fill runs into {p:?} ({:.2} from it)", keeps_off(&fill, p));
}
assert!((length(&fill) - length(&stroke)).abs() < 1e-3, "{why}: fill {} and stroke {} trace the same outline", length(&fill), length(&stroke));
}
+6 -1
View File
@@ -216,6 +216,11 @@ pub static MENU: &[MenuItem] = &[
m!("Text/Case", "Uppercase", "text.caseUpper"),
m!("Text/Case", "Lowercase", "text.caseLower"),
m!("Text/Case", "Title case", "text.caseTitle"),
// "Go to Text > Text direction. Select Left to right or Right to left." (Add right-to-left
// text); Auto, the way back to detection, is FigCraft's own (in `OWN`).
m!("Text/Text direction", "Auto", "text.directionAuto"),
m!("Text/Text direction", "Left to right", "text.directionLtr"),
m!("Text/Text direction", "Right to left", "text.directionRtl"),
m!("Text", SEP, ""),
m!("Text", "Missing fonts…", "text.missingFonts"),
// Arrange
@@ -292,7 +297,7 @@ pub fn items() -> impl Iterator<Item = &'static MenuItem> {
}
/// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones).
const OWN: [&str; 5] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts", "image.crop"];
const OWN: [&str; 6] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts", "image.crop", "text.directionAuto"];
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
+1
View File
@@ -15,6 +15,7 @@ use crate::params::{bool_of, boolean, get, id, num, num_or, req_index, req_strin
use crate::{DocState, EngineError, Result, Session, bad, spec};
mod export;
mod import_ids;
mod load;
mod open;
mod save;
+254
View File
@@ -0,0 +1,254 @@
//! Ids a file being imported brings that the document already uses for something else.
//!
//! Style, variable and collection ids are numbered per file (`S:1`, `VariableID:1`,
//! `VariableCollectionId:1`; see [`figcraft_doc::ids`]), so two files made apart use the same ones
//! for unrelated things. Kept as they were, the imported layers would show the document's
//! variables and styles instead of their own.
use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::ids::MODE_ID_INFIX;
use figcraft_doc::proto::{Action, Expr, MAX_EXPR_DEPTH, walk_mut};
use figcraft_doc::{Document, ModeId, NodeId, NodeKind, VariableValue, Variables};
/// A style, variable or collection of the file being imported.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum Asset<'a> {
Style(&'a str),
Variable(&'a str),
Collection(&'a str),
}
/// New ids for styles, variables and collections of a file being imported (its id → the new one).
#[derive(Debug, Default)]
pub(super) struct Renames {
styles: BTreeMap<String, String>,
variables: BTreeMap<String, String>,
collections: BTreeMap<String, String>,
}
impl Renames {
/// The new ids `other`'s styles, variables and collections take when appended to `doc`.
///
/// One whose id `doc` doesn't use keeps it. One `doc` already has is shared and keeps its id:
/// the same content under the same id, naming only things that are shared too (the same file
/// imported again). A collection and its variables are shared only all together, so variables
/// of one file never join a collection of another. Library copies keep their ids and are shared
/// with the document's copies, as before (`merge_store` records them). Every other id `doc`
/// already uses is replaced by a new one from `doc`'s allocators, clear of `other`'s ids too.
pub(super) fn new(doc: &mut Document, other: &Document) -> Renames {
let (theirs, libs) = (&other.variables, &other.libraries);
// Each with whether `doc` has the same under its id (None: nothing there), and whether
// it's a library copy.
let styles = other.styles.iter().map(|s| {
let here = doc.styles.iter().find(|m| m.id == s.id);
(Asset::Style(&s.id), here.map(|m| m == s), libs.style_import(&s.id).is_some())
});
let colls = theirs.collections.iter().map(|c| {
let here = doc.variables.collection_by_id(&c.id);
(Asset::Collection(&c.id), here.map(|m| m == c), libs.collection_import(&c.id).is_some())
});
let vars = theirs.variables.iter().map(|v| {
let here = doc.variables.get_by_id(&v.id);
(Asset::Variable(&v.id), here.map(|m| m == v), libs.variable_import(&v.id).is_some())
});
// `taken`: ids `doc` uses; `differs`: what `doc` doesn't have the same of.
let (mut taken, mut copies, mut differs) = (BTreeSet::new(), BTreeSet::new(), BTreeSet::new());
for (a, same, copy) in styles.chain(colls).chain(vars) {
match same {
None => {
differs.insert(a);
}
Some(same) => {
taken.insert(a);
if copy {
copies.insert(a);
} else if !same {
differs.insert(a);
}
}
}
}
// What differs makes what names it differ (library copies aside), and a collection and its
// variables differ together.
let mut named_by: BTreeMap<Asset, Vec<Asset>> = BTreeMap::new();
for v in &theirs.variables {
let (var, coll) = (Asset::Variable(&v.id), Asset::Collection(&v.collection));
named_by.entry(coll).or_default().push(var);
named_by.entry(var).or_default().push(coll);
for r in v.values.values().flat_map(VariableValue::references).chain(&v.extends) {
named_by.entry(Asset::Variable(r)).or_default().push(var);
}
}
for c in &theirs.collections {
if let Some(e) = &c.extends {
named_by.entry(Asset::Collection(e)).or_default().push(Asset::Collection(&c.id));
}
}
for s in &other.styles {
for v in s.bound.values() {
named_by.entry(Asset::Variable(v)).or_default().push(Asset::Style(&s.id));
}
}
let mut todo: Vec<Asset> = differs.iter().copied().collect();
while let Some(a) = todo.pop() {
for &n in named_by.get(&a).into_iter().flatten() {
if !copies.contains(&n) && differs.insert(n) {
todo.push(n);
}
}
}
let renamed = |a: Asset| taken.contains(&a) && differs.contains(&a);
Renames {
collections: fresh(
theirs.collections.iter().map(|c| &c.id),
|id| renamed(Asset::Collection(id)),
|avoid| doc.new_collection_id_avoiding(avoid),
),
variables: fresh(theirs.variables.iter().map(|v| &v.id), |id| renamed(Asset::Variable(id)), |avoid| doc.new_variable_id_avoiding(avoid)),
styles: fresh(other.styles.iter().map(|s| &s.id), |id| renamed(Asset::Style(id)), |avoid| doc.new_style_id_avoiding(avoid)),
}
}
pub(super) fn is_empty(&self) -> bool {
self.styles.is_empty() && self.variables.is_empty() && self.collections.is_empty()
}
/// `other` with the renamed styles, variables and collections under their new ids, and what
/// names them following: layers' style links (text ranges too), bindings, explicit modes and
/// prototype actions; styles' bindings; variables' collections, aliases and calculations; what
/// extended collections and their variables extend; and the ids of the renamed collections'
/// modes, which name their collection (`<collection>/mode<k>`).
pub(super) fn applied(&self, other: &Document) -> Document {
let theirs = &other.variables;
let mut doc = other.clone();
let (styles, vars, colls) = (&self.styles, &self.variables, &self.collections);
for s in &mut doc.styles {
rename(styles, &mut s.id);
s.bound.values_mut().for_each(|v| rename(vars, v));
}
for c in &mut doc.variables.collections {
for m in &mut c.modes {
self.mode(theirs, &c.id, &mut m.id);
}
for id in std::iter::once(&mut c.id).chain(&mut c.extends) {
rename(colls, id);
}
}
for v in &mut doc.variables.variables {
let values = std::mem::take(&mut v.values).into_iter().map(|(mut mode, mut val)| {
self.mode(theirs, &v.collection, &mut mode);
val.references_mut().into_iter().for_each(|r| rename(vars, r));
(mode, val)
});
v.values = values.collect();
let overrides = std::mem::take(&mut v.overrides).into_iter().map(|mut mode| {
self.mode(theirs, &v.collection, &mut mode);
mode
});
v.overrides = overrides.collect();
rename(colls, &mut v.collection);
for id in std::iter::once(&mut v.id).chain(&mut v.extends) {
rename(vars, id);
}
}
let ids: Vec<NodeId> = doc.ids().collect();
for id in ids {
let Some(n) = doc.node_mut(id) else { continue };
let links = [&mut n.styles.fill, &mut n.styles.stroke, &mut n.styles.effect, &mut n.styles.grid, &mut n.styles.text];
for s in links.into_iter().flatten() {
rename(styles, s);
}
if let NodeKind::Text(t) = &mut n.kind {
t.map_text_styles(|s| Some(styles.get(s).cloned().unwrap_or_else(|| s.to_string())));
}
n.bound.values_mut().for_each(|v| rename(vars, v));
n.var_modes = std::mem::take(&mut n.var_modes)
.into_iter()
.map(|(mut c, mut mode)| {
self.mode(theirs, &c, &mut mode);
rename(colls, &mut c);
(c, mode)
})
.collect();
for it in &mut n.interactions {
walk_mut(&mut it.actions, &mut |a| self.action(theirs, a));
}
}
doc
}
/// Rename mode `mode` of collection `of` (`theirs`: the imported file's variables as they were).
/// A mode id names the collection it was made in: `of`, or one `of` extends (an extended
/// collection has the modes of the one it extends). Library copies keep their library's mode
/// ids, which may name one of the file's own collections.
fn mode(&self, theirs: &Variables, of: &str, mode: &mut ModeId) {
let mut at = Some(of);
for _ in 0..=theirs.collections.len() {
let Some(c) = at else { return };
if let Some(k) = mode.strip_prefix(c).and_then(|rest| rest.strip_prefix(MODE_ID_INFIX)) {
if let Some(new) = self.collections.get(c) {
*mode = format!("{new}{MODE_ID_INFIX}{k}");
}
return;
}
at = theirs.collection_by_id(c).and_then(|c| c.extends.as_deref());
}
}
/// Rename what one prototype action names (not the actions nested in it).
fn action(&self, theirs: &Variables, a: &mut Action) {
match a {
Action::SetVariable { variable, expr, .. } => {
rename(&self.variables, variable);
if let Some(e) = expr {
self.expr(theirs, e, 0);
}
}
Action::SetVariableMode { collection, mode } => {
self.mode(theirs, collection, mode);
rename(&self.collections, collection);
}
Action::Conditional { blocks } => blocks.iter_mut().filter_map(|b| b.condition.as_mut()).for_each(|c| self.expr(theirs, c, 0)),
_ => {}
}
}
fn expr(&self, theirs: &Variables, e: &mut Expr, depth: usize) {
match e {
Expr::Value(v) => v.references_mut().into_iter().for_each(|r| rename(&self.variables, r)),
Expr::Op { args, .. } if depth < MAX_EXPR_DEPTH => args.iter_mut().for_each(|a| self.expr(theirs, a, depth + 1)),
Expr::Op { .. } => {}
Expr::Mode { collection } => rename(&self.collections, collection),
Expr::InMode { variable, mode } => {
if let Some(v) = theirs.get_by_id(variable) {
self.mode(theirs, &v.collection, mode);
}
rename(&self.variables, variable);
}
}
}
}
/// New ids, from `new_id`, for those of `ids` that `pick` picks; each clear of all of `ids`.
fn fresh<'a>(
ids: impl Iterator<Item = &'a String> + Clone,
pick: impl Fn(&str) -> bool,
mut new_id: impl FnMut(&BTreeSet<String>) -> String,
) -> BTreeMap<String, String> {
let mut avoid: BTreeSet<String> = ids.clone().cloned().collect();
let mut out = BTreeMap::new();
for id in ids.filter(|id| pick(id)) {
let new = new_id(&avoid);
avoid.insert(new.clone());
out.insert(id.clone(), new);
}
out
}
/// `id` as `map` renames it.
fn rename(map: &BTreeMap<String, String>, id: &mut String) {
if let Some(new) = map.get(id.as_str()) {
*id = new.clone();
}
}
+12 -1
View File
@@ -1,3 +1,4 @@
use super::import_ids::Renames;
use super::load::{ext, file_stem, import_summary, load_path, load_with, read, rest_images, sibling_variables, variables_param};
use super::*;
@@ -218,8 +219,18 @@ pub(super) fn import(s: &mut Session, p: &Value) -> Result<Value> {
/// Append `other`'s pages (with their settings and flows, its images, styles and variables) to
/// `doc`. Ids are remapped across all pages at once, so instances keep their main, instance layers
/// their source and interactions and flows their targets wherever those are. Styles, variables and
/// collections whose id the document already has are kept as they are.
/// collections the document already has (the same under the same id, as when a file comes in
/// again) and library copies are shared; others whose id the document uses for something else get
/// new ids, and what comes in names them by those ([`Renames`]).
pub(super) fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
let renames = Renames::new(doc, other);
let renamed;
let other = if renames.is_empty() {
other
} else {
renamed = renames.applied(other);
&renamed
};
for (k, v) in &other.images {
doc.images.entry(k.clone()).or_insert_with(|| v.clone());
}
+7 -1
View File
@@ -983,6 +983,12 @@ fn apply_text(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Val
t.set_para_lists(start, end, |p| p.indent = d.clamp(0.0, f64::from(MAX_LIST_INDENT)) as u8);
}
}
"textDirection" | "direction" => {
let d: figcraft_doc::TextDirection = enum_of(v).ok_or_else(|| bad(cmd, "textDirection: auto|ltr|rtl"))?;
let t = text_mut(cmd, doc.get_mut(id)?)?;
let (start, end) = range_of(props, t.char_count());
t.set_para_lists(start, end, |p| p.direction = d);
}
"listSpacing" => text_mut(cmd, doc.get_mut(id)?)?.list_spacing = f64_of(v).filter(|s| s.is_finite()).unwrap_or(0.0).clamp(0.0, 10_000.0),
"hangingPunctuation" | "hangingList" => {
let on = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be true or false")))?;
@@ -1237,7 +1243,7 @@ pub fn override_group(k: &str) -> &'static str {
"name" => "name",
"characters" | "text" => "characters",
"features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "indentation" | "listSpacing" | "maxLines" | "variationSettings"
| "hangingPunctuation" | "hangingList" | "wrapStyle" | "textWrap" => "textStyle",
| "hangingPunctuation" | "hangingList" | "wrapStyle" | "textWrap" | "textDirection" | "direction" => "textStyle",
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
"width" | "height" | "w" | "h" => "size",
"sizingH" | "sizingV" | "layoutSizingHorizontal" | "layoutSizingVertical" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "resizing",
+29 -2
View File
@@ -1,8 +1,8 @@
//! Text menu and text editing commands.
use figcraft_doc::{
LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextPath, TextStyle,
VariableId,
LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextDirection, TextPath,
TextStyle, VariableId,
};
use serde_json::{Map, Value, json};
@@ -91,6 +91,13 @@ fn set_list(s: &mut Session, p: &Value, l: ListStyle, toggle: bool) -> Result<Va
para_range(s, p, |t, a, b| t.set_para_lists(a, b, |pl| pl.list = l))
}
/// The base direction of the targeted paragraphs: the edited ones while editing, else every
/// paragraph of the targeted texts ("controls are available to override text direction per
/// paragraph", Add right-to-left text, #1830).
fn set_direction(s: &mut Session, p: &Value, d: TextDirection) -> Result<Value> {
para_range(s, p, |t, a, b| t.set_para_lists(a, b, |pl| pl.direction = d))
}
/// Whether every targeted char has `pred` (to decide toggle direction).
fn all_have(s: &Session, p: &Value, pred: impl Fn(&TextStyle) -> bool) -> bool {
let Ok(doc) = s.doc() else { return false };
@@ -319,6 +326,26 @@ pub fn specs() -> Vec<CommandSpec> {
para(s, p, |t| t.wrap_style = w)
}
),
spec!(
"text.direction",
"Text direction",
"{direction: auto|ltr|rtl, ids?} the base direction of the paragraphs (while editing: the selected ones): auto goes by the first letter with a direction, ltr runs them left to right and rtl right to left whatever their letters (Text ▸ Text direction)",
true,
|s, p| {
let d: TextDirection =
get(p, "direction").and_then(super::props::enum_of).ok_or_else(|| bad("text.direction", "`direction`: auto|ltr|rtl"))?;
set_direction(s, p, d)
}
),
spec!("text.directionAuto", "Text direction: auto", "{ids?} while editing: the selected paragraphs", true, |s, p| {
set_direction(s, p, TextDirection::Auto)
}),
spec!("text.directionLtr", "Text direction: left to right", "{ids?} while editing: the selected paragraphs", true, |s, p| {
set_direction(s, p, TextDirection::Ltr)
}),
spec!("text.directionRtl", "Text direction: right to left", "{ids?} while editing: the selected paragraphs", true, |s, p| {
set_direction(s, p, TextDirection::Rtl)
}),
spec!("text.paragraphIndent", "Paragraph indent", "{indent (px), ids?}", true, |s, p| {
let v = finite(req_num("text.paragraphIndent", p, "indent")?).clamp(0.0, 10_000.0);
para(s, p, |t| t.paragraph_indent = v)
+13
View File
@@ -307,6 +307,19 @@ fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tok
figcraft_doc::WrapStyle::Balance => put("text-wrap", "balance".into()),
figcraft_doc::WrapStyle::Pretty => put("text-wrap", "pretty".into()),
}
// A base direction forced on every paragraph (CSS has one per element).
let dir = tx.paragraphs.first().map(|p| p.direction).filter(|d| tx.paragraphs.iter().all(|p| p.direction == *d));
match dir {
Some(figcraft_doc::TextDirection::Ltr) => put("direction", "ltr".into()),
Some(figcraft_doc::TextDirection::Rtl) => {
put("direction", "rtl".into());
// Left alignment is absolute on the canvas; `start` would be the right edge here.
if tx.align_h == TextAlignH::Left {
put("text-align", "left".into());
}
}
_ => {}
}
match s.case {
TextCase::Original => {}
TextCase::Upper => put("text-transform", "uppercase".into()),
+172
View File
@@ -0,0 +1,172 @@
//! `file.import` of a file whose style, variable and collection ids the document already uses for
//! other things: ids are numbered per file, so two files made apart share them. The imported
//! layers keep their own variables, modes and styles, and a file imported again still shares what
//! it brought the first time.
use base64::Engine as _;
use figcraft_doc::proto::Action;
use figcraft_doc::{Document, Node, NodeId, VariableValue};
use serde_json::{Value, json};
use crate::Session;
use crate::components::tests::ex;
use crate::params::id_of;
fn idv(v: &Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn var_id(s: &Session, name: &str) -> String {
s.doc().unwrap().variables.get(name).unwrap().id.clone()
}
fn fill(s: &Session, id: NodeId) -> String {
s.doc().unwrap().node(id).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
}
/// The document's file: collection A (Light, Dark) with colours `a1`…`a4`, paint style `Host`
/// and a frame filled with `a1`.
fn host() -> (Session, NodeId) {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "A", "modes": ["Light", "Dark"]}));
for i in 1..=4 {
ex(&mut s, "variable.create", json!({"collection": "A", "name": format!("a{i}"), "type": "color", "value": format!("#A{i}0000")}));
}
ex(&mut s, "style.create", json!({"type": "paint", "name": "Host", "value": "#FF0000"}));
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": "Host frame"})));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "a1"}));
(s, f)
}
/// Another file made apart: collection B (Light, Dark) with `b1` (green in each mode), `b2` (an
/// alias of `b1`) and `size`; paint style `Brand` bound to `b1`. Frame `Card` shows B in Dark, its
/// corners bound to `size`; in it `Swatch` is filled with `b2` and on click sets B to Dark and
/// `size` to `size + 1`, and `Styled` uses `Brand`.
fn other() -> Session {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "B", "modes": ["Light", "Dark"]}));
ex(&mut s, "variable.create", json!({"collection": "B", "name": "b1", "type": "color", "values": {"Light": "#00B000", "Dark": "#004000"}}));
ex(&mut s, "variable.create", json!({"collection": "B", "name": "b2", "type": "color", "value": {"alias": "b1"}}));
ex(&mut s, "variable.create", json!({"collection": "B", "name": "size", "type": "number", "value": 8}));
let brand = ex(&mut s, "style.create", json!({"type": "paint", "name": "Brand", "value": "#00B000"}))["id"].clone();
ex(&mut s, "style.bind", json!({"style": brand, "field": "fill", "variable": "b1"}));
let card = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
ex(&mut s, "variable.setMode", json!({"ids": [card], "collection": "B", "mode": "Dark"}));
ex(&mut s, "variable.bind", json!({"ids": [card], "field": "cornerRadius", "variable": "size"}));
let swatch = idv(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40, "parent": card, "name": "Swatch"})));
ex(&mut s, "variable.bind", json!({"ids": [swatch], "field": "fill", "variable": "b2"}));
let actions = json!([
{"type": "setVariableMode", "collection": "B", "mode": "Dark"},
{"type": "setVariable", "variable": "size", "expression": "size + 1"}
]);
ex(&mut s, "proto.interaction.add", json!({"id": swatch, "actions": actions}));
let styled = idv(&ex(&mut s, "shape.rectangle", json!({"x": 60, "y": 10, "width": 40, "height": 40, "parent": card, "name": "Styled"})));
ex(&mut s, "style.apply", json!({"ids": [styled], "style": "Brand"}));
s
}
fn import(s: &mut Session, file: &Session) -> Value {
let bytes = figcraft_format::save_bytes(file.doc().unwrap(), None).unwrap();
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
ex(s, "file.import", json!({"data": data, "name": "other.figcraft"}))
}
/// The layers on the pages after the first `kept` pages.
fn layers_after(doc: &Document, kept: usize) -> Vec<&Node> {
doc.pages[kept..].iter().flat_map(|p| doc.subtree(*p).into_iter().skip(1)).filter_map(|id| doc.node(id)).collect()
}
fn layer<'a>(doc: &'a Document, kept: usize, name: &str) -> &'a Node {
layers_after(doc, kept).into_iter().find(|n| n.name == name).unwrap()
}
#[test]
fn imported_layers_keep_their_own_variables_and_styles_when_the_ids_are_taken() {
let (mut s, frame) = host();
let file = other();
assert_eq!(var_id(&file, "b1"), var_id(&s, "a1"), "the ids must collide for this test");
assert_eq!(file.doc().unwrap().styles[0].id, s.doc().unwrap().styles[0].id);
let before = s.doc().unwrap().clone();
import(&mut s, &file);
let doc = s.doc().unwrap();
assert_eq!(doc.variables.variables.len(), 4 + 3);
assert_eq!(doc.variables.collections.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["A", "B"]);
assert_eq!(doc.styles.iter().map(|st| st.name.as_str()).collect::<Vec<_>>(), ["Host", "Brand"]);
// The document's own are as they were.
assert_eq!(doc.variables.variables[..4], before.variables.variables[..]);
assert_eq!(doc.variables.collections[0], before.variables.collections[0]);
assert_eq!(doc.styles[0], before.styles[0]);
// Everything imported names B's variables, B and Brand.
let b = doc.variables.collection("B").unwrap().id.clone();
let imported = layers_after(doc, before.pages.len());
assert!(imported.iter().any(|n| !n.bound.is_empty()));
for n in imported {
for v in n.bound.values() {
assert_eq!(doc.variables.get_by_id(v).unwrap().collection, b, "{}: {v}", n.name);
}
assert!(n.var_modes.keys().all(|c| *c == b), "{}: {:?}", n.name, n.var_modes);
if let Some(st) = &n.styles.fill {
assert_eq!(doc.styles.iter().find(|x| x.id == *st).unwrap().name, "Brand");
}
}
let brand = doc.styles.iter().find(|st| st.name == "Brand").unwrap();
assert_eq!(brand.bound.values().map(|v| doc.variables.get_by_id(v).unwrap().name.as_str()).collect::<Vec<_>>(), ["b1"]);
// Bindings applied again leave the imported layers alone.
let swatch = layer(doc, before.pages.len(), "Swatch").id;
let a2 = var_id(&s, "a2");
ex(&mut s, "variable.set", json!({"variable": a2, "value": "#123456"}));
assert_eq!(fill(&s, swatch), "004000");
assert_eq!(fill(&s, frame), "A10000");
assert!(s.doc().unwrap().validate().is_empty());
}
#[test]
fn an_alias_a_frame_mode_and_prototype_actions_follow_the_renamed_collection() {
let (mut s, _) = host();
let file = other();
let kept = s.doc().unwrap().pages.len();
import(&mut s, &file);
let doc = s.doc().unwrap();
let vars = &doc.variables;
let b = vars.collection("B").unwrap();
let in_b = |name: &str| vars.variables.iter().find(|v| v.name == name && v.collection == b.id).unwrap();
let (b1, b2, size) = (in_b("b1"), in_b("b2"), in_b("size"));
assert!(b2.values.values().all(|v| *v == VariableValue::Alias(b1.id.clone())), "{:?}", b2.values);
// B's mode ids name B's new id, and its variables' values are by those.
let modes: Vec<&str> = b.modes.iter().map(|m| m.id.as_str()).collect();
assert_eq!(modes, [format!("{}/mode1", b.id), format!("{}/mode2", b.id)]);
assert!([b1, b2, size].iter().all(|v| v.values.keys().all(|m| modes.contains(&m.as_str()))));
// The frame shows B in Dark: its mode is one of B's.
let card = layer(doc, kept, "Card");
let dark = b.find_mode("Dark").unwrap().id.clone();
assert_eq!(card.var_modes.iter().collect::<Vec<_>>(), [(&b.id, &dark)]);
assert_eq!(card.bound.get("cornerRadius"), Some(&size.id));
let swatch = layer(doc, kept, "Swatch");
assert_eq!(swatch.bound.get("fill"), Some(&b2.id));
let shown = vars.resolve(&b2.id, &doc.modes_for(swatch.id));
assert!(matches!(shown, Some(VariableValue::Color(c)) if c.to_hex() == "004000"), "{shown:?}");
let actions = &swatch.interactions[0].actions;
assert_eq!(actions[0], Action::SetVariableMode { collection: b.id.clone(), mode: dark });
let Action::SetVariable { variable, expr: Some(e), .. } = &actions[1] else { panic!("{actions:?}") };
assert_eq!((variable, e.variables()), (&size.id, vec![size.id.as_str()]));
}
#[test]
fn importing_the_same_file_again_shares_what_it_brought() {
let file = other();
let mut s = other();
let before = s.doc().unwrap().clone();
import(&mut s, &file);
let doc = s.doc().unwrap();
assert_eq!(doc.variables, before.variables);
assert_eq!(doc.styles, before.styles);
let swatch = layer(doc, before.pages.len(), "Swatch");
assert_eq!(swatch.bound.get("fill"), Some(&var_id(&s, "b2")));
// A file imported into an empty one, then again: one copy of its variables.
let mut t = Session::new();
import(&mut t, &file);
import(&mut t, &file);
let doc = t.doc().unwrap();
assert_eq!((doc.variables.variables.len(), doc.variables.collections.len(), doc.styles.len()), (3, 1, 1));
assert!(layers_after(doc, 1).iter().flat_map(|n| n.bound.values()).all(|v| doc.variables.get_by_id(v).is_some()));
}
+2
View File
@@ -367,6 +367,8 @@ mod images_left_in_file_tests;
#[cfg(test)]
mod import_export_tests;
#[cfg(test)]
mod import_ids_tests;
#[cfg(test)]
mod import_journal_tests;
#[cfg(test)]
mod inspect_behaviour_tests;
@@ -385,6 +385,34 @@ fn library_components_keep_their_variable_bindings() {
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_file_bringing_older_copies_of_the_same_library_variables_shares_them() {
let d = dir("older-copies");
let lib_path = path_str(&d.join("kit.figcraft"));
let mut lib = Session::new();
theme(&mut lib);
let lid = publish_to(&mut lib, &lib_path);
let mut s = Session::new();
ex(&mut s, "library.add", json!({"path": lib_path}));
let r = idv(&ex(&mut s, "shape.rectangle", json!({"width": 10, "height": 10, "name": "Swatch"})));
ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "primary", "field": "fill", "ids": [r]}));
let old_path = path_str(&d.join("old.figcraft"));
ex(&mut s, "file.saveAs", json!({"path": old_path}));
// The copies take an update; the saved file keeps the older ones under the same ids.
ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#444444"}));
republish(&mut lib);
ex(&mut s, "library.acceptUpdates", json!({}));
let before = s.doc().unwrap().clone();
ex(&mut s, "file.import", json!({"path": old_path}));
let doc = s.doc().unwrap();
assert_eq!((&doc.variables, &doc.libraries.variables), (&before.variables, &before.libraries.variables), "shared by id, as before");
let swatches: Vec<&Node> = doc.nodes().filter(|n| n.name == "Swatch").collect();
assert_eq!(swatches.len(), 2);
assert!(swatches.iter().all(|n| n.bound.get("fill") == Some(&copy_of(&s, "primary").id)));
healthy(&s);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn library_variable_commands_fail_cleanly() {
let mut s = Session::new();
+58
View File
@@ -371,6 +371,64 @@ fn max_lines_on_auto_height_text_hugs_that_many_lines() {
assert_eq!(resize_mode(&s, fixed), TextAutoResize::Truncate);
}
#[test]
fn text_direction_is_forced_per_paragraph() {
// Add right-to-left text: "By default, Figma automatically handles text direction based on
// language detection. However, controls are available to override text direction per
// paragraph" ("Go to Text > Text direction. Select Left to right or Right to left.").
let font = test_mono_font();
let mut s = Session::new();
let t = text(&mut s, "abc def\nghi\njkl", json!({"fontFamily": font, "fontSize": 16, "lineHeight": 24}));
let rtl = |s: &Session| -> Vec<bool> {
let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap();
l.lines.iter().map(|l| l.rtl).collect()
};
let inspect = |s: &Session| crate::inspect::node_json(s.doc().unwrap(), t, 0);
assert_eq!(rtl(&s), [false, false, false], "Latin letters read left to right");
assert!(inspect(&s).get("textDirection").is_none() && inspect(&s).get("lineDirections").is_none());
// The layer's paragraphs at once, by the command or `node.set`; inspect reports the one
// direction they share, and Dev Mode's CSS has it.
s.execute("text.direction", &json!({"ids": [t], "direction": "rtl"})).unwrap();
assert_eq!(rtl(&s), [true, true, true], "forced right to left whatever the letters");
assert_eq!(inspect(&s)["textDirection"], json!("rtl"));
let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": t})).unwrap()["code"].as_str().unwrap().to_string();
assert!(css(&mut s).contains("direction: rtl;"), "{}", css(&mut s));
assert!(css(&mut s).contains("text-align: left;"), "Left stays the left edge, which `start` would not be: {}", css(&mut s));
s.execute("node.set", &json!({"id": t, "props": {"textDirection": "ltr"}})).unwrap();
assert_eq!(rtl(&s), [false, false, false]);
assert_eq!(inspect(&s)["textDirection"], json!("ltr"));
assert!(css(&mut s).contains("direction: ltr;"));
s.execute("text.directionAuto", &json!({"ids": [t]})).unwrap();
assert!(inspect(&s).get("textDirection").is_none(), "auto throughout says nothing");
assert!(!css(&mut s).contains("direction:"));
// While editing, the caret's paragraph alone (the menu's Right to left); inspect lists each
// paragraph's direction when they differ, and the CSS stays quiet.
s.execute("text.edit.begin", &json!({"id": t, "caret": 9})).unwrap();
s.execute("text.directionRtl", &json!({})).unwrap();
s.execute("text.edit.end", &json!({})).unwrap();
assert_eq!(rtl(&s), [false, true, false], "the second paragraph only");
assert_eq!(inspect(&s)["lineDirections"], json!(["auto", "rtl", "auto"]));
assert!(!css(&mut s).contains("direction:"));
// Enter inside a forced paragraph keeps the direction for the new one, as list settings carry.
s.execute("text.edit.begin", &json!({"id": t, "caret": 10})).unwrap();
s.execute("text.edit.insert", &json!({"text": "\n"})).unwrap();
s.execute("text.edit.end", &json!({})).unwrap();
assert_eq!(rtl(&s), [false, true, true, false]);
// An empty last paragraph (after a final newline) keeps the direction forced on it.
s.execute("node.set", &json!({"id": t, "props": {"characters": "abc\n", "textDirection": "auto"}})).unwrap();
s.execute("text.edit.begin", &json!({"id": t, "caret": 4})).unwrap();
s.execute("text.directionRtl", &json!({})).unwrap();
s.execute("text.edit.end", &json!({})).unwrap();
assert_eq!(rtl(&s), [false, true], "the empty last line runs right to left");
// A direction the document has words for; junk is refused.
s.execute("node.set", &json!({"id": t, "props": {"characters": "\u{05E9}\u{05DC}\u{05D5}\u{05DD} abc", "textDirection": "auto"}})).unwrap();
assert_eq!(rtl(&s), [true], "Hebrew first: right to left by detection");
s.execute("node.set", &json!({"id": t, "props": {"textDirection": "ltr"}})).unwrap();
assert_eq!(rtl(&s), [false], "forced left to right");
assert!(s.execute("node.set", &json!({"id": t, "props": {"textDirection": "sideways"}})).is_err());
assert!(s.execute("text.direction", &json!({"ids": [t], "direction": "up"})).is_err());
}
#[test]
fn balance_and_pretty_wrap_styles_break_lines_as_documented() {
// Explore text properties: "Auto: Figma fits as many words as possible on each line before
+9 -4
View File
@@ -64,7 +64,7 @@ fn a_list_with_a_start_number_toggles_off() {
let mut s = Session::new();
let t = text(&mut s, "two\nthree\nfour");
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
let n = |start_offset| ParaList { list: N, indent: 0, start_offset };
let n = |start_offset| ParaList { list: N, indent: 0, start_offset, ..ParaList::default() };
td.paragraphs = vec![n(1), n(0), n(0)];
}
let j = crate::inspect::node_json(s.doc().unwrap(), t, 0);
@@ -157,7 +157,8 @@ fn list_keys_at_list_items() {
for (chars, before, caret, k, shift, after, want, why) in cases {
let mut s = Session::new();
let t = text(&mut s, chars);
let ps: Vec<ParaList> = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0 }).collect();
let ps: Vec<ParaList> =
before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0, ..ParaList::default() }).collect();
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
td.paragraphs = ps;
}
@@ -181,7 +182,11 @@ fn paragraph_lists_follow_edits() {
// A paragraph's list stays with it as text is typed, pasted and deleted: new paragraphs copy
// the one they split from; joined paragraphs keep the first one's.
let mut t = TextData::new("ab\ncd\nef");
t.paragraphs = vec![ParaList { list: B, indent: 1, start_offset: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start_offset: 0 }];
t.paragraphs = vec![
ParaList { list: B, indent: 1, start_offset: 0, ..ParaList::default() },
ParaList::default(),
ParaList { list: N, indent: 0, start_offset: 0, ..ParaList::default() },
];
let get = |t: &TextData| (0..t.paragraph_count()).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect::<Vec<_>>();
t.replace_range(1, 1, "x\ny");
assert_eq!(get(&t), [(B, 1), (B, 1), (P, 0), (N, 0)], "a pasted newline: a new item like it");
@@ -196,7 +201,7 @@ fn paragraph_lists_follow_edits() {
(seed >> 33) as usize % n.max(1)
};
let mut t = TextData::new("a\nb\nc\nd");
t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0 }; 4];
t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0, ..ParaList::default() }; 4];
for _ in 0..500 {
let n = t.char_count();
let (a, b) = (rnd(n + 2), rnd(n + 2));
+2 -2
View File
@@ -27,8 +27,8 @@ use figcraft_doc::proto::{
use figcraft_doc::{
AutoLayout, BoolOp, ContentAlign, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, GridTrack, HConstraint, LayoutMode,
LetterSpacing, LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap,
StrokeJoin, StyleKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType,
VariableValue, VectorNetwork, WindingRule,
StrokeJoin, StyleKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextDirection, TextStyle, VConstraint,
VariableType, VariableValue, VectorNetwork, WindingRule,
};
use serde_json::Value;
use sha1::Digest;
+8 -3
View File
@@ -1,8 +1,8 @@
use super::common::{style_fields, unsupported_decoration_look};
use super::{
Affine, BezPath, Bound, Exporter, Fields, Kv, ListStyle, MAX_CANVAS, MAX_CHARS, MAX_NETWORK, Node, NodeKind, Paint, StrokeAlign, StrokeCap,
StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextData, TextStyle, VectorNetwork, WindingRule, arr, b, cap_name, e, finite, fl, int,
join_name, network, o, path_blob, s, u, xy,
StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextData, TextDirection, TextStyle, VectorNetwork, WindingRule, arr, b, cap_name, e, finite,
fl, int, join_name, network, o, path_blob, s, u, xy,
};
impl<'d> Exporter<'d> {
@@ -136,11 +136,16 @@ impl<'d> Exporter<'d> {
let listed = *l != ListStyle::None;
let begins = first(i, l);
let start = starts.get(i).copied().unwrap_or(0);
let direction = match td.para_list(i).direction {
TextDirection::Auto => "AUTO",
TextDirection::Ltr => "LTR",
TextDirection::Rtl => "RTL",
};
o(vec![
("lineType", e(ty)),
("styleId", int(0)),
("indentationLevel", int(i64::from(listed))),
("sourceDirectionality", e("AUTO")),
("sourceDirectionality", e(direction)),
("listStartOffset", int(i64::from(start))),
("isFirstLineOfList", b(begins)),
])
+12
View File
@@ -130,6 +130,18 @@ impl<'a> Mapper<'a> {
if lines.iter().any(|l| *l != "NONE") {
o.insert("lineTypes".into(), json!(lines));
}
// A forced base direction per paragraph (`sourceDirectionality`; not a REST field).
let dirs: Vec<&str> = raw
.iter()
.map(|l| match l.get("sourceDirectionality").and_then(Kv::str) {
Some("LTR") => "LTR",
Some("RTL") => "RTL",
_ => "AUTO",
})
.collect();
if dirs.iter().any(|d| *d != "AUTO") {
o.insert("lineDirections".into(), json!(dirs));
}
// Where a numbered list starts counting, less one (not a REST field).
let starts: Vec<u32> =
raw.iter().map(|l| num(l, "listStartOffset").filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32)).collect();
+3
View File
@@ -1529,6 +1529,9 @@ mod image_filters;
#[path = "text_list_tests.rs"]
mod text_list;
#[path = "text_direction_tests.rs"]
mod text_direction;
/// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset).
pub(super) fn samples() -> Vec<std::path::PathBuf> {
let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() };
@@ -0,0 +1,83 @@
//! A paragraph's forced base direction (#1830): each text line's `sourceDirectionality` (AUTO,
//! LTR or RTL) comes in as the paragraph's direction and goes back out. Fixtures built in code with
//! the parent module's helpers.
use figcraft_doc::{NodeKind, TextDirection};
use super::*;
const DIRECTION_SCHEMA: &str = "
enum NodeType NONE DOCUMENT CANVAS TEXT
enum LineType PLAIN ORDERED_LIST UNORDERED_LIST
enum SourceDirectionality AUTO LTR RTL
struct GUID uint sessionID, uint localID
struct Vector float x, float y
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
message ParentIndex GUID guid, string position
message TextLineData LineType lineType, int indentationLevel, SourceDirectionality sourceDirectionality, int listStartOffset, bool isFirstLineOfList
message TextData string characters, TextLineData[] lines
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float fontSize, TextData textData
message Message NodeChange[] nodeChanges
";
/// A plain line with a base direction.
fn line(direction: &'static str) -> Kv<'static> {
o(vec![
("lineType", e("PLAIN")),
("indentationLevel", Kv::Int(0)),
("sourceDirectionality", e(direction)),
("listStartOffset", Kv::Int(0)),
("isFirstLineOfList", Kv::Bool(false)),
])
}
fn text_node(id: u64, name: &str, chars: &str, lines: Vec<Kv<'static>>) -> Kv<'static> {
let pos = ["!", "\"", "#", "$"][id as usize % 4];
node(
id,
Some((1, pos)),
"TEXT",
name,
vec![
("size", xy(200.0, 60.0)),
("transform", at(0.0, 0.0)),
("fontSize", f(16.0)),
("textData", o(vec![("characters", s(chars)), ("lines", arr(lines))])),
],
)
}
fn directions(doc: &figcraft_doc::Document, name: &str) -> Vec<TextDirection> {
let NodeKind::Text(t) = &named(doc, name).kind else { panic!("{name} is no text") };
(0..t.paragraph_count()).map(|p| t.para_list(p).direction).collect()
}
#[test]
fn a_lines_source_directionality_is_its_paragraphs_direction_both_ways() {
use TextDirection::{Auto as A, Ltr as L, Rtl as R};
let nodes = vec![
node(0, None, "DOCUMENT", "Document", vec![]),
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
text_node(2, "Detected", "abc\ndef", vec![line("AUTO"), line("AUTO")]),
text_node(3, "Forced", "abc\ndef\nghi", vec![line("RTL"), line("AUTO"), line("LTR")]),
];
let sc = Schema::parse(DIRECTION_SCHEMA).unwrap();
let (doc, warnings) = import(&canvas(&sc, &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(directions(&doc, "Detected"), [A, A]);
let NodeKind::Text(t) = &named(&doc, "Detected").kind else { panic!() };
assert!(t.paragraphs.is_empty(), "AUTO throughout keeps the paragraphs folded");
assert_eq!(directions(&doc, "Forced"), [R, A, L]);
// Out again: every line says its direction, and a reader gets the same paragraphs back.
let (bytes, _) = super::super::export(&doc, None).unwrap();
let a = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&a.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let text = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("Forced")).unwrap();
let lines = text.get("textData").and_then(|d| d.get("lines")).map(Kv::list).unwrap();
let out: Vec<&str> = lines.iter().map(|l| l.get("sourceDirectionality").and_then(Kv::str).unwrap()).collect();
assert_eq!(out, ["RTL", "AUTO", "LTR"]);
let (back, _) = import(&bytes, "x").unwrap();
assert_eq!(directions(&back, "Forced"), [R, A, L]);
assert_eq!(directions(&back, "Detected"), [A, A]);
}
+1 -1
View File
@@ -75,7 +75,7 @@ fn the_export_writes_where_a_list_starts() {
let page = doc.first_page().unwrap();
use ListStyle::{Bulleted as B, None as P, Numbered as N};
let mut td = figcraft_doc::TextData::new("two\nthree\nplain\nsix\ndot");
let pl = |list, start_offset| ParaList { list, indent: 0, start_offset };
let pl = |list, start_offset| ParaList { list, indent: 0, start_offset, ..ParaList::default() };
td.paragraphs = vec![pl(N, 1), pl(N, 0), pl(P, 0), pl(N, 5), pl(B, 4)];
doc.create(page, NodeKind::Text(td), "List", 0.0, 0.0, 200.0, 80.0).unwrap();
let (bytes, _) = super::super::export(&doc, None).unwrap();
+17 -12
View File
@@ -5,12 +5,12 @@ use std::collections::BTreeMap;
use figcraft_doc::{
DecorationLength, DecorationStyle, LetterSpacing, LineHeight, ListStyle, ParaList, StyleRun, TextAlignH, TextAlignV, TextAutoResize, TextCase,
TextData, TextDecoration, TextPath, TextStyle,
TextData, TextDecoration, TextDirection, TextPath, TextStyle,
};
use serde_json::Value;
use super::Ctx;
use super::props::{flag, len, list, num, paints, text};
use super::props::{flag, len, num, paints, text};
/// Most characters read from one text node.
const MAX_CHARS: usize = 1 << 20;
@@ -256,23 +256,28 @@ fn runs(cx: &mut Ctx, v: &Value, td: &TextData) -> Vec<StyleRun> {
out
}
/// Each paragraph's list type, nesting level and start number from `lineTypes`,
/// `lineIndentations` and (from `.fig` files) `listStartOffsets`.
/// Each paragraph's list type, nesting level, start number and base direction from `lineTypes`,
/// `lineIndentations` and (from `.fig` files) `listStartOffsets` and `lineDirections`.
fn lists(v: &Value, td: &mut TextData) {
let types = list(v, "lineTypes");
let indents = list(v, "lineIndentations");
let starts = list(v, "listStartOffsets");
td.paragraphs = types
.iter()
.enumerate()
.map(|(i, t)| ParaList {
list: match t.as_str() {
// One entry per paragraph: read up to the paragraph count (bounded by the text's size), not the
// general list limit, so a long text keeps every paragraph's settings.
let n = td.paragraph_count();
let column = |k: &str| v.get(k).and_then(Value::as_array).map(|a| a.get(..a.len().min(n)).unwrap_or_default()).unwrap_or_default();
let (types, indents, starts, dirs) = (column("lineTypes"), column("lineIndentations"), column("listStartOffsets"), column("lineDirections"));
td.paragraphs = (0..types.len().max(dirs.len()))
.map(|i| ParaList {
list: match types.get(i).and_then(Value::as_str) {
Some("UNORDERED") => ListStyle::Bulleted,
Some("ORDERED") => ListStyle::Numbered,
_ => ListStyle::None,
},
indent: indents.get(i).and_then(Value::as_f64).filter(|d| d.is_finite()).map_or(0, |d| d.clamp(0.0, 255.0) as u8),
start_offset: starts.get(i).and_then(Value::as_f64).filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32),
direction: match dirs.get(i).and_then(Value::as_str) {
Some("LTR") => TextDirection::Ltr,
Some("RTL") => TextDirection::Rtl,
_ => TextDirection::Auto,
},
})
.collect();
// Clamp the levels and fold one style for all back into the layer's list style.
+1 -1
View File
@@ -109,7 +109,7 @@ fn a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does() {
if face().is_none() {
return;
}
let level = |list: ListStyle, indent: u8| ParaList { list, indent, start_offset: 0 };
let level = |list: ListStyle, indent: u8| ParaList { list, indent, start_offset: 0, ..ParaList::default() };
let mut t = text("a\nb\nc", TextAutoResize::WidthAndHeight);
t.paragraphs = vec![level(ListStyle::Bulleted, 1), level(ListStyle::None, 1), level(ListStyle::None, 0)];
let l = layout(&t, None, None);
+46 -15
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use figcraft_doc::{
DecorationLength, DecorationStyle, LeadingTrim, LetterSpacing, LineHeight, ListStyle, ParaList, TextAlignH, TextAutoResize, TextCase, TextData,
TextDecoration, TextStyle, WrapStyle, color::PaintKind, valid_feature_tag,
TextDecoration, TextDirection, TextStyle, WrapStyle, color::PaintKind, valid_feature_tag,
};
use figcraft_geom::{Affine, BezPath, Rect};
use harfrust::{Direction, Feature, ShapeOptions, ShaperInstance, Tag, UnicodeBuffer, Variation};
@@ -265,29 +265,47 @@ impl Item {
}
}
/// Bidi embedding levels per char, and whether each char's paragraph runs right to left.
/// Bidi embedding levels per char, and whether each char's paragraph runs right to left;
/// `rtl_para` has one more entry, for the position after the last char (an empty last paragraph).
pub(crate) struct Bidi {
pub(crate) levels: Vec<u8>,
pub(crate) rtl_para: Vec<bool>,
}
impl Bidi {
/// None when the text has nothing right to left (the common case costs one scan).
pub(crate) fn of(text: &str) -> Option<Bidi> {
if !text.chars().any(is_rtl_char) {
/// None when the text has nothing right to left and no paragraph forces its direction (the
/// common case costs one scan). `forced`, by paragraph: `Some(true)` runs right to left and
/// `Some(false)` left to right whatever the letters, `None` goes by the first letter with a
/// direction (Text ▸ Text direction, #1830).
pub(crate) fn of(text: &str, forced: &[Option<bool>]) -> Option<Bidi> {
if forced.iter().all(Option::is_none) && !text.chars().any(is_rtl_char) {
return None;
}
let info = unicode_bidi::BidiInfo::new(text, None);
let mut paras = info.paragraphs.iter().peekable();
let n = text.chars().count();
let (mut levels, mut rtl_para) = (Vec::with_capacity(n), Vec::with_capacity(n));
for (b, _) in text.char_indices() {
while paras.peek().is_some_and(|p| p.range.end <= b) {
paras.next();
// Paragraph by paragraph (the algorithm starts over at each one), so each can be given its
// base level.
for (p, para) in text.split_inclusive('\n').enumerate() {
let base = forced.get(p).copied().flatten().map(|rtl| if rtl { unicode_bidi::Level::rtl() } else { unicode_bidi::Level::ltr() });
let info = unicode_bidi::BidiInfo::new(para, base);
let mut paras = info.paragraphs.iter().peekable();
for (b, _) in para.char_indices() {
while paras.peek().is_some_and(|q| q.range.end <= b) {
paras.next();
}
levels.push(info.levels.get(b).map(|l| l.number()).unwrap_or(0));
rtl_para.push(paras.peek().is_some_and(|q| q.level.is_rtl()));
}
levels.push(info.levels.get(b).map(|l| l.number()).unwrap_or(0));
rtl_para.push(paras.peek().is_some_and(|p| p.level.is_rtl()));
}
// The position after the last char: in the last paragraph, or in the empty one after a
// final newline (its forced direction, else left to right), so an empty last line knows
// its direction too.
let after = if text.is_empty() || text.ends_with('\n') {
forced.get(text.matches('\n').count()).copied().flatten().unwrap_or(false)
} else {
rtl_para.last().copied().unwrap_or(false)
};
rtl_para.push(after);
Some(Bidi { levels, rtl_para })
}
}
@@ -479,8 +497,20 @@ fn shape_text(text: &TextData) -> ShapedText {
.filter_map(|(k, on)| <[u8; 4]>::try_from(k.as_bytes()).ok().map(|t| Feature::new(Tag::new(&t), u32::from(*on), ..)))
.collect();
// Shape each span, split by paragraph, bidi run and fallback face.
let bidi = Bidi::of(&text.characters);
// Shape each span, split by paragraph, bidi run and fallback face. A paragraph's direction is
// its letters' unless forced (Text ▸ Text direction).
let forced: Vec<Option<bool>> = if text.paragraphs.is_empty() {
Vec::new()
} else {
(0..text.paragraph_count())
.map(|p| match text.para_list(p).direction {
TextDirection::Auto => None,
TextDirection::Ltr => Some(false),
TextDirection::Rtl => Some(true),
})
.collect()
};
let bidi = Bidi::of(&text.characters, &forced);
let level_at = |i: usize| bidi.as_ref().and_then(|b| b.levels.get(i)).copied().unwrap_or(0);
// A grapheme cluster (emoji sequence, letter with marks) is shaped with one face.
let cluster_start = crate::breaks::grapheme_starts(&text.characters);
@@ -1251,7 +1281,8 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
// Letter spacing separates glyphs: none after the line's last visible one.
let end_spacing = vis_end.checked_sub(1).filter(|i| *i >= *s).and_then(|i| items.get(i)).map_or(0.0, |it| it.spacing);
let width: f64 = items.get(*s..vis_end).unwrap_or(&[]).iter().map(|it| it.adv).sum::<f64>() - end_spacing;
let first_char = items.get(*s).map(|it| it.char_start).unwrap_or(0);
// An empty last line has no item: the position after the last char is its paragraph's.
let first_char = items.get(*s).map_or(chars.len(), |it| it.char_start);
let rtl_line = rtl_para_at(first_char);
let line_indent = line_indent_of(*para, para_start_line, first_char);
// Hanging punctuation at the line's edges (the visible text's first and last chars).
+2 -2
View File
@@ -43,11 +43,11 @@ fn starts(l: &TextLayout) -> Vec<(f64, Option<f64>)> {
}
fn pl(list: ListStyle, indent: u8) -> ParaList {
ParaList { list, indent, start_offset: 0 }
ParaList { list, indent, start_offset: 0, ..ParaList::default() }
}
fn from(list: ListStyle, indent: u8, start_offset: u32) -> ParaList {
ParaList { list, indent, start_offset }
ParaList { list, indent, start_offset, ..ParaList::default() }
}
#[test]
+30 -2
View File
@@ -406,14 +406,42 @@ fn bidi_visual_order_follows_levels() {
assert_eq!(crate::layout::visual_order(&[0, 0, 0]), vec![0, 1, 2]);
assert!(crate::layout::visual_order(&[]).is_empty());
// Plain left-to-right text skips the analysis.
assert!(crate::layout::Bidi::of("Hello, world 123").is_none());
let b = crate::layout::Bidi::of("abc \u{5D0}\u{5D1}\u{5D2}\n\u{5D3} x").unwrap();
assert!(crate::layout::Bidi::of("Hello, world 123", &[]).is_none());
assert!(crate::layout::Bidi::of("Hello, world 123", &[None, None]).is_none());
let b = crate::layout::Bidi::of("abc \u{5D0}\u{5D1}\u{5D2}\n\u{5D3} x", &[]).unwrap();
assert_eq!(b.levels.get(..4), Some(&[0, 0, 0, 0][..]));
assert!(b.levels.get(4..7).unwrap().iter().all(|l| l % 2 == 1));
// Paragraph direction comes from each paragraph's first strong char.
assert!(!b.rtl_para[0] && b.rtl_para[8]);
}
/// A paragraph's forced base direction (Text ▸ Text direction, #1830): the base level is given,
/// the letters' own levels still follow the rules, and the other paragraphs keep theirs.
#[test]
fn forced_direction_sets_a_paragraphs_base_level() {
use crate::layout::Bidi;
// Latin letters forced right to left: an embedded left-to-right run (level 2) in a
// right-to-left paragraph; the next paragraph, unforced, stays left to right.
let b = Bidi::of("ab\ncd", &[Some(true), None]).unwrap();
assert_eq!(b.rtl_para, [true, true, true, false, false, false], "and the position after the last char");
assert_eq!((b.levels.get(..2), b.levels.get(3..)), (Some(&[2, 2][..]), Some(&[0, 0][..])));
// Hebrew forced left to right: a right-to-left run (level 1) in a left-to-right paragraph.
let b = Bidi::of("\u{5D0}\u{5D1}", &[Some(false)]).unwrap();
assert_eq!(b.rtl_para, [false, false, false]);
assert_eq!(b.levels, [1, 1]);
// Forcing what the letters already say changes nothing; a paragraph past the forced list is
// detected.
let b = Bidi::of("\u{5D0}\n\u{5D1}", &[Some(true)]).unwrap();
assert_eq!(b.rtl_para, [true, true, true, true]);
assert_eq!((b.levels.first(), b.levels.last()), (Some(&1), Some(&1)));
// The empty paragraph after a final newline has its own direction: forced, else left to right.
let b = Bidi::of("ab\n", &[None, Some(true)]).unwrap();
assert_eq!(b.rtl_para, [false, false, false, true]);
let b = Bidi::of("\u{5D0}\n", &[]).unwrap();
assert_eq!(b.rtl_para, [true, true, false]);
assert_eq!(Bidi::of("", &[Some(true)]).map(|b| b.rtl_para), Some(vec![true]));
}
/// The x of the glyph for char `i`.
fn glyph_x(l: &TextLayout, i: usize) -> f64 {
l.glyphs.iter().find(|g| g.char_start == i && g.char_end > i).map(|g| g.x).unwrap()
+6
View File
@@ -147,6 +147,7 @@ status = "draft — needs native review"
"Auto layout" = "Auto-Layout"
"Auto rows" = "Automatische Zeilen"
"Auto width" = "Automatische Breite"
"Auto: as many words as fit on each line" = "Auto: Jede Zeile wird so weit wie möglich gefüllt"
"Back to the FigCraft server" = "Zurück zum FigCraft-Server"
"Back to the font's value" = "Zurück zum Wert der Schrift"
"Back" = "Zurück"
@@ -160,6 +161,8 @@ status = "draft — needs native review"
"Back to the values the font style implies" = "Zurück zu den Werten des Schriftschnitts"
"Background" = "Hintergrund"
"Background blur" = "Hintergrundunschärfe"
"Balance" = "Ausgeglichen"
"Balance: every line close to the same length" = "Ausgeglichen: Alle Zeilen sind möglichst gleich lang"
"Basics" = "Grundlagen"
"Before" = "Vorher"
"Bend" = "Biegen"
@@ -1021,6 +1024,8 @@ status = "draft — needs native review"
"Post" = "Senden"
"Preferences" = "Einstellungen"
"Preferred" = "Bevorzugt"
"Pretty" = "Optimiert"
"Pretty: full lines, no one word alone on the last" = "Optimiert: Volle Zeilen ohne einzelnes Wort in der letzten Zeile"
"Present" = "Präsentieren"
"Present or preview" = "Präsentieren oder Vorschau"
"Present this flow" = "Diesen Ablauf präsentieren"
@@ -1555,6 +1560,7 @@ status = "draft — needs native review"
"With the layers' export settings" = "Mit den Exporteinstellungen der Ebenen"
"Wrap" = "Umbrechen"
"Wrap in new section" = "In neuen Abschnitt einschließen"
"Wrap style" = "Umbruchstil"
"Yellow" = "Gelb"
"Yesterday" = "Gestern"
"You" = "Sie"
+4
View File
@@ -408,6 +408,10 @@ name = "English"
"Decoration" = "Decoration"
"Deep select" = "Deep select"
"Wrap style" = "Wrap style"
"Text direction" = "Text direction"
"Auto: by the first letter with a direction" = "Auto: by the first letter with a direction"
"Left to right" = "Left to right"
"Right to left" = "Right to left"
"Balance" = "Balance"
"Pretty" = "Pretty"
"Auto: as many words as fit on each line" = "Auto: as many words as fit on each line"
+4
View File
@@ -2318,6 +2318,10 @@ status = "draft — needs native review"
"text.caseUpper" = "Mayúsculas"
"text.create" = "Texto"
"text.decoration" = "Decoración"
"text.direction" = "Dirección del texto"
"text.directionAuto" = "Dirección del texto: automática"
"text.directionLtr" = "Dirección del texto: de izquierda a derecha"
"text.directionRtl" = "Dirección del texto: de derecha a izquierda"
"text.edit.begin" = "Editar texto"
"text.edit.caret" = "Colocar el cursor de texto"
"text.edit.end" = "Dejar de editar el texto"
+1
View File
@@ -37,6 +37,7 @@ impl FigApp {
field_focus: false,
panels_unsaved: false,
control_queue: Vec::new(),
control_waits: Default::default(),
control_input: Vec::new(),
frame_stats: frame_stats::FrameStats::default(),
windows: windows::Windows::default(),
+27 -1
View File
@@ -454,6 +454,24 @@ fn hidden(ctx: &Context, id: u64) -> bool {
visibility(ctx, id) == Some(false)
}
/// How long, in seconds, a window may draw no frame while a control request waits for it before
/// [`background`] answers the request as it answers one for a hidden window. eframe runs the app's
/// logic for a window whose redraw is 0.25 s overdue, so the second such pass answers.
pub const NOT_DRAWING_AFTER: f64 = 0.2;
/// Whether window `id` has drawn no frame for [`NOT_DRAWING_AFTER`] while a request waited for it.
/// The platform may still call it visible: under Wayland an app can't tell that its window is
/// minimised, and the compositor then stops asking it to draw. The wait starts again whenever the
/// window draws.
fn stopped_drawing(app: &mut FigApp, ctx: &Context, id: u64, now: f64) -> bool {
let pass = ctx.cumulative_pass_nr_for(crate::windows::viewport(id));
let wait = app.control_waits.entry(id).or_insert((pass, now));
if wait.0 != pass {
*wait = (pass, now);
}
now - wait.1 >= NOT_DRAWING_AFTER
}
/// Send `RequestPaintWhileHidden` for work that needs a real UI pass (a queued capture or
/// synthetic input), and schedule a wakeup for the next capture deadline. Native eframe honours
/// this command; the shared web runner does not implement it yet, so a hidden browser tab remains
@@ -511,7 +529,7 @@ pub fn background(app: &mut FigApp, ctx: &Context) {
let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) });
continue;
}
let is_hidden = hidden(ctx, target);
let is_hidden = hidden(ctx, target) || stopped_drawing(app, ctx, target, now);
if r.method == "ui.screenshot" && visibility(ctx, target) != Some(true) {
// Hidden, minimised, or with visibility the platform has not reported: admit it now
// so the request gets its deadline and a forced paint. Waiting for a frame that may
@@ -564,6 +582,14 @@ pub fn background(app: &mut FigApp, ctx: &Context) {
}
let _ = r.reply.send(ControlReply { id: r.id, result: res.unwrap_or_else(|| Err(format!("no window {target}"))) });
}
// A window a request still waits for is checked again soon: if it draws, its frame answers;
// if it doesn't (minimised under Wayland), the next logic pass does.
let waiting: std::collections::BTreeSet<u64> =
app.control_queue.iter().map(|r| r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused)).collect();
app.control_waits.retain(|id, _| waiting.contains(id));
if !waiting.is_empty() {
crate::repaint_in(ctx, NOT_DRAWING_AFTER);
}
request_paint(app, ctx);
}
+47
View File
@@ -434,6 +434,53 @@ fn a_hidden_window_answers_at_once() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #1906: under Wayland a minimised window still reports itself visible (no `minimized`, no
/// `occluded`) and draws no frames; eframe runs only its logic. A request waiting for that
/// window's frame is answered from the logic passes once the window has drawn nothing for
/// [`crate::control::NOT_DRAWING_AFTER`], instead of timing out after 60 s. A window that draws
/// keeps answering from its frame.
#[test]
fn a_window_that_stopped_drawing_answers_from_its_logic_passes() {
let (mut h, text) = document();
h.settle();
h.time += 120.0;
h.app.clock.set_test_now(Some(h.time));
let (tx, rx) = std::sync::mpsc::channel();
let rename = |id: u64, name: &str| ControlRequest {
id: json!(id),
method: "engine.execute".into(),
params: json!({"command": "node.set", "params": {"ids": [text.clone()], "props": {"name": name}}}),
reply: tx.clone(),
state: Default::default(),
};
let node = figcraft_doc::NodeId(text.as_u64().unwrap());
let name = |h: &Harness| h.app.session.doc().unwrap().node(node).unwrap().name.clone();
// What Wayland reports for a minimised window: nothing about its visibility.
let unknown = RawInput::default();
let ctx = h.ctx.clone();
h.app.control_queue.push(rename(1, "while minimised"));
let _ = ctx.run_logic(&unknown, |ctx| crate::control::background(&mut h.app, ctx));
assert!(rx.try_recv().is_err(), "a window that may still draw answers from its frame");
assert_eq!(h.app.control_queue.len(), 1);
// No frame comes: the next logic pass answers.
h.app.clock.set_test_now(Some(h.time + crate::control::NOT_DRAWING_AFTER + 0.05));
let _ = ctx.run_logic(&unknown, |ctx| crate::control::background(&mut h.app, ctx));
let reply = rx.try_recv().expect("answered while the window draws nothing");
assert_eq!(reply.id, json!(1));
assert!(reply.result.is_ok(), "{:?}", reply.result);
assert_eq!(name(&h), "while minimised");
assert!(h.app.control_queue.is_empty(), "nothing waits for the window");
// Back on screen: a frame drawn after the wait began starts it again, and the frame answers.
h.time += 1.0;
h.app.clock.set_test_now(Some(h.time));
h.app.control_queue.push(rename(2, "on screen"));
let _ = ctx.run_logic(&unknown, |ctx| crate::control::background(&mut h.app, ctx));
h.app.clock.set_test_now(Some(h.time + crate::control::NOT_DRAWING_AFTER + 0.05));
h.frame(vec![]);
let reply = rx.try_recv().expect("the frame answers");
assert_eq!((reply.id, name(&h)), (json!(2), "on screen".to_string()));
}
#[test]
fn auto_save_writes_a_changed_file_then_rests() {
let dir = std::env::temp_dir().join(format!("figcraft-auto-save-{}", std::process::id()));
+3
View File
@@ -754,6 +754,9 @@ pub struct FigApp {
panels_unsaved: bool,
/// Control-channel requests waiting for the next frame.
pub control_queue: Vec<ControlRequest>,
/// Windows a control request waits for: the window's pass count when the wait began and the
/// app-clock time it began, to tell a window that stopped drawing ([`control::background`]).
control_waits: std::collections::BTreeMap<u64, (u64, f64)>,
/// Input events from the control channel (`ui.input`) for the next frame's raw input.
pub control_input: Vec<egui::Event>,
/// Frames drawn and what asked for them (`ui.inspect`).
+1 -1
View File
@@ -10,7 +10,7 @@ use std::sync::Arc;
use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_doc::{
DecorationLength, Document, LeadingTrim, LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAlignV, TextAutoResize,
TextCase, TextData, TextDecoration, TextStyle, VariableType, trim_num,
TextCase, TextData, TextDecoration, TextDirection, TextStyle, VariableType, trim_num,
};
use serde_json::{Value, json};
+28 -2
View File
@@ -4,8 +4,9 @@ use super::picker::{font_picker, remember_family};
use super::spacing::{SpacingField, commit_spacing, parse_letter_spacing, parse_line_height, spacing_field, spacing_input};
use super::{
Arc, DecorationLength, Document, FEATURES, FIELD_H, FieldEdit, FigApp, Icon, Id, Lead, LeadingTrim, ListStyle, MIXED, NodeId, SIZES, Sense, TABS,
TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, Tokens, Ui, Value, VariableType, common, field_row, icons,
json, live, menu_item, menu_popup, regular, row, semibold, text_style_params, tr, tr_noop, trf, trim_num, uniform, vec2, widgets,
TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextDirection, TextStyle, Tokens, Ui, Value, VariableType, common,
field_row, icons, json, live, menu_item, menu_popup, regular, row, semibold, text_style_params, tr, tr_noop, trf, trim_num, uniform, vec2,
widgets,
};
/// The font rows: family, style and size, line height and letter spacing.
@@ -397,6 +398,31 @@ fn type_settings(
let style = ["auto", "balance", "pretty"].get(i).copied().unwrap_or("auto");
app.set_props(ids, json!({"wrapStyle": style}));
}
// The paragraphs' base direction, once a right-to-left script is in the text or a direction
// is forced ("controls are available to override text direction per paragraph", Add
// right-to-left text). While editing: the selected paragraphs.
let has_direction =
|t: &TextData| figcraft_text::display::has_rtl(&t.characters) || t.paragraphs.iter().any(|p| p.direction != TextDirection::Auto);
if ids.iter().any(|i| doc.node(*i).and_then(|n| n.kind.text()).is_some_and(has_direction)) {
caption(ui, tr("Text direction"));
let dir = common(doc, ids, |n| {
n.kind.text().and_then(|t| {
let (a, b) = range.filter(|r| r.0 == n.id).map_or((0, t.char_count()), |(_, a, b)| (a, b));
let (p0, p1) = t.para_span(a, b);
let first = t.para_list(p0).direction;
(p0..=p1).all(|p| t.para_list(p).direction == first).then_some(first as usize)
})
})
.flatten();
let items = [
(tr("Auto"), tr_noop("Auto: by the first letter with a direction")),
("→", tr_noop("Left to right")),
("←", tr_noop("Right to left")),
];
if let Some(i) = text_seg(ui, "direction", &items, dir, w) {
app.run("text.direction", json!({"ids": ids, "direction": (["auto", "ltr", "rtl"].get(i).copied().unwrap_or("auto"))}));
}
}
let resize = common(doc, ids, |n| n.kind.text().map(|t| t.auto_resize)).flatten();
// Auto-height text with a line limit truncates too.
let truncating = common(doc, ids, |n| n.kind.text().map(TextData::truncates)).flatten();
+8 -5
View File
@@ -1,6 +1,6 @@
# FigCraft architecture
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (the `vectordrawable` crate, L4: Android Vector Drawable export; earlier: the shared hub in `collab-net` and the `figcraft-server` app) · **Target:** Figma Design
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (corrected the file I/O overview to include the existing best-effort `.fig` import and experimental export; earlier: the `vectordrawable` crate, L4: Android Vector Drawable export; the shared hub in `collab-net` and the `figcraft-server` app) · **Target:** Figma Design
How FigCraft is built today. Engine first, a thin swappable UI, everything a command; a Figma-shaped
data model (a scene graph of frames with auto layout, components and variables). ~108,800 lines of
@@ -23,7 +23,7 @@ replaced.
| L2 | `figcraft-plugin` | ~1.0k | Plugin manifest and the WebAssembly sandbox (wasmi, metered, memory cap, no file system, network or clock) |
| L2 | `figcraft-pathops` | ~1.1k | Booleans (linesweeper), outline stroke with alignment, arrowheads |
| L3 | `figcraft-render` | ~5.4k | Scene → pixels on tiny-skia (CPU): isolated layers for opacity/blend/effects, clips, masks, shadows, blurs (uniform, progressive, background), noise, texture, glass, text; banded in parallel; retained rendering of only what changed; levels of detail when zoomed out |
| L3 | `figcraft-format` | ~5.2k | Native `.figcraft` (zip of `document.json` + images; streamed JSON), local library files, versions, REST API file JSON import (`rest/`) |
| L3 | `figcraft-format` | ~5.2k | Native `.figcraft` (zip of `document.json` + images; streamed JSON), local library files, versions, REST API file JSON import (`rest/`), best-effort `.fig` import and experimental export (`fig` module) |
| L4 | `figcraft-svg` | ~3.7k | SVG export and import (usvg) |
| L4 | `figcraft-pdf` | ~2.3k | PDF export (krilla) with print options |
| L4 | `figcraft-vectordrawable` | ~1.2k | Android Vector Drawable (XML) export: baked path data, gradients through `aapt:attr`, clip groups, compatibility modes and warnings |
@@ -67,9 +67,11 @@ express an effect.
`.figcraft` is the native format (atomic saves, autosave checkpoints; the desktop app keeps
recovery copies of unsaved documents that reopen after a crash or a forced quit). Imports: REST API
file JSON with variables and images, SVG, raster images, clipboard
content. Exports: PNG, JPG, WebP, SVG, PDF, code. There is no `.fig` reader yet
([`file-format-parity.md`](file-format-parity.md)).
file JSON with variables and images, SVG, raster images, clipboard content and `.fig` files (best
effort). Exports: PNG, JPG, WebP, SVG, PDF, code and `.fig` (experimental). The `.fig`
[reader](../crates/format/src/fig.rs) and [writer](../crates/format/src/fig/export.rs) belong to
`figcraft-format`; fidelity limits and validation are documented in
[`file-format-parity.md`](file-format-parity.md).
## Robustness
@@ -83,6 +85,7 @@ input.
| Date | Change | Summary |
|---|---|---|
| 2026-11-04 | minor | Corrected the file I/O overview and format-crate row: best-effort `.fig` import and experimental export already exist in `figcraft-format`. No capability or readiness change. |
| 2026-10-18 | minor | Added `vectordrawable` (L4), the Android Vector Drawable (XML) writer |
| 2026-10-11 | minor | `collab-net` hosts through one hub for the app's sessions and the standalone server; added the `figcraft-server` app |
| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) |
+4 -2
View File
@@ -59,8 +59,10 @@ queue. A screenshot settles on three completed, presented UI frames (a discarded
not count), then is issued, and expires once at its 60 s deadline if no paint arrives. `window.new`
remains an error at once (`window 0 is minimised or hidden: …`) instead of waiting for the window
to show; `ui.render` draws the document without it. Under Wayland the app can't tell that its
window is minimised: where the compositor then stops drawing it, a request waits for the window
and gets the 60 s timeout error. The browser's shared eframe runner does not implement
window is minimised, and the compositor stops asking it to draw: a window that draws no frame for
0.2 s while a request waits for it is treated as hidden, so the requests that act on the app's
state are answered without a frame (about half a second each), while `ui.screenshot` and
`ui.input` keep waiting for one. The browser's shared eframe runner does not implement
`RequestPaintWhileHidden` yet, so hidden-tab screenshots and injected input are unqualified there.
Coordinates are page units, y down. `x`/`y` in `node.set`/`node.create` are relative to the parent
+3 -2
View File
@@ -1,6 +1,6 @@
# Hardware and platform parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** patch (under Wayland a minimised window answers control and MCP requests in about half a second instead of timing out after 60 s (#1906); images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7)
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
@@ -41,7 +41,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
|---|---|---|---|
| macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens. Quitting ends the process once the preferences, recovery copies and live sessions are taken care of: the window's teardown crashed on Macs with a Touch Bar (#781); the Quit menu and ⌘Q ask about unsaved changes like the main window's close button (#857), and like it close the main window only: with several windows open, each quit closes one; a quit that never asked — killed or crashed — still leaves recovery copies for the next start |
| Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR. The portable zip keeps its settings in `config\` beside `figcraft.exe` (a `portable` file there), taking an installed copy's preferences on its first start (#1320). Draws with Direct3D 12 (FXC shaders) or OpenGL; when the graphics fail before the first frame it starts again on Vulkan, then OpenGL, then the power-saving GPU with lower limits (#965) |
| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) |
| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised: a window that draws nothing for 0.2 s while a control request waits for it answers the request as a hidden window does (about half a second each, #1906), and only requests that need a drawn frame (screenshots, input) wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) |
| FreeBSD | none | tarball | |
| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event, its text or an image copied in another app); an internal error stops the page (a panic can't be caught there), but its unsaved files are kept in the page's local storage (about 5 MB) and open again on reload (#1123) |
| iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer |
@@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | patch | M8, #1906: under Wayland (seen on GNOME and KDE Plasma) a minimised FigCraft window still reports itself visible and the compositor stops asking it to draw, so every control or attached-MCP request waited for a frame and timed out after 60 s ("it was not applied"), though eframe kept running the app's logic. `control::background` now treats a window that drew no frame for `NOT_DRAWING_AFTER` (0.2 s) while a request waited for it as hidden: state-only requests are answered from the logic passes (0.54 s each, measured on GNOME Wayland; they took 13.7 s or timed out before), the wait starts again when the window draws, and screenshots and `ui.input` still wait for a frame. Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes` |
| 2026-10-31 | minor | M1, #492: Web row: the browser pastes images copied in other apps, read from the paste event under the same limits as the desktop. Numbers unchanged. |
| 2026-10-30 | minor | M8: text reads only the fallback fonts it draws with. Each fallback family reads the face nearest a run's style, not all of them (Noto Sans CJK is seven files of 16–20 MB); families naming the faces of one file share its bytes; and the faces are worked out once per style until fonts change, not for every text laid out. First text laid out on a Linux machine with Noto Sans CJK, DejaVu Sans and Inter: 41 files (265 MiB, kept in memory) and 41 faces parsed → 3 files (20 MiB) and 4 faces; fallback lookups per text laid out ~45 µs → 0.2 µs. Tests: `fontdb_fallback_tests`. Numbers unchanged |
| 2026-10-30 | minor | M8, #1117: AI agents and plugins no longer learn where the open files are on disk from `doc.list`. Numbers unchanged. |
+4 -2
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (documents: a paragraph's base direction can be forced with Text ▸ Text direction, as Add right-to-left text says (#1830); Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed
by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian
@@ -57,7 +57,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
Portuguese take the singular for 0 as well; Japanese, Korean, Chinese and Vietnamese have one form;
CLDR's "many" for millions in French, Spanish and Portuguese reads as the plural).
- **Script support in documents:** Unicode bidi with Arabic shaping and mirrored brackets in text
layers; right-to-left list markers and indents; right-to-left names in the layers panel, tabs
layers; a paragraph's base direction forced with Text ▸ Text direction (#1830); right-to-left
list markers and indents; right-to-left names in the layers panel, tabs
and canvas labels (visual order, joined Arabic); inline IME composition with the candidate window
at the caret; system-font fallback for scripts the UI fonts lack. Devanagari shapes through
harfrust. No vertical text (Figma has none either).
@@ -125,6 +126,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see
| Date | Change | Summary |
|---|---|---|
| 2026-11-05 | minor | Documents: a paragraph's base direction can be forced left to right or right to left (Text ▸ Text direction, `text.direction`, `node.set textDirection`, the Type settings control, the `.fig` lines' `sourceDirectionality`), as Add right-to-left text says (#1830); detection stays the default. Four new UI strings (English only; other catalogs fall back). |
| 2026-10-30 | minor | Bosnian (`bs`, Bosanski) draft catalog: every UI string (1,906), command label (629) and menu wording (31), machine-drafted in Latin script with ijekavian forms and marked as a draft awaiting native review. Bosnian plurals: one for 1, 21, 31… (not 11), few for 2–4, 22–24… (not 12–14), other for the rest. Grade and hours unchanged (Bosnian is not one of the twelve key languages) |
| 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged |
| 2026-10-28 | minor | Arabic draft: 1,857 UI entries, 620 command labels and 31 menu wordings, with count-neutral plurals. AI-assisted, native review pending. Desktop uses the existing Arabic fallback and bidi; web requires an embedded Arabic face. No panel mirroring or parity-grade increase claimed |
+2 -1
View File
@@ -42,7 +42,8 @@ replaced then, so a pause between calls is not an error.
The app keeps answering while its window is minimised or covered by other windows (see
`docs/control-protocol.md`): edits, inspection (`inspect_ui` too), pointer, key and text input and
renders work as usual (minimised, each request to the app takes up to about 0.1 s). Only a capture
renders work as usual (minimised, each request to the app takes up to about 0.1 s; under Wayland,
which can't report a minimised window, about half a second). Only a capture
of the window itself (`screenshot` with `window`) needs it on screen: it is then an error at once;
`screenshot` without `window` renders the document instead.
File diff suppressed because one or more lines are too long
+3 -1
View File
File diff suppressed because one or more lines are too long