Merge main into #2049

This commit is contained in:
bflatastic committed 2026-11-07 09:10:44 +07:00
commit a632d63602
52 files changed
+1288 -83

No files matched your search

+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();