mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 12:31:42 +00:00
Merge main into #2049
This commit is contained in:
commit
a632d63602
52 files changed
+1288
-83
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(®ion, *p) > 2.0, "{why}: the region runs into point {i} ({:.2} from it)", keeps_off(®ion, *p));
|
||||
}
|
||||
assert!(
|
||||
(length(&fill) - length(&stroke)).abs() < 1e-6,
|
||||
"{why}: fill {} and stroke {} trace the same outline",
|
||||
length(&fill),
|
||||
length(&stroke)
|
||||
);
|
||||
assert!(
|
||||
(length(®ion) - length(&stroke)).abs() < 1e-6,
|
||||
"{why}: region {} and stroke {} trace the same outline",
|
||||
length(®ion),
|
||||
length(&stroke)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_resolve_only_their_exact_collection() {
|
||||
let mut vars = crate::proto_expr_tests::vars();
|
||||
|
||||
+23
-6
@@ -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());
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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(©_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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
])
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user