mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-14 04:10:55 +00:00
Merge main into #2241
This commit is contained in:
+122
-1
@@ -293,6 +293,10 @@ pub struct ParaList {
|
||||
/// The paragraph's base direction (Text ▸ Text direction); `Auto` follows its letters.
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub direction: TextDirection,
|
||||
/// The paragraph's own wrap style (a wrap style can be set on the text layer, a paragraph or a
|
||||
/// text style); `None` follows the layer's [`TextData::wrap_style`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub wrap: Option<WrapStyle>,
|
||||
}
|
||||
|
||||
/// Character style. In runs, every field is an override; `None` means "inherit the base".
|
||||
@@ -337,6 +341,9 @@ pub struct ParagraphStyle {
|
||||
pub paragraph_indent: f64,
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub list: ListStyle,
|
||||
/// The wrap style (Auto asks nothing).
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub wrap_style: WrapStyle,
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub features: std::collections::BTreeMap<String, bool>,
|
||||
}
|
||||
@@ -838,6 +845,33 @@ impl TextData {
|
||||
self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() })
|
||||
}
|
||||
|
||||
/// Paragraph `p`'s wrap style: its own, else the layer's.
|
||||
pub fn para_wrap_style(&self, p: usize) -> WrapStyle {
|
||||
self.paragraphs.get(p).and_then(|pl| pl.wrap).unwrap_or(self.wrap_style)
|
||||
}
|
||||
|
||||
/// The layer level: every paragraph takes wrap style `w` (their own overrides go).
|
||||
pub fn set_wrap_style(&mut self, w: WrapStyle) {
|
||||
self.wrap_style = w;
|
||||
// Every paragraph by index: `para_span` leaves out an empty one after a final newline.
|
||||
let last = self.paragraph_count() - 1;
|
||||
self.set_para_lists_between(0, last, false, |pl| pl.wrap = None);
|
||||
}
|
||||
|
||||
/// The paragraph level: the paragraphs chars `start..end` touch take wrap style `w`; one equal
|
||||
/// to the layer's is no override.
|
||||
pub fn set_para_wrap_styles(&mut self, start: usize, end: usize, w: WrapStyle) {
|
||||
let layer = self.wrap_style;
|
||||
self.set_para_lists(start, end, |pl| pl.wrap = (w != layer).then_some(w));
|
||||
}
|
||||
|
||||
/// The effective wrap style the paragraphs chars `start..end` touch share, or None when they differ.
|
||||
pub fn common_wrap_style(&self, start: usize, end: usize) -> Option<WrapStyle> {
|
||||
let (a, b) = self.para_span(start, end);
|
||||
let first = self.para_wrap_style(a);
|
||||
(a..=b).all(|p| self.para_wrap_style(p) == first).then_some(first)
|
||||
}
|
||||
|
||||
/// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries).
|
||||
fn fill_paragraphs(&mut self) {
|
||||
let n = self.paragraph_count();
|
||||
@@ -930,7 +964,16 @@ impl TextData {
|
||||
Some(first) if self.paragraphs.iter().all(|p| p.list == first.list) => first.list,
|
||||
_ => self.list,
|
||||
};
|
||||
ParagraphStyle { paragraph_spacing: self.paragraph_spacing, paragraph_indent: self.paragraph_indent, list, features: self.features.clone() }
|
||||
// Every paragraph by index: `para_span` leaves out an empty one after a final newline.
|
||||
let first = self.para_wrap_style(0);
|
||||
let wrap_style = if (1..self.paragraph_count()).all(|p| self.para_wrap_style(p) == first) { first } else { self.wrap_style };
|
||||
ParagraphStyle {
|
||||
paragraph_spacing: self.paragraph_spacing,
|
||||
paragraph_indent: self.paragraph_indent,
|
||||
list,
|
||||
wrap_style,
|
||||
features: self.features.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take what text style `ps` sets at the paragraph and layer level: the spacing, indent and
|
||||
@@ -942,6 +985,7 @@ impl TextData {
|
||||
// Every paragraph by index: `para_span` leaves out an empty one after a final newline.
|
||||
let last = self.paragraph_count() - 1;
|
||||
self.set_para_lists_between(0, last, false, |pl| pl.list = ps.list);
|
||||
self.set_wrap_style(ps.wrap_style);
|
||||
}
|
||||
|
||||
/// As [`take_paragraph_style`](Self::take_paragraph_style), with the list style going to the
|
||||
@@ -952,6 +996,7 @@ impl TextData {
|
||||
self.paragraph_indent = ps.paragraph_indent;
|
||||
self.features.clone_from(&ps.features);
|
||||
self.set_para_lists(start, end, |pl| pl.list = ps.list);
|
||||
self.set_para_wrap_styles(start, end, ps.wrap_style);
|
||||
}
|
||||
|
||||
/// Follow text style `id`'s paragraph-level definition wherever chars link to it.
|
||||
@@ -970,6 +1015,7 @@ impl TextData {
|
||||
&& self.paragraph_indent == ps.paragraph_indent
|
||||
&& self.features == ps.features
|
||||
&& self.common_para_list(start, end).is_some_and(|pl| pl.list == ps.list)
|
||||
&& self.common_wrap_style(start, end) == Some(ps.wrap_style)
|
||||
}
|
||||
|
||||
/// Whether the whole text shows what text style `ps` sets at the paragraph and layer level.
|
||||
@@ -1124,6 +1170,69 @@ mod case_tests {
|
||||
assert_eq!(t.para_list(0), ParaList::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_style_per_paragraph_loads_from_old_files_and_folds() {
|
||||
use WrapStyle::{Auto, Balance, Pretty};
|
||||
let old = r#"{"list":"numbered","indent":1}"#;
|
||||
assert_eq!(serde_json::from_str::<ParaList>(old).unwrap().wrap, None);
|
||||
let mut t = TextData::new("a\nb");
|
||||
let key = t.layout_key();
|
||||
t.set_para_wrap_styles(0, 0, Balance);
|
||||
assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1)), (Balance, Auto));
|
||||
assert_ne!(t.layout_key(), key);
|
||||
let back: TextData = serde_json::from_str(&serde_json::to_string(&t).unwrap()).unwrap();
|
||||
assert_eq!(back, t);
|
||||
t.set_para_wrap_styles(0, 0, Auto);
|
||||
assert!(t.paragraphs.is_empty());
|
||||
t.set_para_wrap_styles(0, 0, Balance);
|
||||
t.set_wrap_style(Pretty);
|
||||
assert!(t.paragraphs.is_empty());
|
||||
assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1)), (Pretty, Pretty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_style_loads_from_old_paragraph_styles_and_texts() {
|
||||
use WrapStyle::Auto;
|
||||
// A paragraph style saved before it carried a wrap style.
|
||||
let old = r#"{"paragraph_spacing":4.0,"list":"bulleted"}"#;
|
||||
let ps: ParagraphStyle = serde_json::from_str(old).unwrap();
|
||||
assert_eq!((ps.wrap_style, ps.paragraph_spacing, ps.list), (Auto, 4.0, ListStyle::Bulleted));
|
||||
assert!(!serde_json::to_string(&ParagraphStyle::default()).unwrap().contains("wrap_style"), "Auto is not saved");
|
||||
// A text saved before its paragraphs carried a wrap style.
|
||||
let mut t = TextData::new("a\nb");
|
||||
t.set_para_lists(0, 0, |p| p.list = ListStyle::Bulleted);
|
||||
let mut v = serde_json::to_value(&t).unwrap();
|
||||
for p in v["paragraphs"].as_array_mut().unwrap() {
|
||||
assert!(p.as_object_mut().unwrap().remove("wrap").is_none(), "Auto is not saved");
|
||||
}
|
||||
v.as_object_mut().unwrap().remove("wrap_style");
|
||||
let old: TextData = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(old.wrap_style, Auto);
|
||||
assert_eq!(old.paragraphs.len(), 2);
|
||||
assert!(old.paragraphs.iter().all(|p| p.wrap.is_none()));
|
||||
assert_eq!((old.para_wrap_style(0), old.para_wrap_style(1)), (Auto, Auto));
|
||||
assert_eq!(old, t);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_wrap_style_clears_an_override_on_the_empty_last_paragraph() {
|
||||
use WrapStyle::{Auto, Balance, Pretty};
|
||||
let mut t = TextData::new("ab\n");
|
||||
assert_eq!(t.paragraph_count(), 2);
|
||||
// The empty paragraph after the final newline: a caret there reaches it.
|
||||
t.set_para_wrap_styles(3, 3, Balance);
|
||||
assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1)), (Auto, Balance));
|
||||
t.set_wrap_style(Pretty);
|
||||
assert!(t.paragraphs.is_empty(), "{:?}", t.paragraphs);
|
||||
assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1)), (Pretty, Pretty));
|
||||
// Back to the layer's own style: no override is left on either paragraph.
|
||||
t.set_para_wrap_styles(3, 3, Auto);
|
||||
assert_eq!(t.para_wrap_style(1), Auto);
|
||||
t.set_wrap_style(Auto);
|
||||
assert!(t.paragraphs.is_empty());
|
||||
assert_eq!(t.common_wrap_style(0, t.char_count()), Some(Auto));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_list_starts_touches_only_starts() {
|
||||
use ListStyle::{None as P, Numbered as N};
|
||||
@@ -1190,4 +1299,16 @@ mod whole_text_paragraph_style_tests {
|
||||
plain.take_paragraph_style(&ps);
|
||||
assert_eq!((plain.list, plain.paragraphs.len()), (t.list, t.paragraphs.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_made_from_the_whole_layer_counts_a_trailing_empty_paragraphs_wrap_style() {
|
||||
// The first two paragraphs are Balance, the empty one after the final newline keeps the
|
||||
// layer's Auto: they differ, so the style takes the layer's wrap style, not Balance.
|
||||
let mut t = TextData::new("a\nb\n");
|
||||
t.set_para_wrap_styles(0, 3, WrapStyle::Balance);
|
||||
assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1), t.para_wrap_style(2)), (WrapStyle::Balance, WrapStyle::Balance, WrapStyle::Auto));
|
||||
assert_eq!(t.paragraph_style().wrap_style, WrapStyle::Auto);
|
||||
t.set_para_wrap_styles(4, 4, WrapStyle::Balance);
|
||||
assert_eq!(t.paragraph_style().wrap_style, WrapStyle::Balance, "all three Balance");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ pub const NODE_KEYS: &[&str] = &[
|
||||
"lineHeight",
|
||||
"lineIndentations",
|
||||
"lineTypes",
|
||||
"lineWrapStyles",
|
||||
"listSpacing",
|
||||
"listStartOffsets",
|
||||
"listStyle",
|
||||
@@ -466,8 +467,14 @@ fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>, options: &In
|
||||
if t.leading_trim != figcraft_doc::LeadingTrim::None {
|
||||
insert_prop(o, options, "leadingTrim", || json!(t.leading_trim));
|
||||
}
|
||||
if t.wrap_style != figcraft_doc::WrapStyle::Auto {
|
||||
insert_prop(o, options, "wrapStyle", || json!(t.wrap_style));
|
||||
// The paragraphs' effective wrap styles: one for the layer when they agree, else each one's.
|
||||
let wraps: Vec<figcraft_doc::WrapStyle> = (0..t.paragraph_count()).map(|p| t.para_wrap_style(p)).collect();
|
||||
if let Some(first) = wraps.first().copied().filter(|w| *w != figcraft_doc::WrapStyle::Auto)
|
||||
&& wraps.iter().all(|w| *w == first)
|
||||
{
|
||||
insert_prop(o, options, "wrapStyle", || json!(first));
|
||||
} else if wraps.iter().any(|w| *w != figcraft_doc::WrapStyle::Auto) {
|
||||
insert_prop(o, options, "lineWrapStyles", || wraps.iter().map(|w| json!(w)).collect());
|
||||
}
|
||||
if !t.runs.is_empty() {
|
||||
insert_prop(o, options, "styledRuns", || serde_json::to_value(&t.runs).unwrap_or(Value::Null));
|
||||
|
||||
@@ -29,7 +29,7 @@ fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (l
|
||||
(auto|px|\"10%\" of the font size; the offset runs from the baseline down to an underline's top edge), textDecorationSkipInk (underlines break around descenders), \
|
||||
textDecorationColor (colour, or auto/null: the text's), textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \
|
||||
listStyle (none|bulleted|numbered; with start/end: the paragraphs those chars touch), indentation (list nesting level 0-8, same range rule), \
|
||||
listSpacing (px between list items, in place of paragraphSpacing), hangingPunctuation, hangingList (markers outside the box), leadingTrim (vertical trim: none|capHeight), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \
|
||||
listSpacing (px between list items, in place of paragraphSpacing), wrapStyle (auto|balance|pretty; with start/end: the paragraphs those chars touch), hangingPunctuation, hangingList (markers outside the box), leadingTrim (vertical trim: none|capHeight), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \
|
||||
fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), \
|
||||
path (a vector's SVG path data: replaces its geometry, keeping the layer, its paints, styles and bindings; width/height in the same call scale it as \
|
||||
node.create does; its own box goes at the layer's x/y, or with x/y given, at x + its left edge and y + its top as node.create places it), pointCount, innerRadius, arcStart, arcSweep, \
|
||||
@@ -1008,7 +1008,14 @@ fn apply_text(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Val
|
||||
}
|
||||
"leadingTrim" => text_mut(cmd, doc.get_mut(id)?)?.leading_trim = enum_of(v).ok_or_else(|| bad(cmd, "leadingTrim: none|capHeight"))?,
|
||||
"wrapStyle" | "textWrap" => {
|
||||
text_mut(cmd, doc.get_mut(id)?)?.wrap_style = enum_of(v).ok_or_else(|| bad(cmd, "wrapStyle: auto|balance|pretty"))?
|
||||
let w: figcraft_doc::WrapStyle = enum_of(v).ok_or_else(|| bad(cmd, "wrapStyle: auto|balance|pretty"))?;
|
||||
let t = text_mut(cmd, doc.get_mut(id)?)?;
|
||||
if props.contains_key("start") || props.contains_key("end") {
|
||||
let (start, end) = range_of(props, t.char_count());
|
||||
t.set_para_wrap_styles(start, end, w);
|
||||
} else {
|
||||
t.set_wrap_style(w);
|
||||
}
|
||||
}
|
||||
"maxLines" => {
|
||||
let lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32;
|
||||
|
||||
@@ -275,6 +275,9 @@ fn paragraph_style_json(cmd: &str, v: &Value) -> Result<ParagraphStyle> {
|
||||
"paragraphSpacing" | "paragraph_spacing" => ps.paragraph_spacing = length(k, x)?,
|
||||
"paragraphIndent" | "paragraph_indent" => ps.paragraph_indent = length(k, x)?,
|
||||
"listStyle" | "list" => ps.list = super::props::enum_of(x).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?,
|
||||
"wrapStyle" | "textWrap" | "wrap_style" => {
|
||||
ps.wrap_style = super::props::enum_of(x).ok_or_else(|| bad(cmd, "wrapStyle: auto|balance|pretty"))?;
|
||||
}
|
||||
"features" | "openTypeFeatures" | "fontFeatures" | "textFeatures" => {
|
||||
ps.features = super::props::features_of(cmd, x)?.into_iter().filter_map(|(tag, on)| Some((tag, on?))).collect();
|
||||
}
|
||||
@@ -289,10 +292,11 @@ fn paragraph_style_json(cmd: &str, v: &Value) -> Result<ParagraphStyle> {
|
||||
fn paragraph_style_of(n: &Node, range: Option<(usize, usize)>) -> Option<ParagraphStyle> {
|
||||
let t = n.kind.text()?;
|
||||
let mut ps = t.paragraph_style();
|
||||
if let Some((a, b)) = range
|
||||
&& let Some(pl) = t.common_para_list(a, b)
|
||||
{
|
||||
ps.list = pl.list;
|
||||
if let Some((a, b)) = range {
|
||||
if let Some(pl) = t.common_para_list(a, b) {
|
||||
ps.list = pl.list;
|
||||
}
|
||||
ps.wrap_style = t.common_wrap_style(a, b).unwrap_or(t.wrap_style);
|
||||
}
|
||||
Some(ps)
|
||||
}
|
||||
|
||||
@@ -98,6 +98,15 @@ 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))
|
||||
}
|
||||
|
||||
/// The wrap style of the targeted texts: the edited paragraphs while editing (the paragraph
|
||||
/// level), else the whole layer (the layer level: every paragraph, overrides included).
|
||||
fn set_wrap_style(s: &mut Session, p: &Value, w: figcraft_doc::WrapStyle) -> Result<Value> {
|
||||
match textedit::selected_range(s) {
|
||||
Some((_, a, b)) => para(s, p, |t| t.set_para_wrap_styles(a, b, w)),
|
||||
None => para(s, p, |t| t.set_wrap_style(w)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 };
|
||||
@@ -445,12 +454,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"text.wrapStyle",
|
||||
"Wrap style",
|
||||
"{style: auto|balance|pretty, ids?} where lines break when the text wraps: Auto fits as many words as fit on each line, Balance makes the lines close to the same length, Pretty keeps lines full but leaves no one word alone on the last",
|
||||
"{style: auto|balance|pretty, ids?} where lines break when the text wraps (while editing: the selected paragraphs; else the whole layer): Auto fits as many words as fit on each line, Balance makes the lines close to the same length, Pretty keeps lines full but leaves no one word alone on the last",
|
||||
true,
|
||||
|s, p| {
|
||||
let w: figcraft_doc::WrapStyle =
|
||||
get(p, "style").and_then(super::props::enum_of).ok_or_else(|| bad("text.wrapStyle", "`style`: auto|balance|pretty"))?;
|
||||
para(s, p, |t| t.wrap_style = w)
|
||||
set_wrap_style(s, p, w)
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
|
||||
@@ -100,12 +100,16 @@ fn zoom_to(s: &mut Session, z: f64) -> Result<Value> {
|
||||
Ok(json!({"zoom": v.zoom}))
|
||||
}
|
||||
|
||||
/// How far an arrow key pans the view with nothing selected (screen pixels; Shift: ten times).
|
||||
pub const PAN_STEP_PX: f64 = 20.0;
|
||||
/// How far an arrow key pans the view with nothing selected (screen pixels). Measured in Figma at
|
||||
/// 100% zoom: about 64 px (62-64 in two trials, +/-3 px).
|
||||
pub const PAN_STEP_PX: f64 = 64.0;
|
||||
|
||||
/// Shift multiplies the arrow pan by this much (Figma: about 320 px, five steps).
|
||||
pub const PAN_BIG_STEPS: f64 = 5.0;
|
||||
|
||||
/// `view.pan`: the arrow keys with nothing selected move the view by keyboard steps.
|
||||
fn pan(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let step = if boolean(p, "big").unwrap_or(false) { PAN_STEP_PX * 10.0 } else { PAN_STEP_PX };
|
||||
let step = if boolean(p, "big").unwrap_or(false) { PAN_STEP_PX * PAN_BIG_STEPS } else { PAN_STEP_PX };
|
||||
let dir = |k| num(p, k).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-100.0, 100.0) * step;
|
||||
let (dx, dy) = (dir("dx"), dir("dy"));
|
||||
let v = view(s)?;
|
||||
@@ -318,7 +322,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("view.multiplayerCursors", "Multiplayer cursors", "{on?} others' cursors in a live session", false, |s, p| {
|
||||
toggle(s, p, "view.multiplayerCursors")
|
||||
}),
|
||||
spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (ten times as far, like Shift)}", false, pan),
|
||||
spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (five times as far, like Shift)}", false, pan),
|
||||
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| toggle(s, p, "view.outlines")),
|
||||
spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| {
|
||||
toggle(s, p, "view.outlinesSelection")
|
||||
@@ -698,4 +702,45 @@ mod start_on_home_tests {
|
||||
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
|
||||
assert!(!old.start_on_home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_arrows_pan_64_px_and_320_with_shift_as_measured_in_figma() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
|
||||
s.execute("view.pan", &json!({"dx": 1})).unwrap();
|
||||
let v = &s.ds().unwrap().view;
|
||||
assert_eq!((v.cx, v.cy), (364.0, 200.0), "one press pans 64 px");
|
||||
s.execute("view.pan", &json!({"dy": -1, "big": true})).unwrap();
|
||||
let v = &s.ds().unwrap().view;
|
||||
assert_eq!((v.cx, v.cy), (364.0, -120.0), "Shift pans 320 px");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_arrow_pan_is_the_same_screen_distance_at_any_zoom_on_both_axes() {
|
||||
// (zoom, dx, dy, big, expected centre change in document px)
|
||||
let cases = [
|
||||
(2.0, 1.0, 0.0, false, 32.0, 0.0),
|
||||
(0.5, 1.0, 0.0, false, 128.0, 0.0),
|
||||
(2.0, 0.0, 1.0, true, 0.0, 160.0),
|
||||
(0.5, 0.0, 1.0, true, 0.0, 640.0),
|
||||
(1.0, -1.0, 0.0, false, -64.0, 0.0),
|
||||
(1.0, 1.0, 0.0, true, 320.0, 0.0),
|
||||
(1.0, -1.0, 0.0, true, -320.0, 0.0),
|
||||
(1.0, 0.0, -1.0, false, 0.0, -64.0),
|
||||
(1.0, 0.0, 1.0, false, 0.0, 64.0),
|
||||
(1.0, 0.0, -1.0, true, 0.0, -320.0),
|
||||
];
|
||||
for (zoom, dx, dy, big, ex, ey) in cases {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("view.set", &json!({"zoom": zoom, "cx": 300, "cy": 200})).unwrap();
|
||||
s.execute("view.pan", &json!({"dx": dx, "dy": dy, "big": big})).unwrap();
|
||||
let v = &s.ds().unwrap().view;
|
||||
let got = (v.cx - 300.0, v.cy - 200.0);
|
||||
assert!(
|
||||
(got.0 - ex).abs() < 1e-6 && (got.1 - ey).abs() < 1e-6,
|
||||
"zoom {zoom} dx {dx} dy {dy} big {big}: moved {got:?}, expected {:?}",
|
||||
(ex, ey)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,10 +302,12 @@ fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tok
|
||||
TextAlignH::Right => put("text-align", "right".into()),
|
||||
TextAlignH::Justified => put("text-align", "justify".into()),
|
||||
}
|
||||
match tx.wrap_style {
|
||||
figcraft_doc::WrapStyle::Auto => {}
|
||||
figcraft_doc::WrapStyle::Balance => put("text-wrap", "balance".into()),
|
||||
figcraft_doc::WrapStyle::Pretty => put("text-wrap", "pretty".into()),
|
||||
// CSS has one `text-wrap` per element: written when every paragraph has the same one.
|
||||
let wrap = (0..tx.paragraph_count()).map(|p| tx.para_wrap_style(p)).collect::<Vec<_>>();
|
||||
match wrap.first().copied().filter(|w| wrap.iter().all(|x| x == w)) {
|
||||
Some(figcraft_doc::WrapStyle::Balance) => put("text-wrap", "balance".into()),
|
||||
Some(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));
|
||||
|
||||
@@ -418,6 +418,51 @@ fn text_styles_carry_paragraph_spacing_indent_lists_and_features() {
|
||||
assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"paragraphSpacing": "wide"}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_styles_carry_the_wrap_style() {
|
||||
// Explore text properties: a wrap style can be set on the text layer, a paragraph or a text
|
||||
// style. A style made from a text takes its wrap style, applying it sets it,
|
||||
// editing it on a styled text unlinks it.
|
||||
use figcraft_doc::WrapStyle::{Auto, Balance, Pretty};
|
||||
let mut s = Session::new();
|
||||
let td = |s: &Session, n: NodeId| -> TextData { node(s, n).kind.text().unwrap().clone() };
|
||||
let t = text(&mut s, 0.0);
|
||||
run(&mut s, "node.set", json!({"id": t, "props": {"characters": "One\nTwo", "wrapStyle": "balance"}}));
|
||||
let st = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Even", "from": t})));
|
||||
let listed = run(&mut s, "style.list", json!({"type": "text"}));
|
||||
let even = listed.as_array().unwrap().iter().find(|x| x["id"] == json!(st)).unwrap();
|
||||
assert_eq!(even["paragraph"]["wrap_style"], json!("balance"), "{even}");
|
||||
// Applying it sets the layer's wrap style, with no paragraph overrides, and links the layer.
|
||||
let t2 = text(&mut s, 300.0);
|
||||
run(&mut s, "node.set", json!({"id": t2, "props": {"characters": "A\nB"}}));
|
||||
run(&mut s, "style.apply", json!({"ids": [t2], "style": st}));
|
||||
let d = td(&s, t2);
|
||||
assert_eq!(d.wrap_style, Balance);
|
||||
assert!(d.paragraphs.iter().all(|p| p.wrap.is_none()), "{:?}", d.paragraphs);
|
||||
assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str()));
|
||||
// Redefining the style moves the layer and keeps the link.
|
||||
run(&mut s, "style.update", json!({"style": st, "value": {"wrapStyle": "pretty"}}));
|
||||
let d = td(&s, t2);
|
||||
assert_eq!((d.para_wrap_style(0), d.para_wrap_style(1)), (Pretty, Pretty));
|
||||
assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str()), "still linked after the update");
|
||||
// Editing the wrap style on the styled text unlinks it.
|
||||
run(&mut s, "node.set", json!({"id": t2, "props": {"wrapStyle": "auto"}}));
|
||||
assert_eq!(td(&s, t2).wrap_style, Auto);
|
||||
assert_eq!(node(&s, t2).styles.text, None, "wrap style edited: unlinked");
|
||||
// On a range, the paragraphs touched take it as their own and the layer keeps its style.
|
||||
let t3 = text(&mut s, 600.0);
|
||||
run(&mut s, "node.set", json!({"id": t3, "props": {"characters": "A\nB"}}));
|
||||
run(&mut s, "style.apply", json!({"ids": [t3], "style": st, "start": 0, "end": 1}));
|
||||
let d = td(&s, t3);
|
||||
assert_eq!((d.wrap_style, d.para_wrap_style(0), d.para_wrap_style(1)), (Auto, Pretty, Auto));
|
||||
// A definition given as a value takes the node.set spellings; junk is refused.
|
||||
let st2 = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Note", "value": {"wrapStyle": "balance"}})));
|
||||
let want = ParagraphStyle { wrap_style: Balance, ..Default::default() };
|
||||
assert!(matches!(&s.doc().unwrap().style(&st2).unwrap().kind, StyleKind::Text { paragraph, .. } if **paragraph == want));
|
||||
assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"wrapStyle": "zigzag"}})).is_err());
|
||||
assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"textWrap": 3}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_are_organised_by_slash_names_order_and_duplicates() {
|
||||
// Documented: "/" in a name groups styles in folders; moving a style to another folder renames
|
||||
|
||||
@@ -495,6 +495,92 @@ fn balance_and_pretty_wrap_styles_break_lines_as_documented() {
|
||||
assert_eq!(lines(&s), [(0, 9), (9, 16), (16, 21)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_style_per_paragraph_overrides_the_layers() {
|
||||
// Explore text properties: a wrap style can be set on the text layer, a paragraph or a text
|
||||
// style. A paragraph's own wrap style beats the layer's; setting the layer
|
||||
// sets every paragraph again.
|
||||
let font = test_mono_font();
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "aaa bbb ccc ddd eee\naaa bbb ccc ddd eee", json!({"fontFamily": font, "fontSize": 16, "lineHeight": 24}));
|
||||
s.execute("node.set", &json!({"id": t, "props": {"textAutoResize": "height", "width": 136}})).unwrap();
|
||||
let lines = |s: &Session| -> Vec<(usize, usize)> {
|
||||
let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap();
|
||||
l.lines.iter().map(|l| (l.char_start, l.char_end)).collect()
|
||||
};
|
||||
let inspect = |s: &Session| crate::inspect::node_json(s.doc().unwrap(), t, 0);
|
||||
let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": t})).unwrap()["code"].as_str().unwrap().to_string();
|
||||
let paragraphs = |s: &Session| s.doc().unwrap().node(t).unwrap().kind.text().unwrap().paragraphs.clone();
|
||||
assert_eq!(lines(&s), [(0, 16), (16, 20), (20, 36), (36, 39)], "Auto: four words and one in each paragraph");
|
||||
// The second paragraph alone, by a range on node.set.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance", "start": 20, "end": 20}})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 16), (16, 20), (20, 32), (32, 39)]);
|
||||
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "balance"]));
|
||||
assert!(inspect(&s).get("wrapStyle").is_none());
|
||||
assert!(!css(&mut s).contains("text-wrap"), "{}", css(&mut s));
|
||||
// While editing, the caret's paragraph (the Wrap style control).
|
||||
s.execute("text.edit.begin", &json!({"id": t, "caret": 0})).unwrap();
|
||||
s.execute("text.wrapStyle", &json!({"style": "pretty"})).unwrap();
|
||||
s.execute("text.edit.end", &json!({})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 12), (12, 20), (20, 32), (32, 39)]);
|
||||
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["pretty", "balance"]));
|
||||
// Undo restores the paragraph's override.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 16), (16, 20), (20, 32), (32, 39)]);
|
||||
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "balance"]));
|
||||
// Enter inside the balanced paragraph carries the style to the new paragraph.
|
||||
s.execute("text.edit.begin", &json!({"id": t, "caret": 22})).unwrap();
|
||||
s.execute("text.edit.insert", &json!({"text": "\n"})).unwrap();
|
||||
s.execute("text.edit.end", &json!({})).unwrap();
|
||||
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "balance", "balance"]));
|
||||
// The layer level sets every paragraph and folds the overrides away.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance"}})).unwrap();
|
||||
assert!(paragraphs(&s).iter().all(|p| p.wrap.is_none()), "{:?}", paragraphs(&s));
|
||||
assert_eq!(inspect(&s)["wrapStyle"], json!("balance"));
|
||||
assert!(css(&mut s).contains("text-wrap: balance;"), "{}", css(&mut s));
|
||||
assert!(s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "zigzag", "start": 0, "end": 0}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_wrap_style_without_editing_sets_the_layer_and_folds_paragraph_overrides() {
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "one\ntwo\nthree", json!({}));
|
||||
let inspect = |s: &Session| crate::inspect::node_json(s.doc().unwrap(), t, 0);
|
||||
let td = |s: &Session| s.doc().unwrap().node(t).unwrap().kind.text().unwrap().clone();
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance", "start": 5, "end": 5}})).unwrap();
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "pretty", "start": 9, "end": 9}})).unwrap();
|
||||
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "balance", "pretty"]));
|
||||
assert!(s.ds().unwrap().text_edit.is_none(), "no edit session");
|
||||
s.execute("text.wrapStyle", &json!({"ids": [t], "style": "balance"})).unwrap();
|
||||
let after = td(&s);
|
||||
assert!(after.paragraphs.iter().all(|p| p.wrap.is_none()), "{:?}", after.paragraphs);
|
||||
assert_eq!(after.wrap_style, figcraft_doc::WrapStyle::Balance);
|
||||
assert!((0..3).all(|p| after.para_wrap_style(p) == figcraft_doc::WrapStyle::Balance));
|
||||
assert_eq!(inspect(&s)["wrapStyle"], json!("balance"));
|
||||
assert!(inspect(&s).get("lineWrapStyles").is_none());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "balance", "pretty"]), "undo restores the overrides");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_reports_wrap_style_only_when_not_auto_and_counts_a_trailing_empty_paragraph() {
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "one\ntwo\n", json!({}));
|
||||
let inspect = |s: &Session| crate::inspect::node_json(s.doc().unwrap(), t, 0);
|
||||
let quiet = |s: &Session| inspect(s).get("wrapStyle").is_none() && inspect(s).get("lineWrapStyles").is_none();
|
||||
assert!(quiet(&s), "uniform Auto says nothing");
|
||||
// An override on the empty last paragraph (a caret after the final newline) is listed with the others.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance", "start": 8, "end": 8}})).unwrap();
|
||||
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "auto", "balance"]));
|
||||
assert!(inspect(&s).get("wrapStyle").is_none());
|
||||
// Every paragraph the same non-Auto style: the one key; back to Auto: neither.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance"}})).unwrap();
|
||||
assert_eq!(inspect(&s)["wrapStyle"], json!("balance"));
|
||||
assert!(inspect(&s).get("lineWrapStyles").is_none());
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "auto"}})).unwrap();
|
||||
assert!(quiet(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines() {
|
||||
// "Max lines is available only if truncate text is enabled, and: Text resizing is set to
|
||||
|
||||
@@ -278,6 +278,7 @@ fn lists(v: &Value, td: &mut TextData) {
|
||||
Some("RTL") => TextDirection::Rtl,
|
||||
_ => TextDirection::Auto,
|
||||
},
|
||||
wrap: None,
|
||||
})
|
||||
.collect();
|
||||
// Clamp the levels and fold one style for all back into the layer's list style.
|
||||
|
||||
@@ -33,7 +33,7 @@ const MAX_CACHED_IMAGES: usize = 256;
|
||||
/// The largest sides of the copies the canvas draws larger images from while they show smaller
|
||||
/// (device pixels): two 16 000 × 10 000 images take 640 MB each in full, past the cache's budget
|
||||
/// together, and 42 MB each at 4096 (168 MB at 8192).
|
||||
pub const DISPLAY_SIDES: [u32; 2] = [4096, 8192];
|
||||
pub const DISPLAY_SIDES: [u32; 6] = [256, 512, 1024, 2048, 4096, 8192];
|
||||
|
||||
/// Decoded pixmaps by image, filters and profile, holding at most a byte budget of pixels: adding
|
||||
/// one past the budget (or past [`MAX_CACHED_IMAGES`]) drops the least recently used first, so
|
||||
|
||||
@@ -8,7 +8,7 @@ use figcraft_color::{ImageScaleMode, Paint};
|
||||
use figcraft_doc::{Document, ImageData, NodeId, NodeKind};
|
||||
use tiny_skia::Pixmap;
|
||||
|
||||
use crate::images::{self, DISPLAY_SIDES, MAX_CACHED_BYTES};
|
||||
use crate::images::{self, MAX_CACHED_BYTES};
|
||||
use crate::{Options, View, render_view};
|
||||
|
||||
const W: u32 = 6000;
|
||||
@@ -63,12 +63,11 @@ fn sharp_stripes(p: &Pixmap, y: u32, black_first: bool, each: u32) -> bool {
|
||||
#[test]
|
||||
fn zoomed_out_the_canvas_draws_both_images_from_copies_that_stay_decoded() {
|
||||
let (d, page, keys) = doc("zoomed-out");
|
||||
// At 25% each shows 1500 px long: drawn from 4096 px copies, not decoded in full.
|
||||
render_view(&d, page, &view(0.25), &canvas()).unwrap();
|
||||
let first = keys.each_ref().map(|k| shown(&d, k, 1500.0));
|
||||
for (k, p) in keys.iter().zip(&first) {
|
||||
assert_eq!((p.width(), p.height()), (DISPLAY_SIDES[0], 5), "{k}");
|
||||
assert_eq!(images::cached_sides(k), vec![DISPLAY_SIDES[0]], "{k}: only its copy is decoded");
|
||||
assert_eq!((p.width(), p.height()), (2048, 3), "{k}");
|
||||
assert_eq!(images::cached_sides(k), vec![2048], "{k}: only its copy is decoded");
|
||||
}
|
||||
// Drawn again (a drag, a pan), both are still the copies decoded the first time.
|
||||
render_view(&d, page, &view(0.25), &canvas()).unwrap();
|
||||
@@ -93,13 +92,15 @@ fn exports_and_close_ups_draw_from_the_image_itself() {
|
||||
#[test]
|
||||
fn the_copy_is_the_smallest_that_holds_what_shows() {
|
||||
let side = |long, shown| images::display_side(long, shown);
|
||||
assert_eq!(side(6000, 1500.0), 4096);
|
||||
assert_eq!(side(6000, 1500.0), 2048);
|
||||
assert_eq!(side(16_000, 4096.0), 4096);
|
||||
assert_eq!(side(16_000, 4097.0), 8192);
|
||||
assert_eq!(side(4096, 100.0), 256);
|
||||
assert_eq!(side(3000, 20.0), 256);
|
||||
// Shown at more than the largest copy, or an image no larger than the copy: the image itself.
|
||||
assert_eq!(side(16_000, 9000.0), 16_384);
|
||||
assert_eq!(side(6000, 5000.0), 16_384);
|
||||
assert_eq!(side(4096, 100.0), 16_384);
|
||||
assert_eq!(side(200, 100.0), 16_384);
|
||||
// An unknown size or a meaningless scale: the image itself.
|
||||
assert_eq!(side(0, 100.0), 16_384);
|
||||
assert_eq!(side(16_000, f64::NAN), 16_384);
|
||||
|
||||
@@ -869,7 +869,7 @@ fn line_width(items: &[Item], (start, end, _, _): LineRange) -> f64 {
|
||||
/// to give a word to the lines after it ("keeps each line full, but adjusts the last four lines to
|
||||
/// avoid an orphan"). `rebreak` lays the text out again with a width per paragraph and line.
|
||||
fn restyle_breaks(
|
||||
style: WrapStyle,
|
||||
style_of: &dyn Fn(usize) -> WrapStyle,
|
||||
w: f64,
|
||||
lines: Vec<LineRange>,
|
||||
items: &[Item],
|
||||
@@ -905,7 +905,7 @@ fn restyle_breaks(
|
||||
}
|
||||
// Words wider than the box break where they must; the re-breaking adds no such break.
|
||||
let base = emergency_breaks(items, word_breaks, ¶);
|
||||
match style {
|
||||
match style_of(p) {
|
||||
WrapStyle::Auto => {}
|
||||
WrapStyle::Balance => {
|
||||
// The narrowest width that still gives `n` lines, to half a pixel.
|
||||
@@ -992,8 +992,8 @@ fn wrapped_lines(
|
||||
let lines = from_saved.or_else(|| breaks(&|_, _| wrap, None)).unwrap_or_default();
|
||||
// Balance and Pretty re-break the paragraphs at narrower widths (never saved breaks).
|
||||
match wrap {
|
||||
Some(w) if !followed && text.wrap_style != WrapStyle::Auto => {
|
||||
restyle_breaks(text.wrap_style, w, lines, items, word_breaks, line_indent_of, hung_of, &|wrap_of| {
|
||||
Some(w) if !followed && (0..text.paragraph_count()).any(|p| text.para_wrap_style(p) != WrapStyle::Auto) => {
|
||||
restyle_breaks(&|p| text.para_wrap_style(p), w, lines, items, word_breaks, line_indent_of, hung_of, &|wrap_of| {
|
||||
breaks(wrap_of, None).unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -62,12 +62,12 @@ fn control_key_arrows_pan_with_nothing_selected() {
|
||||
(v.cx, v.cy)
|
||||
};
|
||||
call(&mut u, "ui.key", json!({"key": "ArrowRight"})).unwrap();
|
||||
assert_eq!(view(&u), (10.0, 0.0), "20 screen pixels at 200%");
|
||||
assert_eq!(view(&u), (32.0, 0.0), "64 screen pixels at 200% are 32 document pixels");
|
||||
call(&mut u, "ui.key", json!({"key": "ArrowUp", "shift": true})).unwrap();
|
||||
assert_eq!(view(&u), (10.0, -100.0), "Shift: ten times as far");
|
||||
assert_eq!(view(&u), (32.0, -160.0), "Shift: 320 screen pixels, 160 document pixels");
|
||||
u.app.run("select.set", json!({"ids": [r]}));
|
||||
call(&mut u, "ui.key", json!({"key": "ArrowRight"})).unwrap();
|
||||
assert_eq!(view(&u), (10.0, -100.0), "with a selection the view stays");
|
||||
assert_eq!(view(&u), (32.0, -160.0), "with a selection the view stays");
|
||||
let id = figcraft_engine::params::id_of(&r).unwrap();
|
||||
assert_eq!(u.app.session.doc().unwrap().world_bounds(id).unwrap().x0, 1.0, "the selection moved instead");
|
||||
// With a shape tool chosen and nothing selected the arrows move the placement crosshair
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Which submenu the main and context menus show, driven through egui headless: a closed menu
|
||||
//! reopens with no submenu open (also one level down), hovering another row closes the open
|
||||
//! submenu at once while a submenu opens only once the pointer stays on its row a moment
|
||||
//! (sweeping across rows doesn't flash each one), and on the way from a
|
||||
//! submenu at once and a submenu opens on the first frame its row is hovered (as Figma's does),
|
||||
//! and on the way from a
|
||||
//! submenu's row to its submenu the rows the pointer crosses don't take over (the safe triangle),
|
||||
//! unless it rests on one or turns away.
|
||||
|
||||
@@ -214,7 +214,7 @@ fn resting_on_a_crossed_row_hands_over_to_it() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_straight_down_switches_after_a_short_hover() {
|
||||
fn moving_straight_down_switches_at_once() {
|
||||
let mut u = Ui::new();
|
||||
u.open_main_menu();
|
||||
let file = u.top_row(0);
|
||||
@@ -227,24 +227,21 @@ fn moving_straight_down_switches_after_a_short_hover() {
|
||||
p.y = (p.y + 4.0).min(edit.y);
|
||||
u.hover(p);
|
||||
}
|
||||
assert!(u.tops_shown().is_empty(), "File closes at once, Edit waits: {:?}", u.tops_shown());
|
||||
u.wait(HOVER_DELAY + 0.05);
|
||||
assert_eq!(u.tops_shown(), ["Edit"]);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.tops_shown(), ["Edit"], "File closes at once, Edit opens on its first frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_submenu_opens_after_a_short_hover() {
|
||||
fn the_first_submenu_opens_on_the_next_frame() {
|
||||
let mut u = Ui::new();
|
||||
u.open_main_menu();
|
||||
u.hover(u.top_row(0).center());
|
||||
u.frame(vec![]);
|
||||
assert!(u.tops_shown().is_empty(), "opened at once");
|
||||
u.wait(HOVER_DELAY + 0.05);
|
||||
assert_eq!(u.tops_shown(), ["File"]);
|
||||
assert_eq!(u.tops_shown(), ["File"], "Figma opens it on the first frame (median 11 ms)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweeping_across_rows_opens_only_where_the_pointer_stops() {
|
||||
fn sweeping_across_rows_shows_only_the_row_under_the_pointer() {
|
||||
let mut u = Ui::new();
|
||||
u.open_main_menu();
|
||||
u.settle(u.top_row(0).center());
|
||||
@@ -254,9 +251,8 @@ fn sweeping_across_rows_opens_only_where_the_pointer_stops() {
|
||||
u.hover(pos2(r.min.x + 20.0, r.center().y));
|
||||
// (A panel drawn above the row the pointer is on goes the next frame.)
|
||||
u.frame(vec![]);
|
||||
assert!(u.tops_shown().is_empty(), "{:?} open on row {i}", u.tops_shown());
|
||||
assert_eq!(u.tops_shown(), [TOPS[i]], "wrong submenu open on row {i}");
|
||||
}
|
||||
u.wait(HOVER_DELAY + 0.05);
|
||||
assert_eq!(u.tops_shown(), ["Arrange"]);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ pub(crate) fn submenu<R>(ui: &mut Ui, key: &str, label: &str, t: &Tokens, add: i
|
||||
// Which submenu is open: hover intent with a safe triangle.
|
||||
//
|
||||
// Every menu tree (the main menu, the context menu) keeps, per level, which of its submenus is
|
||||
// open. Hovering another row closes the open submenu at once; resting on a submenu row for
|
||||
// `HOVER_DELAY` opens it (sweeping across rows doesn't flash every submenu on the way). Except while the
|
||||
// open. Hovering a row closes the open submenu at once and opens the row's own submenu on the
|
||||
// same frame (as Figma's main menu does, measured; `HOVER_DELAY` is zero). Except while the
|
||||
// pointer is on its way from the open submenu's row to the submenu: while each move lands inside
|
||||
// the triangle between where it was and the submenu's near edge (it keeps getting nearer), rows
|
||||
// it crosses don't take over and the submenu's row stays highlighted. Resting (`AIM_REST`) or
|
||||
@@ -20,8 +20,10 @@ pub(crate) fn submenu<R>(ui: &mut Ui, key: &str, label: &str, t: &Tokens, add: i
|
||||
// last submenu open.
|
||||
// Closing a menu tree forgets all of it, so it reopens with no submenu open.
|
||||
|
||||
/// How long the pointer stays on a submenu row before its submenu opens.
|
||||
pub(crate) const HOVER_DELAY: f64 = 0.15;
|
||||
/// How long the pointer stays on a submenu row before its submenu opens. None: Figma's main menu
|
||||
/// opens it on the first frame (median 11 ms measured), so the delay is zero; kept as a constant so
|
||||
/// the pending-row bookkeeping the aim relies on stays as it was.
|
||||
pub(crate) const HOVER_DELAY: f64 = 0.0;
|
||||
|
||||
/// How long the pointer may rest inside the safe triangle before the row under it takes over.
|
||||
pub(crate) const AIM_REST: f64 = 0.3;
|
||||
|
||||
@@ -210,6 +210,7 @@ pub(crate) fn field_row<R>(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui, f
|
||||
#[cfg(test)]
|
||||
mod variable_tests {
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, RawInput};
|
||||
use figcraft_doc::WrapStyle;
|
||||
use figcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
@@ -223,6 +224,8 @@ mod variable_tests {
|
||||
ctx: egui::Context,
|
||||
time: f64,
|
||||
text: NodeId,
|
||||
/// The AccessKit nodes of the last frame.
|
||||
nodes: Vec<egui::accesskit::Node>,
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
@@ -236,7 +239,8 @@ mod variable_tests {
|
||||
s.execute("variable.create", &json!({"collection": "Type", "name": name, "type": ty, "values": {"Base": value}})).unwrap();
|
||||
}
|
||||
s.execute("select.set", &json!({ "ids": [text] })).unwrap();
|
||||
let mut p = Self { app: FigApp::new(s, Box::new(NoServices)), ctx: egui::Context::default(), time: 0.0, text };
|
||||
let mut p = Self { app: FigApp::new(s, Box::new(NoServices)), ctx: egui::Context::default(), time: 0.0, text, nodes: Vec::new() };
|
||||
p.ctx.enable_accesskit();
|
||||
crate::theme::install_fonts(&p.ctx, false, 0);
|
||||
crate::theme::apply(&p.ctx, Tokens::light());
|
||||
for _ in 0..3 {
|
||||
@@ -269,6 +273,9 @@ mod variable_tests {
|
||||
crate::variable_ui::picker(&mut self.app, ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
if let Some(update) = out.platform_output.accesskit_update.take() {
|
||||
self.nodes = update.nodes.into_iter().map(|(_, n)| n).collect();
|
||||
}
|
||||
let mut texts = Vec::new();
|
||||
for c in out.shapes {
|
||||
walk(&c.shape, &mut texts);
|
||||
@@ -351,4 +358,75 @@ mod variable_tests {
|
||||
assert!(shows(&texts, "Inter") && shows(&texts, "Bold"), "the dropdowns show the last values");
|
||||
assert!(p.app.ui.last_error.is_none(), "{:?}", p.app.ui.last_error);
|
||||
}
|
||||
|
||||
/// The centre of the AccessKit node labelled `label` (a button, say).
|
||||
fn node_center(p: &Panel, label: &str) -> Pos2 {
|
||||
let n = p.nodes.iter().find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("no node {label:?}"));
|
||||
let r = n.bounds().expect("bounds");
|
||||
Pos2::new(((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32)
|
||||
}
|
||||
|
||||
const WRAP: [&str; 3] = [
|
||||
"Auto: as many words as fit on each line",
|
||||
"Balance: every line close to the same length",
|
||||
"Pretty: full lines, no one word alone on the last",
|
||||
];
|
||||
|
||||
/// The Wrap style buttons that are on, by short name.
|
||||
fn wrap_on(p: &Panel) -> Vec<&'static str> {
|
||||
let on = |label: &str| p.nodes.iter().find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("no node {label:?}")).toggled();
|
||||
["Auto", "Balance", "Pretty"].into_iter().zip(WRAP).filter(|(_, l)| on(l) == Some(egui::accesskit::Toggled::True)).map(|(n, _)| n).collect()
|
||||
}
|
||||
|
||||
/// The panel with the Type settings popover open, over two paragraphs.
|
||||
fn settings_panel() -> Panel {
|
||||
let mut p = Panel::new();
|
||||
p.app.session.execute("node.set", &json!({"id": p.text, "props": {"characters": "one\ntwo"}})).unwrap();
|
||||
p.frame(vec![]);
|
||||
let at = node_center(&p, "Type settings");
|
||||
p.click(at);
|
||||
p.frame(vec![]);
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_style_control_follows_the_caret_paragraph() {
|
||||
// #1827: with the caret in one paragraph the control shows and sets that paragraph's style.
|
||||
let mut p = settings_panel();
|
||||
assert_eq!(wrap_on(&p), ["Auto"], "the layer's style when not editing");
|
||||
figcraft_engine::textedit::begin(&mut p.app.session, p.text, Some(5), false).unwrap();
|
||||
p.frame(vec![]);
|
||||
assert_eq!(wrap_on(&p), ["Auto"]);
|
||||
let at = node_center(&p, WRAP[1]);
|
||||
p.click(at);
|
||||
let td = |p: &Panel| p.app.session.doc().unwrap().node(p.text).unwrap().kind.text().unwrap().clone();
|
||||
assert_eq!((td(&p).para_wrap_style(0), td(&p).para_wrap_style(1)), (WrapStyle::Auto, WrapStyle::Balance), "only the caret's paragraph");
|
||||
assert_eq!(td(&p).wrap_style, WrapStyle::Auto);
|
||||
assert_eq!(wrap_on(&p), ["Balance"], "the control shows the paragraph's value");
|
||||
figcraft_engine::textedit::set_caret(&mut p.app.session, 1, false).unwrap();
|
||||
p.frame(vec![]);
|
||||
assert_eq!(wrap_on(&p), ["Auto"], "another paragraph shows its own");
|
||||
assert!(p.app.ui.last_error.is_none(), "{:?}", p.app.ui.last_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_style_control_has_nothing_active_for_mixed_paragraphs() {
|
||||
let mut p = settings_panel();
|
||||
figcraft_engine::textedit::begin(&mut p.app.session, p.text, Some(5), false).unwrap();
|
||||
p.frame(vec![]);
|
||||
let at = node_center(&p, WRAP[2]);
|
||||
p.click(at);
|
||||
assert_eq!(wrap_on(&p), ["Pretty"]);
|
||||
// A selection across both paragraphs, which differ: none is on.
|
||||
figcraft_engine::textedit::set_caret(&mut p.app.session, 1, false).unwrap();
|
||||
figcraft_engine::textedit::set_caret(&mut p.app.session, 6, true).unwrap();
|
||||
p.frame(vec![]);
|
||||
assert!(wrap_on(&p).is_empty(), "{:?}", wrap_on(&p));
|
||||
// Choosing one sets both paragraphs.
|
||||
let at = node_center(&p, WRAP[1]);
|
||||
p.click(at);
|
||||
assert_eq!(wrap_on(&p), ["Balance"]);
|
||||
let t = p.app.session.doc().unwrap().node(p.text).unwrap().kind.text().unwrap().clone();
|
||||
assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1)), (WrapStyle::Balance, WrapStyle::Balance));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,7 +388,14 @@ fn type_settings(
|
||||
}
|
||||
// Where lines break when the text wraps (Explore text properties).
|
||||
caption(ui, tr("Wrap style"));
|
||||
let wrap = common(doc, ids, |n| n.kind.text().map(|t| t.wrap_style as usize)).flatten();
|
||||
// Per paragraph, as the text direction: while editing, the selected paragraphs.
|
||||
let wrap = 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));
|
||||
t.common_wrap_style(a, b).map(|w| w as usize)
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
let items = [
|
||||
(tr("Auto"), tr_noop("Auto: as many words as fit on each line")),
|
||||
(tr("Balance"), tr_noop("Balance: every line close to the same length")),
|
||||
@@ -396,7 +403,7 @@ fn type_settings(
|
||||
];
|
||||
if let Some(i) = text_seg(ui, "wrap", &items, wrap, w) {
|
||||
let style = ["auto", "balance", "pretty"].get(i).copied().unwrap_or("auto");
|
||||
app.set_props(ids, json!({"wrapStyle": style}));
|
||||
app.run("text.wrapStyle", json!({"ids": ids, "style": 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
|
||||
|
||||
@@ -44,13 +44,13 @@ fn arrows_pan_the_view_when_nothing_is_selected() {
|
||||
u.app.run("select.set", json!({"ids": []}));
|
||||
u.app.run("view.set", json!({"zoom": 2.0, "cx": 0, "cy": 0}));
|
||||
press(&mut u, Key::ArrowRight, Modifiers::NONE);
|
||||
assert_eq!(view(&u), (10.0, 0.0, 2.0), "20 screen pixels at 200%");
|
||||
assert_eq!(view(&u), (32.0, 0.0, 2.0), "64 screen pixels at 200% are 32 document pixels");
|
||||
press(&mut u, Key::ArrowUp, Modifiers::SHIFT);
|
||||
assert_eq!(view(&u), (10.0, -100.0, 2.0), "Shift: ten times as far");
|
||||
assert_eq!(view(&u), (32.0, -160.0, 2.0), "Shift: 320 screen pixels, 160 document pixels");
|
||||
// With a selection the arrows nudge it and the view stays.
|
||||
u.app.run("select.set", json!({"ids": [r]}));
|
||||
press(&mut u, Key::ArrowRight, Modifiers::NONE);
|
||||
assert_eq!(view(&u), (10.0, -100.0, 2.0));
|
||||
assert_eq!(view(&u), (32.0, -160.0, 2.0));
|
||||
assert_eq!(u.app.session.doc().unwrap().world_bounds(r).unwrap().x0, 1.0);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, Ui, pos2, vec2};
|
||||
use figcraft_color::{Paint, PaintKind};
|
||||
use figcraft_doc::{
|
||||
Document, GridKind, ListStyle, Node, NodeId, ParagraphStyle, PublishedLibrary, Style, StyleKind, TextStyle, VariableType, trim_num,
|
||||
Document, GridKind, ListStyle, Node, NodeId, ParagraphStyle, PublishedLibrary, Style, StyleKind, TextStyle, VariableType, WrapStyle, trim_num,
|
||||
};
|
||||
use figcraft_engine::library;
|
||||
use serde_json::{Value, json};
|
||||
@@ -1002,6 +1002,11 @@ fn paragraph_rows(ui: &mut Ui, ps: &ParagraphStyle) {
|
||||
ListStyle::Bulleted => rows.push(tr("Bulleted list").into()),
|
||||
ListStyle::Numbered => rows.push(tr("Numbered list").into()),
|
||||
}
|
||||
match ps.wrap_style {
|
||||
WrapStyle::Auto => {}
|
||||
WrapStyle::Balance => rows.push(format!("{}: {}", tr("Wrap style"), tr("Balance"))),
|
||||
WrapStyle::Pretty => rows.push(format!("{}: {}", tr("Wrap style"), tr("Pretty"))),
|
||||
}
|
||||
if !ps.features.is_empty() {
|
||||
rows.push(ps.features.iter().map(|(tag, on)| format!("{}{tag}", if *on { '+' } else { '-' })).collect::<Vec<_>>().join(" "));
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---|---|---|
|
||||
| Paint, text, effect, grid styles; style picker; text styles on character ranges | done | behaviour suite: `styles_behaviour_tests.rs`; a text style carries paragraph spacing and indent, the list style and the OpenType features beside the character properties (`paragraph` next to `style` in `style.list`): applied with it, moved by `style.update`, unlinking when edited, kept by `.fig` files (#1829) |
|
||||
| Paint, text, effect, grid styles; style picker; text styles on character ranges | done | behaviour suite: `styles_behaviour_tests.rs`; a text style carries paragraph spacing and indent, the list style, the wrap style and the OpenType features beside the character properties (`paragraph` next to `style` in `style.list`): applied with it, moved by `style.update`, unlinking when edited, kept by `.fig` files (#1829; not the wrap style yet: its `.fig` field is unknown, #1827) |
|
||||
| Editing a style updates its layers; detach and delete keep values; editing a styled property unlinks that style | done | an edited fill/stroke/effect/grid list or text chars unlink; applying a style replaces variables bound to what it sets; text styles on text layers only |
|
||||
| Organising styles: folders by `/` names, order, duplicate, rename folder, Local styles list | done | `style.move` (into a folder renames), `style.duplicate` (right below), `style.renameFolder`; the page's Local styles list by type and folder (drag to reorder, right-click) |
|
||||
| Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii, grid sizes and a text style's font, font style (a face name from a string variable, or a numeric weight from a number variable, which picks the nearest face and a variable font's `wght` axis), size, line height and letter spacing, the rest through the command; a bound line height is applied in pixels, as the reference tool does (measured 2026-11-10) |
|
||||
@@ -85,6 +85,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
| 2026-11-07 | patch | M3, #2088: with two or more layers selected the Design panel header's component button opens a menu with Create component, Create multiple components and Create component set (`component.createSet`, naming measured in Figma), as Figma's does; one layer keeps the plain button. Tests: `create_multiple_menu_tests`, `components_behaviour_tests::create_multiple_components_keeps_names_and_places_and_is_one_undo_step`, `components_behaviour_tests::create_set_*`. Grade unchanged. |
|
||||
| 2026-11-05 | minor | M3, #2002: the Local variables window selects several variables, as the Layers panel selects layers. A click selects one row, Shift+click the range from the anchor through the rows shown, Cmd/Ctrl+click toggles a row, Escape or a click on the empty space below the rows clears; selected rows use the sidebar's selected-row colour. Right-clicking a row that is part of a selection of several offers Delete, Move to group and Scope (all supported properties, hidden from pickers, or only one scope) for the selected local variables, each sent as one `command.batch`, so one undo step; no engine command changed. Renaming and value edits stay single-row. Tests: `variables_select_tests` (headless UI). Grade unchanged |
|
||||
| 2026-11-02 | minor | M3, #1829: text styles carry paragraph spacing and indent, the list style and the OpenType features ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType features", Create and apply text styles) beside the character properties: `style.create` takes them from the layer (or the selected paragraphs' list) or from a `value` (`paragraphSpacing`, `paragraphIndent`, `listStyle`, `features`), `style.apply` sets them (on a range, the list goes to the paragraphs touched; the rest is the layer's, as in Figma), `style.update` moves them on every linked layer, editing one on a styled text unlinks it as editing its font does, the style editor lists them, and a `.fig` file keeps them with the style (its sample layer). Grade unchanged. |
|
||||
| 2026-11-07 | minor | M4, #1827: text styles carry the wrap style (`wrapStyle` in a style's `paragraph`): applied with the style (on a range, to the paragraphs touched), moved by `style.update`, unlinking when edited; shown read-only in the style editor |
|
||||
| 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Grade unchanged. |
|
||||
| 2026-11-06 | patch | M3: a layer's effect binding (`variable.bind`, `effects/<i>/<field>`) takes only a field its effect has, as an effect style's does: `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have, is refused instead of being kept and doing nothing (a binding an older file has there still does nothing, and unbinds). Tests: `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`. Grade unchanged. |
|
||||
| 2026-11-06 | minor | M3: Go to main component (context menu, Design panel, Ctrl+Alt+Cmd+K) on an instance of a library component opens the library file, as the help center describes for mains kept in another file ("Edit main components"); it used to refuse. The file opens in a tab, or an open tab comes to the front, on the main's page with the main selected and zoomed to: a nested instance goes to its own main, a variant to that variant. A main deleted from the library file since it was published leaves the file open with a note; a library file that moved, a team library on a server and a path off this computer are refused with a message. Test: `library::tests::going_to_a_library_main_opens_the_library_file`. Numbers unchanged |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hardware and platform parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** patch (Windows: the custom title bar's windows stay borderless, so the content is no longer drawn about 30 px low with clicks landing above it (#1343); under Wayland a minimised window answers control and MCP requests in about half a second instead of timing out after 60 s (#1906); images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (unused; zoomed-out images draw from small copies that stay decoded, so a page of photos zooms out without decoding them again; under Wayland a minimised window answers control and MCP requests in about half a second instead of timing out after 60 s (#1906); images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
|
||||
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
|
||||
@@ -12,7 +12,7 @@ since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu
|
||||
|
||||
| Feature | Figma | FigCraft (macOS / Windows / Linux / web) | Hours |
|
||||
|---|---|---|---|
|
||||
| Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom, large images drawn from a copy no larger than they show (#1483); when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it (#312); shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path |
|
||||
| Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom, images drawn from the smallest copy that holds the size they show, down to 256 px (#1483); when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it (#312); shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path |
|
||||
| Effects (blurs, glass, noise) | GPU | CPU | in the above |
|
||||
| HiDPI / Retina | yes | yes (all); a canvas image rendered at the screen's density is placed on whole device pixels, so fractional pans, zooms and display scales don't blur it (#359; geometry tests, the snapping not judged by eye) | — |
|
||||
| Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.37 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 |
|
||||
@@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-10 | minor | Zoomed-out images: the canvas's copies of an image shown smaller than its pixels now also come at 256, 512, 1024 and 2048 px, the smallest that holds what the screen shows, and images of 4096 px or less get them too (they were drawn from the image itself at any size). On a page of 237 photos, about 4.6 GB decoded against the 512 MB cache, every zoomed-out render decoded them again: 3.4 s a frame at 1% before, 0.9 s the first time and 4 ms after; peak memory 3.9 → 2.3 GB. Exports, and images shown at their own size or larger, still draw from the image itself. |
|
||||
| 2026-11-10 | patch | M10, #1343 (also #1422, #2233): Windows: the window no longer draws below a hidden caption. FigCraft draws its own title bar on Windows and opens undecorated windows, but winit 0.30 still gives them `WS_CAPTION` and `WS_BORDER`; on some systems the desktop compositor reserves that caption, so the content was drawn about 30 px low (a black band above it) while clicks kept the client coordinates and landed above what was drawn. A per-frame step (`apps/figcraft/src/window_frame.rs`, safe `winsafe` calls, Windows only) clears `WS_CAPTION` and `WS_BORDER` from every FigCraft window, the main one and the others, keeping the sizing border and window buttons so edge resizing and snap work, and writes only when a style changed (winit puts them back on maximize and restore). The same fix as DesignCraft #2139 and PhotoCraft #2401; the maximize-to-work-area trim from DesignCraft is left for a later change. Not run on a Windows machine that shows the offset: the style math and the per-frame hook are tested, the window itself needs a live check. Tests: `window_frame_tests`. Numbers unchanged |
|
||||
| 2026-11-06 | patch | M8, #1906: under Wayland (seen on GNOME and KDE Plasma) a minimised FigCraft window still reports itself visible and the compositor stops asking it to draw, so every control or attached-MCP request waited for a frame and timed out after 60 s ("it was not applied"), though eframe kept running the app's logic. `control::background` now treats a window that drew no frame for `NOT_DRAWING_AFTER` (0.2 s) while a request waited for it as hidden: state-only requests are answered from the logic passes (0.54 s each, measured on GNOME Wayland; they took 13.7 s or timed out before), the wait starts again when the window draws, and screenshots and `ui.input` still wait for a frame. Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes` |
|
||||
| 2026-10-31 | minor | M1, #492: Web row: the browser pastes images copied in other apps, read from the paste event under the same limits as the desktop. Numbers unchanged. |
|
||||
|
||||
+11
-5
@@ -298,6 +298,11 @@ Or in a project's `.mcp.json`:
|
||||
`textDecorationOffset` (`auto`, px, or `"10%"` of the font size; the offset runs from the baseline
|
||||
down to an underline's top edge), `textDecorationSkipInk` (underlines break around descenders) and
|
||||
`textDecorationColor` (a colour, or `auto` for the text's) are character props too.
|
||||
- **Wrap style** (`auto`, `balance`, `pretty`) works at three levels: `wrapStyle` on `node.set`
|
||||
sets the layer (every paragraph); with `start`/`end` it sets the paragraphs those chars touch,
|
||||
and a paragraph's own style beats the layer's. `text.wrapStyle` sets the selected paragraphs while
|
||||
text is edited. `get_node` reports one `wrapStyle`, or `lineWrapStyles` per paragraph when they
|
||||
differ.
|
||||
- **Lists** are per paragraph: `listStyle` (`none`, `bulleted`, `numbered`) and `indentation` (the
|
||||
nesting level, 0–8) apply to the paragraphs chars `start`..`end` touch (all of them without a
|
||||
range); `get_node` reports one `listStyle` for the layer, or `lineTypes` and `lineIndentations`
|
||||
@@ -313,10 +318,11 @@ Or in a project's `.mcp.json`:
|
||||
`style.detach {which: "text", start?, end?}` unlinks them (values stay). A layer's `styles.text` is
|
||||
the style all its chars share (none when they differ); `get_node` lists the linked chars as
|
||||
`textStyleRanges` (`[start, end, style id]`).
|
||||
A text style also carries paragraph spacing and indent, the list style and the OpenType features
|
||||
(`paragraph` beside `style` in `style.list`): applying it sets them (on a range, the list goes to
|
||||
the paragraphs touched; the rest is the layer's), `style.update` moves them too, and a `.fig`
|
||||
file keeps them with the style.
|
||||
A text style also carries paragraph spacing and indent, the list style, the wrap style and the
|
||||
OpenType features (`paragraph` beside `style` in `style.list`; a `value` takes `wrapStyle`):
|
||||
applying it sets them (on a range, the list and wrap style go to the paragraphs touched; the rest
|
||||
is the layer's), `style.update` moves them too, and a `.fig` file keeps them with the style
|
||||
(not the wrap style yet).
|
||||
Changing what a text style sets (font, size, line height, letter spacing, decoration, case, axis
|
||||
values; at the paragraph level the spacing, indent, list and features) on linked chars unlinks
|
||||
them; `style.update` moves every linked char. `style.create {type: "text", name}` takes the
|
||||
@@ -638,7 +644,7 @@ on; `key` resolves keys the way the canvas does. Commands behind keys:
|
||||
`board.nextSticky` (`Shift+Cmd/Ctrl+Enter`, also while typing in a sticky). Keys no design-file
|
||||
shortcut uses; on a design file they refuse.
|
||||
- `view.pan {dx, dy, big?}`: the arrow keys with nothing selected (`key ArrowRight` does the same)
|
||||
pan the view by 20 screen pixels a step, 200 with `big` (Shift).
|
||||
pan the view by 64 screen pixels a step, 320 with `big` (Shift).
|
||||
- `view.multiplayerCursors {on?}` (`Alt+Cmd+\`): show or hide others' cursors in a live session.
|
||||
- `view.outlinesHiddenLayers {on?}` and `view.outlinesObjectBounds {on?}`: what outline mode
|
||||
(`view.outlines`, `view.outlinesSelection`) draws besides the shapes: hidden layers, and the box
|
||||
|
||||
+2
-1
File diff suppressed because one or more lines are too long
+7
-4
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user