From 340b673666bbd638c80dcd4b8d93f15bc3cd626b Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 13:03:09 +0700 Subject: [PATCH] M3: text depth: RTL names, text style browser, axis previews (#72) Right-to-left and mixed-direction names in the panels: layers, pages, Assets, tab titles, the file header and canvas frame titles show in visual order (Unicode bidi, joined Arabic letters, mirrored brackets), elided at their end (the left end of a right-to-left name). egui shapes each font run itself, so shown names are laid out a cluster at a time. Rename fields keep egui's editing and draw the shown text over it: the caret, selection, clicks, drags and arrow keys follow what's on screen. System fonts are added for scripts the built-in UI fonts lack. The Typography section's text style browser: list and grid views with each style in its own font, search, folders, this file's and enabled libraries' styles, apply, detach, edit the typography, create from the selected chars, hover to preview on the canvas, and "Mixed" when the chars use several styles. Text styles now link chars (style_id on text styles), so a range can take a style; editing what a style sets unlinks the chars edited; files from before link on open. Live text previews (text.preview / text.endPreview): a hovered text style, font style or named instance and a dragged axis slider show on the canvas without an undo step; release commits one step, Escape cancels. ui.input sends raw window input over the control channel. Visual QA: list markers keep the paragraph's font, renames start with the name selected and centred, Max lines shows while truncating. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 17 +- apps/figcraft/src/main.rs | 5 + crates/doc/src/lib.rs | 35 ++ crates/doc/src/text.rs | 83 +++ crates/engine/src/cmd/props.rs | 4 +- crates/engine/src/cmd/styles.rs | 239 ++++++-- crates/engine/src/cmd/text.rs | 15 + crates/engine/src/inspect.rs | 5 + crates/engine/src/lib.rs | 27 +- crates/engine/src/library.rs | 3 + crates/engine/src/text_style_picker_tests.rs | 234 ++++++++ crates/engine/src/textpreview.rs | 67 +++ crates/format/src/rest/mod.rs | 2 +- crates/text/src/display.rs | 399 ++++++++++++++ crates/text/src/display_tests.rs | 189 +++++++ crates/text/src/fontdb.rs | 12 + crates/text/src/layout.rs | 13 +- crates/text/src/lib.rs | 6 +- crates/text/src/list_tests.rs | 22 + crates/ui-egui/src/assets_panel.rs | 14 +- crates/ui-egui/src/bidi.rs | 311 +++++++++++ crates/ui-egui/src/canvas.rs | 13 +- crates/ui-egui/src/control.rs | 46 +- crates/ui-egui/src/credits_tests.rs | 2 +- crates/ui-egui/src/font_preview.rs | 100 ++++ crates/ui-egui/src/layers.rs | 19 +- crates/ui-egui/src/lib.rs | 58 +- crates/ui-egui/src/right.rs | 12 +- crates/ui-egui/src/rtl_names_tests.rs | 149 +++++ crates/ui-egui/src/style_picker.rs | 549 ++++++++++++++----- crates/ui-egui/src/style_picker_tests.rs | 97 +++- crates/ui-egui/src/tabs.rs | 16 +- crates/ui-egui/src/theme.rs | 88 ++- crates/ui-egui/src/typography.rs | 343 +++++++----- crates/ui-egui/src/widgets.rs | 31 +- docs/control-protocol.md | 29 +- docs/mcp.md | 13 + docs/parity.md | 2 +- 38 files changed, 2892 insertions(+), 377 deletions(-) create mode 100644 crates/engine/src/text_style_picker_tests.rs create mode 100644 crates/engine/src/textpreview.rs create mode 100644 crates/text/src/display.rs create mode 100644 crates/text/src/display_tests.rs create mode 100644 crates/text/src/list_tests.rs create mode 100644 crates/ui-egui/src/bidi.rs create mode 100644 crates/ui-egui/src/font_preview.rs create mode 100644 crates/ui-egui/src/rtl_names_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 75173c20..73e866f3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -61,8 +61,18 @@ dimension measured side by side. font, named instances as styles, Type settings ▸ Variable sliders, per-range axis values applied to shaping, outlines and PDF), range-aware Typography panel with "Mixed". Range fills in the Fill section. Right-to-left and mixed-direction text (Unicode bidi: Arabic shaping, mirrored brackets, - caret, clicks and selection follow the runs; arrow keys move visually). *Left:* axis hover - preview, right-to-left layer names in the panels, visual QA. 5–8 h. + caret, clicks and selection follow the runs; arrow keys move visually). Right-to-left and + mixed-direction names in the panels (layers, pages, Assets, tabs, the file header, canvas frame + titles: visual order, joined Arabic, the ellipsis at a right-to-left name's left end; rename fields + whose caret, clicks, drags and arrow keys follow the shown text; system fonts for scripts the UI + fonts lack). The text style browser in the Typography section (list and grid views in each + style's own font, search, folders, this file's and enabled libraries' styles, apply, detach, edit + the typography, create from the selected chars, hover to preview on the canvas, "Mixed" when the + chars use several styles); text styles link chars, not just layers (editing what a style sets + unlinks the chars edited). Live previews of a hovered named instance or font style and of a + dragged axis slider (one undo step on release, Escape cancels). Visual QA: list markers keep the + paragraph's font, renames start with the name selected and centred, Max lines shows while + truncating. *Left:* list markers on the right of right-to-left paragraphs. 1 h. 3. **Auto layout depth**: *landed:* on-canvas padding/gap handles (also wrapped-row and grid column/row gaps, grid tracks outlined), min/max width/height (UI, Hug/Fill/Fixed, hugging text wraps at its max), ignore auto layout (absolute position) with constraints, text baseline @@ -161,7 +171,8 @@ dimension measured side by side. ### Where we're going - **Next:** the fidelity pass (7) and hardening (9: a real-file corpus, platform QA); effects: - inner shadows under strokes (render order), progressive blur, glass tuned side by side. + inner shadows under strokes (render order), progressive blur, glass tuned side by side; the last + text details (right-to-left list markers). - **Then:** interop depth (6: .fig best effort), plugins and collaboration (8). - **Alpha:** release builds on every push to `release` (packaging in progress), demo files, docs. diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 329d757f..36cab0b9 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -42,6 +42,11 @@ impl eframe::App for App { ctx.send_viewport_cmd(egui::ViewportCommand::Title(title)); } + fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) { + // Pointer and key events sent over the control channel (`ui.input`). + raw_input.events.append(&mut self.fig.control_input); + } + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // Last-resort guard: a panic in the UI frame becomes a message, not a lost document. let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.fig.ui(ui))); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 61644da0..c48822c0 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -751,6 +751,41 @@ impl Document { if self.current_page.is_none_or(|p| !self.pages.contains(&p)) { self.current_page = self.pages.first().copied(); } + self.link_layer_text_styles(); + } + + /// Text layers linked to a text style whose chars carry no link (files from before per-char + /// links, importers that link layers): the chars showing the style link to it, and the layer + /// stays linked only if they all do. + pub fn link_layer_text_styles(&mut self) { + let defs: BTreeMap = self + .styles + .iter() + .filter_map(|s| match &s.kind { + StyleKind::Text { style } => Some((s.id.clone(), style.clone())), + _ => None, + }) + .collect(); + let legacy = |n: &Node| match &n.kind { + NodeKind::Text(t) => n.styles.text.is_some() && t.spans().iter().all(|(_, _, s)| s.style_id.is_none()), + _ => false, + }; + let ids: Vec = self.nodes.iter().filter(|(_, n)| legacy(n)).map(|(k, _)| *k).collect(); + for id in ids { + let Some(n) = self.nodes.get_mut(&id) else { continue }; + let n = Arc::make_mut(n); + let sid = n.styles.text.clone().unwrap_or_default(); + if let NodeKind::Text(t) = &mut n.kind { + if let Some(def) = defs.get(&sid) { + t.each_style(|s| { + if s.shows_text_style(def) { + s.style_id = Some(sid.clone()); + } + }); + } + n.styles.text = t.text_style_link().flatten().map(str::to_string); + } + } } /// Hit test: the topmost visible, unlocked node under `pt` on `page` for a click. diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 1d968f0e..d45fc3cb 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -187,6 +187,10 @@ pub struct TextStyle { /// 550, `wdth`: 85). Empty = what the style name implies; axes the face lacks are ignored. #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub variations: std::collections::BTreeMap, + /// The text style (shared style id) these chars are linked to. A layer's chars can link to + /// different styles; editing what a style sets unlinks the chars edited. + #[serde(skip_serializing_if = "Option::is_none")] + pub style_id: Option, } impl Default for TextStyle { @@ -202,6 +206,7 @@ impl Default for TextStyle { fills: None, link: None, variations: std::collections::BTreeMap::new(), + style_id: None, } } } @@ -223,6 +228,29 @@ impl TextStyle { || self.variations.get("slnt").is_some_and(|v| *v != 0.0) } + /// Take what text style `def` sets (font, size, line height, letter spacing, decoration, case + /// and axis values) and link to it as `id`; fills and hyperlinks stay. + pub fn take_text_style(&mut self, def: &TextStyle, id: &str) { + *self = TextStyle { fills: self.fills.take(), link: self.link.take(), style_id: Some(id.to_string()), ..def.clone() }; + } + + /// Whether these chars show what text style `def` sets. + pub fn shows_text_style(&self, def: &TextStyle) -> bool { + self.font_family == def.font_family + && self.font_style == def.font_style + && self.font_size == def.font_size + && self.line_height == def.line_height + && self.letter_spacing == def.letter_spacing + && self.decoration == def.decoration + && self.case == def.case + && self.variations == def.variations + } + + /// This style as a text style's definition (linked to nothing). + pub fn as_text_style(&self) -> TextStyle { + TextStyle { style_id: None, link: None, ..self.clone() } + } + /// Switch to the named style, dropping custom values of the axes the name implies. pub fn set_font_style(&mut self, name: impl Into) { self.font_style = name.into(); @@ -426,6 +454,61 @@ impl TextData { self.normalize(); } + /// Link chars `start..end` (all of them when the range is empty or the whole text) to text + /// style `id`, defined as `def`. + pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) { + self.restyle(start, end, |s| s.take_text_style(def, id)); + } + + /// Unlink chars `start..end` (all when empty or whole) from their text styles; values stay. + pub fn unlink_text_style(&mut self, start: usize, end: usize) { + self.restyle(start, end, |s| s.style_id = None); + } + + /// The text style every char is linked to: `Some(None)` when none is, None when they differ. + pub fn text_style_link(&self) -> Option> { + let mut spans = self.spans().into_iter().map(|(_, _, s)| s.style_id.as_deref()); + let first = spans.next()?; + spans.all(|s| s == first).then_some(first) + } + + /// Apply `f` to every distinct style (the base and each run) and merge runs that became equal. + pub(crate) fn each_style(&mut self, mut f: impl FnMut(&mut TextStyle)) { + f(&mut self.style); + for r in &mut self.runs { + f(&mut r.style); + } + self.normalize(); + } + + /// Whether any char links to text style `id`. + pub fn links_text_style(&self, id: &str) -> bool { + std::iter::once(&self.style).chain(self.runs.iter().map(|r| &r.style)).any(|s| s.style_id.as_deref() == Some(id)) + } + + /// Follow text style `id`'s new definition wherever chars link to it. + pub fn update_text_style(&mut self, id: &str, def: &TextStyle) { + self.each_style(|s| { + if s.style_id.as_deref() == Some(id) { + s.take_text_style(def, id); + } + }); + } + + /// Point text style links elsewhere: `f(id)` gives the new id, None unlinks (values stay). + pub fn map_text_styles(&mut self, f: impl Fn(&str) -> Option) { + self.each_style(|s| s.style_id = s.style_id.as_deref().and_then(&f)); + } + + /// Unlink chars whose style is gone (`def` gives None) or that no longer show it (edited since). + pub fn check_text_styles<'a>(&mut self, def: impl Fn(&str) -> Option<&'a TextStyle>) { + self.each_style(|s| { + if s.style_id.as_deref().is_some_and(|id| !def(id).is_some_and(|d| s.shows_text_style(d))) { + s.style_id = None; + } + }); + } + /// Whether every char shares one style (the panel shows "Mixed" otherwise). pub fn is_uniform(&self) -> bool { self.runs.is_empty() diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 7f64eb93..5deee456 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -85,7 +85,7 @@ pub(crate) fn sizing(v: &Value) -> Option { } } -fn line_height(v: &Value) -> Option { +pub(crate) fn line_height(v: &Value) -> Option { match v { Value::String(s) if s.eq_ignore_ascii_case("auto") => Some(LineHeight::Auto), Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LineHeight::Percent), @@ -94,7 +94,7 @@ fn line_height(v: &Value) -> Option { } } -fn letter_spacing(v: &Value) -> Option { +pub(crate) fn letter_spacing(v: &Value) -> Option { match v { Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LetterSpacing::Percent), Value::Object(_) => serde_json::from_value(v.clone()).ok(), diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 77459194..84c91f23 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -32,22 +32,20 @@ pub fn specs() -> Vec { true, create ), - spec!("style.apply", "Apply style", "{ids?, style (id or name), target? (fill|stroke; paint styles)}", true, apply), - spec!("style.detach", "Detach style", "{ids?, which: fill|stroke|effect|grid|text}", true, |s, p| { - let which = req_string("style.detach", p, "which")?; - for i in req_targets("style.detach", s, p)? { - let n = s.doc_mut()?.get_mut(i)?; - match which.as_str() { - "fill" => n.styles.fill = None, - "stroke" => n.styles.stroke = None, - "effect" => n.styles.effect = None, - "grid" => n.styles.grid = None, - "text" => n.styles.text = None, - _ => return Err(bad("style.detach", "which: fill|stroke|effect|grid|text")), - } - } - ok() - }), + spec!( + "style.apply", + "Apply style", + "{ids?, style (id or name), target? (fill|stroke; paint styles), start?, end? (text styles: these chars; while editing text, the selected chars)}", + true, + apply + ), + spec!( + "style.detach", + "Detach style", + "{ids?, which: fill|stroke|effect|grid|text, start?, end? (text: these chars; while editing text, the selected chars)}", + true, + detach + ), spec!("style.update", "Edit style", "{style, name?, description?, value?, from? (node id: redefine from it)}", true, update), spec!("style.delete", "Delete style", "{style}", true, |s, p| { let id = req_string("style.delete", p, "style")?; @@ -57,12 +55,13 @@ pub fn specs() -> Vec { return Err(EngineError::Disabled("style.delete".into(), crate::library::READ_ONLY.into())); } doc.styles.retain(|st| st.id != sid); - // Nodes keep their values but lose the link. + // Nodes (and text chars) keep their values but lose the link. for nid in doc.ids().collect::>() { if let Some(n) = doc.node(nid) - && [&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text] + && ([&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text] .iter() .any(|r| r.as_deref() == Some(sid.as_str())) + || n.kind.text().is_some_and(|t| t.links_text_style(&sid))) { let n = doc.get_mut(nid)?; for r in [&mut n.styles.fill, &mut n.styles.stroke, &mut n.styles.effect, &mut n.styles.grid, &mut n.styles.text] { @@ -70,6 +69,9 @@ pub fn specs() -> Vec { *r = None; } } + if let NodeKind::Text(t) = &mut n.kind { + t.map_text_styles(|x| (x != sid).then(|| x.to_string())); + } } } ok() @@ -171,11 +173,91 @@ fn next_style_id(doc: &Document) -> String { } } +/// A text layer and the chars a command covers in it (None = all of them). +pub(crate) type TextTarget = (NodeId, Option<(usize, usize)>); + +/// Text layers a text style command acts on, each with the chars it covers (None = the whole +/// layer): chars `start..end` when given, else the selected chars of the text being edited (when +/// it is the target), else every targeted layer. +pub(crate) fn text_targets(cmd: &str, s: &Session, p: &Value) -> Result> { + let at = |k: &str| num(p, k).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1e12) as usize); + let (start, end) = (at("start"), at("end")); + if start.is_some() || end.is_some() { + let range = (start.unwrap_or(0), end.unwrap_or(usize::MAX)); + return Ok(req_targets(cmd, s, p)?.into_iter().map(|i| (i, Some(range))).collect()); + } + let given = targets(s, p); + if let Some((id, a, b)) = crate::textedit::selected_range(s) + && a < b + && given == [id] + { + return Ok(vec![(id, Some((a, b)))]); + } + Ok(req_targets(cmd, s, p)?.into_iter().map(|i| (i, None)).collect()) +} + +/// A text style definition from JSON (`fontSize` or `font_size` keys; line height and letter +/// spacing also as for `node.set`: "auto", 24, "150%", "2%"). +fn text_style_json(cmd: &str, v: &Value) -> Result { + let snake = + |k: &str| k.chars().flat_map(|c| if c.is_ascii_uppercase() { vec!['_', c.to_ascii_lowercase()] } else { vec![c] }).collect::(); + let friendly = |k: &str, x: &Value| -> Value { + let parsed = match (k, x) { + (_, Value::Object(_)) => None, + ("line_height", x) => super::props::line_height(x).and_then(|l| serde_json::to_value(l).ok()), + ("letter_spacing", x) => super::props::letter_spacing(x).and_then(|l| serde_json::to_value(l).ok()), + _ => None, + }; + parsed.unwrap_or_else(|| x.clone()) + }; + let v = match v { + Value::Object(m) => Value::Object(m.iter().map(|(k, x)| (snake(k), friendly(&snake(k), x))).collect()), + other => other.clone(), + }; + serde_json::from_value::(v).map(|t| t.as_text_style()).map_err(|e| bad(cmd, e.to_string())) +} + +/// The text style definition a layer's chars from `at` (None = its base style) show. +fn text_style_of(n: &Node, at: Option) -> Option { + let t = n.kind.text()?; + Some(at.map_or(&t.style, |a| t.style_at(a)).as_text_style()) +} + +fn detach(s: &mut Session, p: &Value) -> Result { + let cmd = "style.detach"; + let which = req_string(cmd, p, "which")?; + if which == "text" { + for (i, range) in text_targets(cmd, s, p)? { + let doc = s.doc_mut()?; + match range { + Some((a, b)) => { + if let NodeKind::Text(t) = &mut doc.get_mut(i)?.kind { + t.unlink_text_style(a, b); + } + } + None => apply_style_ref(doc, i, "textStyle", None)?, + } + } + return ok(); + } + for i in req_targets(cmd, s, p)? { + let n = s.doc_mut()?.get_mut(i)?; + match which.as_str() { + "fill" => n.styles.fill = None, + "stroke" => n.styles.stroke = None, + "effect" => n.styles.effect = None, + "grid" => n.styles.grid = None, + _ => return Err(bad(cmd, "which: fill|stroke|effect|grid|text")), + } + } + ok() +} + fn kind_from_node(t: &str, n: &Node) -> Option { Some(match t { "paint" | "color" | "fill" => StyleKind::Paint { paints: n.fills.clone() }, "stroke" => StyleKind::Paint { paints: n.strokes.clone() }, - "text" => StyleKind::Text { style: n.kind.text()?.style.clone() }, + "text" => StyleKind::Text { style: text_style_of(n, None)? }, "effect" => StyleKind::Effect { effects: n.effects.clone() }, "grid" => StyleKind::Grid { grids: n.grids.clone() }, _ => return None, @@ -187,23 +269,35 @@ fn create(s: &mut Session, p: &Value) -> Result { let t = req_string(cmd, p, "type")?.to_ascii_lowercase(); let name = req_string(cmd, p, "name")?; let from = id(p, "from").or_else(|| s.selection().first().copied()); + // A text style made while editing text takes (and links) the selected chars. + let range = match (t.as_str(), crate::textedit::selected_range(s)) { + ("text", Some((tid, a, b))) if a < b && from.is_none_or(|f| f == tid) && get(p, "value").is_none() => Some((tid, a, b)), + _ => None, + }; + let from = range.map(|r| r.0).or(from); let doc = s.doc_mut()?; let kind = if let Some(v) = get(p, "value") { match t.as_str() { "paint" | "color" | "fill" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? }, - "text" => StyleKind::Text { style: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? }, + "text" => StyleKind::Text { style: text_style_json(cmd, v)? }, "effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? }, "grid" => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? }, _ => return Err(bad(cmd, "type: paint|text|effect|grid")), } + } else if let Some((tid, a, _)) = range { + StyleKind::Text { style: doc.node(tid).and_then(|n| text_style_of(n, Some(a))).ok_or_else(|| bad(cmd, "not a text layer"))? } } else { let n = from.and_then(|f| doc.node(f)).ok_or_else(|| bad(cmd, "pass `value` or select a layer to take it from"))?; kind_from_node(&t, n).ok_or_else(|| bad(cmd, "that layer has no such property"))? }; let sid = next_style_id(doc); - doc.styles.push(Style { id: sid.clone(), name, description: string(p, "description").unwrap_or_default(), kind }); - // The source layer becomes linked to the new style. - if let Some(f) = from + doc.styles.push(Style { id: sid.clone(), name, description: string(p, "description").unwrap_or_default(), kind: kind.clone() }); + // The source layer (or the selected chars) becomes linked to the new style. + if let (Some((tid, a, b)), StyleKind::Text { style }) = (range, &kind) { + if let NodeKind::Text(td) = &mut doc.get_mut(tid)?.kind { + td.link_text_style(a, b, style, &sid); + } + } else if let Some(f) = from && get(p, "value").is_none() { let field = match t.as_str() { @@ -243,10 +337,10 @@ pub fn apply_style_ref(doc: &mut Document, id: NodeId, field: &str, sid: Option< n.styles.grid = Some(id.clone()); } ("textStyle", Some(Style { kind: StyleKind::Text { style }, id, .. })) => { + // Every char takes the style's typography (their fills and links stay). if let NodeKind::Text(t) = &mut n.kind { - let keep_fills = t.style.fills.clone(); - t.style = TextStyle { fills: keep_fills, ..style.clone() }; - t.runs.clear(); + let len = t.char_count(); + t.link_text_style(0, len, style, id); } n.styles.text = Some(id.clone()); } @@ -254,7 +348,13 @@ pub fn apply_style_ref(doc: &mut Document, id: NodeId, field: &str, sid: Option< ("strokeStyle", None) => n.styles.stroke = None, ("effectStyle", None) => n.styles.effect = None, ("gridStyle", None) => n.styles.grid = None, - ("textStyle", None) => n.styles.text = None, + ("textStyle", None) => { + if let NodeKind::Text(t) = &mut n.kind { + let len = t.char_count(); + t.unlink_text_style(0, len); + } + n.styles.text = None; + } _ => return Err(EngineError::Other(format!("style doesn't fit `{field}`"))), } Ok(()) @@ -263,9 +363,24 @@ pub fn apply_style_ref(doc: &mut Document, id: NodeId, field: &str, sid: Option< pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { let cmd = "style.apply"; let st = req_string(cmd, p, "style")?; + let style = s.doc()?.style(&st).cloned().ok_or_else(|| bad(cmd, format!("no style `{st}`")))?; + // Text styles go on chars: a range, the selected chars of the edited text, or whole layers. + if let StyleKind::Text { style: def } = &style.kind { + for (i, range) in text_targets(cmd, s, p)? { + let doc = s.doc_mut()?; + match range { + Some((a, b)) => { + if let NodeKind::Text(t) = &mut doc.get_mut(i)?.kind { + t.link_text_style(a, b, def, &style.id); + } + } + None => apply_style_ref(doc, i, "textStyle", Some(&style.id))?, + } + } + return ok(); + } let ids = req_targets(cmd, s, p)?; let doc = s.doc_mut()?; - let style = doc.style(&st).cloned().ok_or_else(|| bad(cmd, format!("no style `{st}`")))?; let field = match (&style.kind, string(p, "target").as_deref()) { (StyleKind::Paint { .. }, Some("stroke")) => "strokeStyle", (StyleKind::Paint { .. }, _) => "fillStyle", @@ -292,7 +407,7 @@ fn update(s: &mut Session, p: &Value) -> Result { let cur = doc.styles.get(idx).map(|x| x.kind.type_name()).unwrap_or("paint"); Some(match cur { "paint" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? }, - "text" => StyleKind::Text { style: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? }, + "text" => StyleKind::Text { style: text_style_json(cmd, v)? }, "effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? }, _ => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? }, }) @@ -317,19 +432,26 @@ fn update(s: &mut Session, p: &Value) -> Result { ok() } -/// Re-apply style `sid` to every node linked to it. +/// Re-apply style `sid` to every node (and text chars) linked to it. pub fn propagate_style(doc: &mut Document, sid: &str) { + let text_def = match doc.style(sid).map(|s| &s.kind) { + Some(StyleKind::Text { style }) => Some(style.clone()), + _ => None, + }; let ids: Vec = doc.ids().collect(); for i in ids { let Some(n) = doc.node(i) else { continue }; + if let Some(def) = &text_def { + if n.kind.text().is_some_and(|t| t.links_text_style(sid)) + && let Ok(n) = doc.get_mut(i) + && let NodeKind::Text(t) = &mut n.kind + { + t.update_text_style(sid, def); + } + continue; + } let refs = n.styles.clone(); - for (field, r) in [ - ("fillStyle", &refs.fill), - ("strokeStyle", &refs.stroke), - ("effectStyle", &refs.effect), - ("gridStyle", &refs.grid), - ("textStyle", &refs.text), - ] { + for (field, r) in [("fillStyle", &refs.fill), ("strokeStyle", &refs.stroke), ("effectStyle", &refs.effect), ("gridStyle", &refs.grid)] { if r.as_deref() == Some(sid) { let _ = apply_style_ref(doc, i, field, Some(sid)); } @@ -337,6 +459,49 @@ pub fn propagate_style(doc: &mut Document, sid: &str) { } } +/// Text style links follow edits (on `ids`): chars that no longer show their style's values (or +/// whose style is gone) unlink, and a layer's link is the style all its chars share. +pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) { + let linked = |n: &Node| match &n.kind { + NodeKind::Text(t) => n.styles.text.is_some() || t.style.style_id.is_some() || t.runs.iter().any(|r| r.style.style_id.is_some()), + _ => false, + }; + let ids: Vec = ids.iter().copied().filter(|i| doc.node(*i).is_some_and(linked)).collect(); + if ids.is_empty() { + return; + } + let defs: BTreeMap = doc + .styles + .iter() + .filter_map(|s| match &s.kind { + StyleKind::Text { style } => Some((s.id.clone(), style.clone())), + _ => None, + }) + .collect(); + for id in ids { + let Some(n) = doc.node(id) else { continue }; + let NodeKind::Text(t) = &n.kind else { continue }; + let mut t2 = t.clone(); + // A layer linked as a whole whose chars carry no link yet (imported): they link now. + if let Some(sid) = &n.styles.text + && t2.text_style_link() == Some(None) + && let Some(def) = defs.get(sid) + && t2.spans().iter().all(|(_, _, s)| s.shows_text_style(def)) + { + let len = t2.char_count(); + t2.restyle(0, len, |s| s.style_id = Some(sid.clone())); + } + t2.check_text_styles(|sid| defs.get(sid)); + let link = t2.text_style_link().flatten().map(str::to_string); + if (t2 != *t || link != n.styles.text) + && let Ok(n) = doc.get_mut(id) + { + n.styles.text = link; + n.kind = NodeKind::Text(t2); + } + } +} + /// `variable.bind`: bind (or with no variable, unbind) `field` of the targets. pub fn bind(s: &mut Session, p: &Value) -> Result { let cmd = "variable.bind"; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 8407fcce..ff0424dd 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -237,6 +237,21 @@ pub fn specs() -> Vec { }) } ), + spec!( + "text.preview", + "Preview text change", + "{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step", + false, + |s, p| { + let c = req_string("text.preview", p, "command")?; + let params = get(p, "params").cloned().unwrap_or_else(|| json!({})); + crate::textpreview::show(s, &c, ¶ms)?; + Ok(json!({"previewing": crate::textpreview::active(s)})) + } + ), + spec!("text.endPreview", "End text preview", "{} puts the document back as it was before text.preview → {ended}", false, |s, _| { + Ok(json!({"ended": crate::textpreview::end(s)})) + }), spec!("text.set", "Set text", "{id?, characters}", true, |s, p| { let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?; let ids = req_targets("text.set", s, p)?; diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 0c6b550b..6a557f49 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -153,6 +153,11 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { if !t.runs.is_empty() { o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null)); } + // Chars linked to text styles: [start, end, style id] (the layer as a whole: `styles.text`). + let linked: Vec = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect(); + if !linked.is_empty() { + o.insert("textStyleRanges".into(), Value::Array(linked)); + } } NodeKind::Ellipse { start, sweep, inner } if (*sweep).abs() < std::f64::consts::TAU - 1e-9 || *inner > 0.0 => { o.insert("arc".into(), json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner})); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5dc638f4..7bdc9dc9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -23,6 +23,7 @@ pub mod shapebuilder; pub mod snap; pub mod synthetic; pub mod textedit; +pub mod textpreview; pub mod tools; pub mod vectoredit; @@ -234,6 +235,9 @@ pub struct DocState { versions_changed: bool, /// A version shown in place of the document (read-only). pub preview: Option, + /// The document under a live text preview (a hovered text style or font instance, a dragged + /// axis slider): `doc` shows the preview until [`textpreview::end`] puts this back. + pub text_preview: Option>, /// The document as of the last version saved (or as opened): autosaves only follow changes. checkpoint: Arc, } @@ -264,6 +268,7 @@ impl DocState { if doc.pages.is_empty() { doc.repair(); } + doc.link_layer_text_styles(); let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0)); let doc = Arc::new(doc); let mut st = Self { @@ -284,6 +289,7 @@ impl DocState { versions: Vec::new(), versions_changed: false, preview: None, + text_preview: None, }; st.zoom_to_fit(); st @@ -312,7 +318,7 @@ impl DocState { } pub fn is_dirty(&self) -> bool { - let doc = self.preview.as_ref().map_or(&self.doc, |p| &p.doc); + let doc = self.preview.as_ref().map(|p| &p.doc).or(self.text_preview.as_ref()).unwrap_or(&self.doc); !Arc::ptr_eq(doc, &self.saved_doc) || self.versions_changed } @@ -540,6 +546,16 @@ impl Session { /// as it was. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + if textpreview::COMMANDS.contains(&id) { + // A live preview is no edit: no undo step, normalisation or rollback here. + let r = guard::run(id, || (spec.run)(self, params)); + if r.is_err() { + textpreview::end(self); + } + return r; + } + // Every other command acts on the document itself, not on a live text preview. + textpreview::end(self); if ENDS_PREVIEW.contains(&id) && let Ok(ds) = self.ds_mut() { @@ -629,6 +645,7 @@ impl Session { /// Start a gesture: later [`preview`](Self::preview) calls re-apply edits to this snapshot. pub fn begin_interaction(&mut self, label: &str) -> Result<()> { + textpreview::end(self); let ds = self.ds_mut()?; if ds.preview.is_some() { return Err(EngineError::Disabled(label.to_string(), PREVIEW_READ_ONLY.into())); @@ -682,6 +699,7 @@ impl Session { } pub fn undo(&mut self) -> Result { + textpreview::end(self); let ds = self.ds_mut()?; if ds.preview.is_some() { return Err(EngineError::Disabled("edit.undo".into(), PREVIEW_READ_ONLY.into())); @@ -702,6 +720,7 @@ impl Session { } pub fn redo(&mut self) -> Result { + textpreview::end(self); let ds = self.ds_mut()?; if ds.preview.is_some() { return Err(EngineError::Disabled("edit.redo".into(), PREVIEW_READ_ONLY.into())); @@ -803,6 +822,10 @@ pub fn normalize(doc: &mut Document, before: &Document) { } } touched.extend(doc.take_dirty()); + // Text chars edited away from their text style unlink from it. + let live_now: Vec = touched.iter().copied().filter(|i| doc.contains(*i)).collect(); + cmd::styles::sync_text_styles(doc, &live_now); + touched.extend(doc.take_dirty()); let live = |doc: &Document, t: &BTreeSet| -> Vec { t.iter().copied().filter(|i| doc.contains(*i)).collect() }; // 0b. Fill and hug only where they can apply (a layer moved out of auto layout, a parent that // started to hug or lost auto layout): the rest becomes fixed at its current size. @@ -925,6 +948,8 @@ mod tests; #[cfg(test)] mod text_fill_tests; #[cfg(test)] +mod text_style_picker_tests; +#[cfg(test)] mod variable_tests; #[cfg(test)] mod variables_editor_tests; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index dcaa585d..0a0989a7 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -706,6 +706,9 @@ fn localize(n: &mut Node, src: &Document, map: &BTreeMap, styles for r in [&mut n.styles.fill, &mut n.styles.stroke, &mut n.styles.effect, &mut n.styles.grid, &mut n.styles.text] { *r = r.take().and_then(|s| styles.get(&s).cloned()); } + if let NodeKind::Text(t) = &mut n.kind { + t.map_text_styles(|s| styles.get(s).cloned()); + } } if let Some(vars) = vars { n.bound = std::mem::take(&mut n.bound).into_iter().filter_map(|(f, v)| vars.variables.get(&v).map(|l| (f, l.clone()))).collect(); diff --git a/crates/engine/src/text_style_picker_tests.rs b/crates/engine/src/text_style_picker_tests.rs new file mode 100644 index 00000000..37c7dc5c --- /dev/null +++ b/crates/engine/src/text_style_picker_tests.rs @@ -0,0 +1,234 @@ +//! Text styles on chars (the text style browser's commands): apply to layers, ranges or the +//! selected chars being edited, edits unlink what they change, style edits follow the links, +//! detach, delete, create from selected chars, old files; live previews of styles and axis values. + +use figcraft_doc::{NodeId, StyleKind, TextData}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; + +fn text(s: &mut Session, chars: &str) -> NodeId { + id_of(&s.execute("text.create", &json!({"text": chars, "x": 0, "y": 0})).unwrap()["id"]).unwrap() +} + +fn data(s: &Session, id: NodeId) -> TextData { + s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone() +} + +fn layer_link(s: &Session, id: NodeId) -> Option { + s.doc().unwrap().node(id).unwrap().styles.text.clone() +} + +/// The style link of each char. +fn links(s: &Session, id: NodeId) -> Vec> { + let t = data(s, id); + (0..t.char_count()).map(|i| t.style_at(i).style_id.clone()).collect() +} + +/// A text style "Heading" (Inter Bold 32) made from a layer. +fn heading(s: &mut Session) -> String { + let src = text(s, "Heading"); + s.execute("node.set", &json!({"ids": [src], "props": {"fontSize": 32, "fontStyle": "Bold"}})).unwrap(); + let id = s.execute("style.create", &json!({"type": "text", "name": "Type/Heading", "from": src})).unwrap()["id"].as_str().unwrap().to_string(); + s.execute("edit.delete", &json!({"ids": [src]})).unwrap(); + id +} + +fn new_session() -> Session { + Session::new() +} + +#[test] +fn styles_apply_to_layers_and_ranges() { + let mut s = new_session(); + let h = heading(&mut s); + let t = text(&mut s, "Hello world"); + // A red word keeps its colour when the whole layer takes the style. + s.execute("text.style", &json!({"id": t, "start": 6, "end": 11, "textFills": [{"type": "solid", "color": "#ff0000"}]})).unwrap(); + s.execute("style.apply", &json!({"ids": [t], "style": h})).unwrap(); + assert_eq!(layer_link(&s, t).as_deref(), Some(h.as_str())); + assert!(links(&s, t).iter().all(|l| l.as_deref() == Some(h.as_str()))); + let d = data(&s, t); + assert_eq!(d.style_at(0).font_size, 32.0); + assert_eq!(d.style_at(8).font_style, "Bold"); + assert!(d.style_at(8).fills.is_some(), "range fills stay"); + // A range takes another style: the layer as a whole is linked to none. + let b = s + .execute( + "style.create", + &json!({"type": "text", "name": "Type/Body", "value": {"fontFamily": "Inter", "fontStyle": "Regular", "fontSize": 14}}), + ) + .unwrap(); + let b = b["id"].as_str().unwrap().to_string(); + s.execute("style.apply", &json!({"ids": [t], "style": b, "start": 0, "end": 5})).unwrap(); + assert_eq!(layer_link(&s, t), None); + let l = links(&s, t); + assert_eq!(l[0].as_deref(), Some(b.as_str())); + assert_eq!(l[6].as_deref(), Some(h.as_str())); + assert_eq!(data(&s, t).style_at(0).font_size, 14.0); +} + +#[test] +fn editing_text_applies_to_the_selected_chars() { + let mut s = new_session(); + let h = heading(&mut s); + let t = text(&mut s, "Hello world"); + s.execute("text.edit.begin", &json!({"id": t, "caret": 0})).unwrap(); + s.execute("text.edit.caret", &json!({"index": 5, "extend": true})).unwrap(); + s.execute("style.apply", &json!({"style": h})).unwrap(); + let l = links(&s, t); + assert!(l[..5].iter().all(|x| x.as_deref() == Some(h.as_str()))); + assert!(l[5..].iter().all(Option::is_none)); + // A new style from the selected chars links them. + s.execute("text.edit.caret", &json!({"index": 6})).unwrap(); + s.execute("text.edit.caret", &json!({"index": 11, "extend": true})).unwrap(); + s.execute("text.style", &json!({"id": t, "start": 6, "end": 11, "fontSize": 20})).unwrap(); + let c = s.execute("style.create", &json!({"type": "text", "name": "Type/Caption"})).unwrap()["id"].as_str().unwrap().to_string(); + let style = s.doc().unwrap().style(&c).cloned().unwrap(); + let StyleKind::Text { style: def } = style.kind else { panic!("not a text style") }; + assert_eq!(def.font_size, 20.0); + assert_eq!(def.style_id, None, "definitions link to nothing"); + assert_eq!(links(&s, t)[8].as_deref(), Some(c.as_str())); + // Detach the selected chars only. + s.execute("style.detach", &json!({"which": "text"})).unwrap(); + assert_eq!(links(&s, t)[8], None); + assert_eq!(links(&s, t)[0].as_deref(), Some(h.as_str())); + s.execute("text.edit.end", &json!({})).unwrap(); + // The whole edit is one undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(links(&s, t).iter().all(Option::is_none)); +} + +#[test] +fn edits_unlink_and_style_edits_follow() { + let mut s = new_session(); + let h = heading(&mut s); + let a = text(&mut s, "One"); + let b = text(&mut s, "Two"); + s.execute("style.apply", &json!({"ids": [a, b], "style": h})).unwrap(); + // Changing what the style sets detaches the layer (its values stay). + s.execute("node.set", &json!({"ids": [b], "props": {"fontSize": 50}})).unwrap(); + assert_eq!(layer_link(&s, b), None); + assert!(links(&s, b).iter().all(Option::is_none)); + assert_eq!(layer_link(&s, a).as_deref(), Some(h.as_str())); + // A colour isn't part of a text style: the link stays. + s.execute("text.style", &json!({"id": a, "start": 0, "end": 1, "textFills": [{"type": "solid", "color": "#00ff00"}]})).unwrap(); + assert_eq!(layer_link(&s, a).as_deref(), Some(h.as_str())); + // Redefining the style moves linked chars only. + let mut def = match &s.doc().unwrap().style(&h).unwrap().kind { + StyleKind::Text { style } => style.clone(), + _ => panic!("not a text style"), + }; + def.font_size = 40.0; + s.execute("style.update", &json!({"style": h, "value": serde_json::to_value(&def).unwrap()})).unwrap(); + assert_eq!(data(&s, a).style_at(2).font_size, 40.0); + assert_eq!(data(&s, b).style_at(0).font_size, 50.0); + assert_eq!(layer_link(&s, a).as_deref(), Some(h.as_str()), "following the style keeps the link"); + // Deleting the style unlinks every char. + s.execute("style.delete", &json!({"style": h})).unwrap(); + assert!(links(&s, a).iter().all(Option::is_none)); + assert_eq!(layer_link(&s, a), None); + assert_eq!(data(&s, a).style_at(0).font_size, 40.0, "values stay"); +} + +#[test] +fn files_from_before_char_links_link_their_chars() { + let mut s = new_session(); + let h = heading(&mut s); + let t = text(&mut s, "Old"); + s.execute("style.apply", &json!({"ids": [t], "style": h})).unwrap(); + // An old file: the layer is linked, its chars carry nothing. + let mut doc = s.doc().unwrap().clone(); + let n = doc.get_mut(t).unwrap(); + if let figcraft_doc::NodeKind::Text(td) = &mut n.kind { + let len = td.char_count(); + td.unlink_text_style(0, len); + } + n.styles.text = Some(h.clone()); + let s2 = Session::with_document(doc); + assert!(links(&s2, t).iter().all(|l| l.as_deref() == Some(h.as_str()))); + assert_eq!(layer_link(&s2, t).as_deref(), Some(h.as_str())); + // Saved and read back, the links survive. + let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap(); + let (back, _) = figcraft_format::load_bytes(&bytes).unwrap(); + assert_eq!(back.node(t).unwrap().kind.text().unwrap().style.style_id.as_deref(), Some(h.as_str())); +} + +#[test] +fn previews_show_without_an_undo_step() { + let mut s = new_session(); + let h = heading(&mut s); + let t = text(&mut s, "Preview me"); + s.ds_mut().unwrap().mark_saved(); + let undo = s.ds().unwrap().history.undo.len(); + let before = s.doc().unwrap().clone(); + s.execute("text.preview", &json!({"command": "style.apply", "params": {"ids": [t], "style": h}})).unwrap(); + assert_eq!(data(&s, t).style_at(0).font_size, 32.0, "the canvas shows the style"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo); + assert!(!s.ds().unwrap().is_dirty(), "a preview isn't an edit"); + // Another preview replaces it (from the document, not on top of the first). + s.execute("text.preview", &json!({"command": "text.setVariations", "params": {"ids": [t], "variations": {"wght": 650}}})).unwrap(); + assert_eq!(data(&s, t).style_at(0).font_size, before.node(t).unwrap().kind.text().unwrap().style.font_size); + assert_eq!(data(&s, t).style_at(0).variations.get("wght"), Some(&650.0)); + // Ending it puts the document back as it was. + assert_eq!(s.execute("text.endPreview", &json!({})).unwrap()["ended"], json!(true)); + assert_eq!(*s.doc().unwrap(), before); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.execute("text.endPreview", &json!({})).unwrap()["ended"], json!(false)); + // Committing after a preview is one undo step on the document itself. + s.execute("text.preview", &json!({"command": "style.apply", "params": {"ids": [t], "style": h}})).unwrap(); + s.execute("style.apply", &json!({"ids": [t], "style": h})).unwrap(); + assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1); + assert!(s.ds().unwrap().text_preview.is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(*s.doc().unwrap(), before); + // Any other command ends a preview first. + s.execute("text.preview", &json!({"command": "style.apply", "params": {"ids": [t], "style": h}})).unwrap(); + s.execute("node.set", &json!({"ids": [t], "props": {"name": "Renamed"}})).unwrap(); + assert_eq!(data(&s, t).style_at(0).font_size, before.node(t).unwrap().kind.text().unwrap().style.font_size); + assert!(s.ds().unwrap().text_preview.is_none()); +} + +#[test] +fn previews_while_editing_keep_the_edit() { + let mut s = new_session(); + let h = heading(&mut s); + let t = text(&mut s, "Hello world"); + s.execute("text.edit.begin", &json!({"id": t, "caret": 0})).unwrap(); + s.execute("text.edit.caret", &json!({"index": 5, "extend": true})).unwrap(); + let edit = s.ds().unwrap().text_edit.clone(); + s.execute("text.preview", &json!({"command": "style.apply", "params": {"style": h}})).unwrap(); + assert_eq!(links(&s, t)[0].as_deref(), Some(h.as_str())); + assert_eq!(links(&s, t)[6], None); + assert_eq!(s.ds().unwrap().text_edit, edit); + s.execute("text.endPreview", &json!({})).unwrap(); + assert_eq!(s.ds().unwrap().text_edit, edit); + assert!(links(&s, t).iter().all(Option::is_none)); + // Typing ends it too, and the edit goes on. + s.execute("text.preview", &json!({"command": "style.apply", "params": {"style": h}})).unwrap(); + s.execute("text.edit.insert", &json!({"text": "Bye"})).unwrap(); + assert_eq!(data(&s, t).characters, "Bye world"); + assert!(links(&s, t).iter().all(Option::is_none)); +} + +#[test] +fn previews_refuse_what_they_cant_show() { + let mut s = new_session(); + let t = text(&mut s, "x"); + for p in [ + json!({}), + json!({"command": 5}), + json!({"command": "edit.delete", "params": {"ids": [t]}}), + json!({"command": "text.preview", "params": {}}), + json!({"command": "style.apply", "params": {"ids": [t], "style": "nope"}}), + json!({"command": "text.setVariations", "params": {"ids": [t], "variations": "junk"}}), + json!({"command": "style.apply", "params": Value::Null}), + ] { + let doc = s.doc().unwrap().clone(); + assert!(s.execute("text.preview", &p).is_err(), "{p}"); + assert_eq!(*s.doc().unwrap(), doc); + assert!(s.ds().unwrap().text_preview.is_none()); + } + assert!(s.doc().unwrap().contains(t)); +} diff --git a/crates/engine/src/textpreview.rs b/crates/engine/src/textpreview.rs new file mode 100644 index 00000000..507eccc3 --- /dev/null +++ b/crates/engine/src/textpreview.rs @@ -0,0 +1,67 @@ +//! Live text previews: what a text command would do, shown on the canvas without an undo step — +//! while the pointer is over a text style in the style browser or a font's named instance, or +//! while a variable font axis slider is dragged. The document under the preview is kept and comes +//! back when the preview ends ([`end`], `text.endPreview`). Every other command ends it first, so +//! committing (running the command for real) applies to the document itself as one undo step, and +//! Escape just ends the preview. + +use std::sync::Arc; + +use serde_json::Value; + +use crate::{EngineError, Result, Session, bad, find_command, guard, normalize}; + +/// The commands that show or end a preview (they don't end the one showing). +pub const COMMANDS: [&str; 2] = ["text.preview", "text.endPreview"]; + +/// What can be previewed: text styles, font styles and axis values. +pub const PREVIEWABLE: [&str; 4] = ["style.apply", "library.applyStyle", "text.setVariations", "text.style"]; + +/// Show `command params` as a live preview (in place of an earlier one). +pub fn show(s: &mut Session, command: &str, params: &Value) -> Result<()> { + let cmd = "text.preview"; + if !PREVIEWABLE.contains(&command) { + return Err(bad(cmd, format!("`{command}` can't be previewed (one of: {})", PREVIEWABLE.join(", ")))); + } + let spec = find_command(command).ok_or_else(|| EngineError::UnknownCommand(command.to_string()))?; + end(s); + let ds = s.ds()?; + if ds.preview.is_some() { + return Err(EngineError::Disabled(cmd.into(), crate::PREVIEW_READ_ONLY.into())); + } + let (base, selection, text_edit, page, uid) = (ds.doc.clone(), ds.selection.clone(), ds.text_edit.clone(), ds.page, ds.uid); + let result = guard::run(command, || (spec.run)(s, params)) + // Library copies and instance structure stay as they are, in previews too. + .and_then(|v| s.check_instance_structure(command, &Some((base.clone(), selection.clone(), page, uid))).map(|()| v)); + let ds = s.ds_mut()?; + // A preview changes what's shown, never the selection or the text being edited. + ds.selection = selection; + ds.text_edit = text_edit; + match result { + Ok(_) if !Arc::ptr_eq(&ds.doc, &base) => { + normalize(ds.doc_mut(), &base); + ds.text_preview = Some(base); + ds.revision += 1; + Ok(()) + } + Ok(_) => Ok(()), + Err(e) => { + ds.doc = base; + Err(e) + } + } +} + +/// End the live preview, if any: the document is back as it was. Returns whether one was showing. +pub fn end(s: &mut Session) -> bool { + let Ok(ds) = s.ds_mut() else { return false }; + let Some(base) = ds.text_preview.take() else { return false }; + ds.doc = base; + ds.revision += 1; + true +} + +/// Whether a live text preview is showing. +pub fn active(s: &Session) -> bool { + s.ds().is_ok_and(|d| d.text_preview.is_some()) +} diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index ac4fce16..f005bfe0 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -590,7 +590,7 @@ impl<'a> Ctx<'a> { "effect" | "effects" => (StyleKind::Effect { effects: n.effects.clone() }, &mut n.styles.effect), "grid" | "grids" => (StyleKind::Grid { grids: n.grids.clone() }, &mut n.styles.grid), "text" => match &n.kind { - NodeKind::Text(t) => (StyleKind::Text { style: t.style.clone() }, &mut n.styles.text), + NodeKind::Text(t) => (StyleKind::Text { style: t.style.as_text_style() }, &mut n.styles.text), _ => continue, }, _ => continue, diff --git a/crates/text/src/display.rs b/crates/text/src/display.rs new file mode 100644 index 00000000..b8887095 --- /dev/null +++ b/crates/text/src/display.rs @@ -0,0 +1,399 @@ +//! One line of display text for UI toolkits that neither shape nor reorder text (layer, page, style +//! and file names in the panels): Unicode bidi reordering into visual order, mirrored brackets in +//! right-to-left runs, and Arabic letters in their contextual (joined) presentation forms, lam-alef +//! ligatures included. The result keeps, for every cluster on screen, the range of logical chars it +//! shows, so a caller that measured the shown text can place a caret, hit-test a click, move the +//! caret visually and draw a selection (see [`VisualLine::caret_x`] and friends). + +use std::ops::Range; + +use crate::layout::{is_rtl_char, visual_order}; + +/// What one shown cluster is: a base char with its combining marks (or a lam-alef pair). +#[derive(Clone, Debug, PartialEq)] +pub struct Cluster { + /// Logical chars it shows. + pub logical: Range, + /// Chars of [`VisualLine::text`] it is drawn with (empty for invisible bidi controls). + pub shown: Range, + /// It runs right to left (odd bidi level). + pub rtl: bool, +} + +/// A line of text in display order. +#[derive(Clone, Debug, PartialEq)] +pub struct VisualLine { + /// The text as shown, left to right. + pub text: String, + /// The shown clusters, left to right. + pub clusters: Vec, + /// The paragraph runs right to left (its first strong character does). + pub rtl_para: bool, + /// Chars in the logical text. + pub len: usize, +} + +/// Whether `s` has anything that reads right to left (else it shows as is). +pub fn has_rtl(s: &str) -> bool { + s.chars().any(is_rtl_char) +} + +/// `s` (one line; line breaks show as spaces) in display order. +pub fn visual_line(s: &str) -> VisualLine { + let chars: Vec = s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect(); + let len = chars.len(); + if !chars.iter().copied().any(is_rtl_char) { + let clusters = (0..len).map(|i| Cluster { logical: i..i + 1, shown: i..i + 1, rtl: false }).collect(); + return VisualLine { text: chars.into_iter().collect(), clusters, rtl_para: false, len }; + } + let flat: String = chars.iter().collect(); + let info = unicode_bidi::BidiInfo::new(&flat, None); + let (levels, rtl_para) = match info.paragraphs.first() { + Some(p) => { + let lv = info.reordered_levels_per_char(p, p.range.clone()); + (lv.iter().map(|l| l.number()).collect::>(), p.level.is_rtl()) + } + None => (vec![0; len], false), + }; + let shaped = shape_arabic(&chars); + // Clusters in logical order: a base with the marks after it; lam-alef as one. + let mut logical: Vec<(Range, String, u8)> = Vec::new(); + let mut i = 0; + while i < len { + let start = i; + let mut shown = String::new(); + match shaped.get(i).copied().flatten() { + Some(Shaped::Ligature(c)) => { + shown.push(c); + i += 2; + } + Some(Shaped::Form(c)) => { + shown.push(c); + i += 1; + } + None => { + if let Some(c) = chars.get(i).copied().filter(|c| !is_bidi_control(*c)) { + shown.push(c); + } + i += 1; + } + } + while let Some(c) = chars.get(i).copied().filter(|c| joining(*c) == Join::Transparent) { + shown.push(c); + i += 1; + } + let level = levels.get(start).copied().unwrap_or(0); + if level % 2 == 1 { + shown = shown.chars().map(mirror).collect(); + } + logical.push((start..i.min(len), shown, level)); + } + let order = visual_order(&logical.iter().map(|c| c.2).collect::>()); + let mut text = String::new(); + let mut clusters = Vec::with_capacity(order.len()); + let mut at = 0; + for k in order { + let Some((range, shown, level)) = logical.get(k) else { continue }; + let n = shown.chars().count(); + text.push_str(shown); + clusters.push(Cluster { logical: range.clone(), shown: at..at + n, rtl: level % 2 == 1 }); + at += n; + } + VisualLine { text, clusters, rtl_para, len } +} + +/// The longest start of `s` that, with an ellipsis after it, `fits` (given how it shows); `s` +/// itself when it fits. The ellipsis follows the paragraph: at the left end of a right-to-left name. +pub fn elide(s: &str, fits: impl Fn(&VisualLine) -> bool) -> VisualLine { + let full = visual_line(s); + if fits(&full) { + return full; + } + // Cut only between clusters (never between a letter and its marks). + let chars: Vec = s.chars().collect(); + let cuts: Vec = (1..chars.len()).filter(|i| chars.get(*i).is_some_and(|c| joining(*c) != Join::Transparent)).collect(); + let with = |n: usize| -> VisualLine { + let mut t: String = chars.iter().take(n).collect::().trim_end().to_string(); + t.push('…'); + visual_line(&t) + }; + // Binary search the number of cuts kept. + let (mut lo, mut hi) = (0usize, cuts.len()); + while lo < hi { + let mid = (lo + hi).div_ceil(2); + if cuts.get(mid - 1).is_some_and(|n| fits(&with(*n))) { + lo = mid; + } else { + hi = mid - 1; + } + } + match lo.checked_sub(1).and_then(|k| cuts.get(k)) { + Some(n) => with(*n), + None => visual_line("…"), + } +} + +impl VisualLine { + /// Each cluster's left and right x from the measured left and right x of every shown char. + /// An invisible cluster sits where the one before it ends. + pub fn cluster_edges(&self, char_edges: &[(f64, f64)]) -> Vec<(f64, f64)> { + let mut x = char_edges.first().map_or(0.0, |e| e.0); + self.clusters + .iter() + .map(|c| { + let l = char_edges.get(c.shown.start).map_or(x, |e| e.0); + let r = c.shown.end.checked_sub(1).filter(|_| !c.shown.is_empty()).and_then(|k| char_edges.get(k)).map_or(l, |e| e.1); + x = r; + (l, r) + }) + .collect() + } + + /// The x of a caret before logical char `i` (`len` = the end), given [`cluster_edges`](Self::cluster_edges). + /// It sits at the leading edge of the char after it (the right edge in a right-to-left run); at + /// the end, after the last char. + pub fn caret_x(&self, i: usize, edges: &[(f64, f64)]) -> f64 { + let i = i.min(self.len); + let at = |k: usize, frac: f64| -> Option { + let c = self.clusters.get(k)?; + let (l, r) = edges.get(k).copied()?; + Some(if c.rtl { r - (r - l) * frac } else { l + (r - l) * frac }) + }; + if let Some((k, c)) = self.clusters.iter().enumerate().find(|(_, c)| c.logical.contains(&i)) { + let frac = (i - c.logical.start) as f64 / c.logical.len().max(1) as f64; + return at(k, frac).unwrap_or(0.0); + } + // The end: after the last logical cluster. + match self.clusters.iter().position(|c| c.logical.end == self.len) { + Some(k) => at(k, 1.0).unwrap_or(0.0), + None => edges.first().map_or(0.0, |e| e.0), + } + } + + /// The logical caret position nearest to `x`. + pub fn hit(&self, x: f64, edges: &[(f64, f64)]) -> usize { + for (c, (l, r)) in self.clusters.iter().zip(edges) { + if x < (l + r) / 2.0 { + return if c.rtl { c.logical.end } else { c.logical.start }; + } + if x < *r { + return if c.rtl { c.logical.start } else { c.logical.end }; + } + } + // Past the right end: the end of a left-to-right line, the start of a right-to-left one. + match self.clusters.last() { + Some(c) if c.rtl => c.logical.start, + Some(c) => c.logical.end, + None => 0, + } + } + + /// The caret one step left or right of `i` on screen (the nearest caret position past it). + /// At the line's edge the caret stays. + pub fn step(&self, i: usize, right: bool, edges: &[(f64, f64)]) -> usize { + let x = self.caret_x(i, edges); + (0..=self.len) + .filter(|k| *k != i) + .map(|k| (k, self.caret_x(k, edges))) + .filter(|(_, cx)| if right { *cx > x + 0.01 } else { *cx < x - 0.01 }) + .min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs()).then(a.0.abs_diff(i).cmp(&b.0.abs_diff(i)))) + .map_or(i, |(k, _)| k) + } + + /// The x spans (left, right) covering logical chars `a..b`, left to right; a selection across + /// direction runs can be several spans. + pub fn selection(&self, a: usize, b: usize, edges: &[(f64, f64)]) -> Vec<(f64, f64)> { + let (a, b) = (a.min(b), a.max(b)); + let mut out: Vec<(f64, f64)> = Vec::new(); + for (c, (l, r)) in self.clusters.iter().zip(edges) { + let (s, e) = (c.logical.start.max(a), c.logical.end.min(b)); + if s >= e { + continue; + } + // Part of a cluster (a lam-alef ligature): its share, from the leading edge. + let n = c.logical.len().max(1) as f64; + let (f0, f1) = ((s - c.logical.start) as f64 / n, (e - c.logical.start) as f64 / n); + let (x0, x1) = if c.rtl { (r - (r - l) * f1, r - (r - l) * f0) } else { (l + (r - l) * f0, l + (r - l) * f1) }; + match out.last_mut() { + Some(last) if (last.1 - x0).abs() < 0.5 => last.1 = x1, + _ => out.push((x0, x1)), + } + } + out + } +} + +/// Explicit bidi formatting characters: they steer the order but show nothing. +fn is_bidi_control(c: char) -> bool { + matches!(c, '\u{200E}' | '\u{200F}' | '\u{061C}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}') +} + +/// The mirrored glyph of a paired character shown in a right-to-left run. +fn mirror(c: char) -> char { + match c { + '(' => ')', + ')' => '(', + '[' => ']', + ']' => '[', + '{' => '}', + '}' => '{', + '<' => '>', + '>' => '<', + '«' => '»', + '»' => '«', + '‹' => '›', + '›' => '‹', + '≤' => '≥', + '≥' => '≤', + other => other, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Join { + /// Doesn't join (spaces, Latin, hamza…). + None, + /// Joins only to the char before it (alef, dal, reh, waw…). + Right, + /// Joins on both sides (beh, seen, lam…). + Dual, + /// Joins whatever is next to it but has no forms (tatweel, zero width joiner). + Causing, + /// Combining marks: skipped when deciding joins. + Transparent, +} + +/// Arabic letters and their presentation forms (isolated, final, initial, medial; the first two +/// only for right-joining letters). +const FORMS: [(char, char, u8); 51] = [ + ('\u{0621}', '\u{FE80}', 1), + ('\u{0622}', '\u{FE81}', 2), + ('\u{0623}', '\u{FE83}', 2), + ('\u{0624}', '\u{FE85}', 2), + ('\u{0625}', '\u{FE87}', 2), + ('\u{0626}', '\u{FE89}', 4), + ('\u{0627}', '\u{FE8D}', 2), + ('\u{0628}', '\u{FE8F}', 4), + ('\u{0629}', '\u{FE93}', 2), + ('\u{062A}', '\u{FE95}', 4), + ('\u{062B}', '\u{FE99}', 4), + ('\u{062C}', '\u{FE9D}', 4), + ('\u{062D}', '\u{FEA1}', 4), + ('\u{062E}', '\u{FEA5}', 4), + ('\u{062F}', '\u{FEA9}', 2), + ('\u{0630}', '\u{FEAB}', 2), + ('\u{0631}', '\u{FEAD}', 2), + ('\u{0632}', '\u{FEAF}', 2), + ('\u{0633}', '\u{FEB1}', 4), + ('\u{0634}', '\u{FEB5}', 4), + ('\u{0635}', '\u{FEB9}', 4), + ('\u{0636}', '\u{FEBD}', 4), + ('\u{0637}', '\u{FEC1}', 4), + ('\u{0638}', '\u{FEC5}', 4), + ('\u{0639}', '\u{FEC9}', 4), + ('\u{063A}', '\u{FECD}', 4), + ('\u{0641}', '\u{FED1}', 4), + ('\u{0642}', '\u{FED5}', 4), + ('\u{0643}', '\u{FED9}', 4), + ('\u{0644}', '\u{FEDD}', 4), + ('\u{0645}', '\u{FEE1}', 4), + ('\u{0646}', '\u{FEE5}', 4), + ('\u{0647}', '\u{FEE9}', 4), + ('\u{0648}', '\u{FEED}', 2), + ('\u{0649}', '\u{FEEF}', 2), + ('\u{064A}', '\u{FEF1}', 4), + ('\u{0671}', '\u{FB50}', 2), + ('\u{0679}', '\u{FB66}', 4), + ('\u{067E}', '\u{FB56}', 4), + ('\u{0686}', '\u{FB7A}', 4), + ('\u{0688}', '\u{FB88}', 2), + ('\u{0691}', '\u{FB8C}', 2), + ('\u{0698}', '\u{FB8A}', 2), + ('\u{06A9}', '\u{FB8E}', 4), + ('\u{06AF}', '\u{FB92}', 4), + ('\u{06BA}', '\u{FB9E}', 2), + ('\u{06BE}', '\u{FBAA}', 4), + ('\u{06C1}', '\u{FBA6}', 4), + ('\u{06CC}', '\u{FBFC}', 4), + ('\u{06D2}', '\u{FBAE}', 2), + ('\u{06D3}', '\u{FBB0}', 2), +]; + +/// Lam-alef ligatures: (alef, isolated form; the final form follows it). +const LAM_ALEF: [(char, char); 4] = [('\u{0622}', '\u{FEF5}'), ('\u{0623}', '\u{FEF7}'), ('\u{0625}', '\u{FEF9}'), ('\u{0627}', '\u{FEFB}')]; + +fn forms(c: char) -> Option<(char, u8)> { + FORMS.iter().find(|f| f.0 == c).map(|f| (f.1, f.2)) +} + +fn joining(c: char) -> Join { + match c { + '\u{0610}'..='\u{061A}' + | '\u{064B}'..='\u{065F}' + | '\u{0670}' + | '\u{06D6}'..='\u{06DC}' + | '\u{06DF}'..='\u{06E4}' + | '\u{06E7}' + | '\u{06E8}' + | '\u{06EA}'..='\u{06ED}' => Join::Transparent, + '\u{0640}' | '\u{200D}' => Join::Causing, + _ => match forms(c) { + Some((_, 4)) => Join::Dual, + Some((_, 2)) => Join::Right, + _ => Join::None, + }, + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Shaped { + /// The char's contextual form. + Form(char), + /// A lam-alef ligature standing for this char and the next. + Ligature(char), +} + +/// Contextual forms of the Arabic letters in `chars` (None = shown as is; the alef of a lam-alef +/// ligature is None too, the ligature stands at the lam). +fn shape_arabic(chars: &[char]) -> Vec> { + let mut out = vec![None; chars.len()]; + let kinds: Vec = chars.iter().map(|c| joining(*c)).collect(); + // The nearest non-transparent neighbour's joining type. + let prev = |i: usize| (0..i).rev().map(|k| kinds.get(k).copied().unwrap_or(Join::None)).find(|j| *j != Join::Transparent).unwrap_or(Join::None); + let next = + |i: usize| (i + 1..chars.len()).map(|k| kinds.get(k).copied().unwrap_or(Join::None)).find(|j| *j != Join::Transparent).unwrap_or(Join::None); + let mut i = 0; + while i < chars.len() { + let (Some(c), Some(kind)) = (chars.get(i).copied(), kinds.get(i).copied()) else { break }; + let joins_before = matches!(prev(i), Join::Dual | Join::Causing); + if c == '\u{0644}' + && let Some(lig) = chars.get(i + 1).and_then(|a| LAM_ALEF.iter().find(|l| l.0 == *a)).map(|l| l.1) + { + let form = if joins_before { char::from_u32(lig as u32 + 1).unwrap_or(lig) } else { lig }; + if let Some(slot) = out.get_mut(i) { + *slot = Some(Shaped::Ligature(form)); + } + i += 2; + continue; + } + if let Some((iso, n)) = forms(c) { + let joins_after = kind == Join::Dual && matches!(next(i), Join::Right | Join::Dual | Join::Causing); + let offset = match (n, joins_before, joins_after) { + (1, _, _) => 0, + (_, false, false) => 0, + (_, true, false) => 1, + (_, false, true) => 2, + (_, true, true) => 3, + }; + if let (Some(slot), Some(f)) = (out.get_mut(i), char::from_u32(iso as u32 + offset)) { + *slot = Some(Shaped::Form(f)); + } + } + i += 1; + } + out +} + +#[cfg(test)] +#[path = "display_tests.rs"] +mod tests; diff --git a/crates/text/src/display_tests.rs b/crates/text/src/display_tests.rs new file mode 100644 index 00000000..f0fe7306 --- /dev/null +++ b/crates/text/src/display_tests.rs @@ -0,0 +1,189 @@ +use super::*; + +/// Every shown char 10 units wide, left to right. +fn edges(v: &VisualLine) -> Vec<(f64, f64)> { + let chars: Vec<(f64, f64)> = (0..v.text.chars().count()).map(|k| (k as f64 * 10.0, k as f64 * 10.0 + 10.0)).collect(); + v.cluster_edges(&chars) +} + +#[test] +fn left_to_right_text_shows_as_is() { + let v = visual_line("Frame 12"); + assert_eq!(v.text, "Frame 12"); + assert!(!v.rtl_para); + assert_eq!(v.clusters.len(), 8); + assert!(!has_rtl("Frame 12")); + let e = edges(&v); + assert_eq!(v.caret_x(0, &e), 0.0); + assert_eq!(v.caret_x(8, &e), 80.0); + assert_eq!(v.hit(14.0, &e), 1); + assert_eq!(v.hit(16.0, &e), 2); + assert_eq!(v.step(3, true, &e), 4); + assert_eq!(v.step(0, false, &e), 0); +} + +#[test] +fn hebrew_runs_right_to_left() { + let v = visual_line("שלום"); + assert_eq!(v.text, "םולש"); + assert!(v.rtl_para); + assert!(v.clusters.iter().all(|c| c.rtl)); + // The caret before the first letter is at the right end; the end of the text at the left. + let e = edges(&v); + assert_eq!(v.caret_x(0, &e), 40.0); + assert_eq!(v.caret_x(4, &e), 0.0); + // Right of the text is its start, left of it its end. + assert_eq!(v.hit(100.0, &e), 0); + assert_eq!(v.hit(-5.0, &e), 4); + // The right arrow goes back towards the start. + assert_eq!(v.step(2, true, &e), 1); + assert_eq!(v.step(2, false, &e), 3); +} + +#[test] +fn mixed_direction_names_order_and_caret() { + // A left-to-right name with a Hebrew word. + let v = visual_line("abc אבג"); + assert_eq!(v.text, "abc גבא"); + assert!(!v.rtl_para); + let e = edges(&v); + assert_eq!(v.caret_x(4, &e), 70.0); + assert_eq!(v.caret_x(7, &e), 40.0); + // Visual right-arrow order across the runs. + let mut at = 0; + let mut seen = vec![at]; + for _ in 0..7 { + at = v.step(at, true, &e); + seen.push(at); + } + assert_eq!(seen, vec![0, 1, 2, 3, 7, 6, 5, 4]); + assert_eq!(v.hit(65.0, &e), 4); + assert_eq!(v.hit(62.0, &e), 5); + // A selection inside the Hebrew word is one span on screen. + assert_eq!(v.selection(4, 6, &e), vec![(50.0, 70.0)]); + // One across the runs is two. + assert_eq!(v.selection(2, 5, &e), vec![(20.0, 40.0), (60.0, 70.0)]); + + // A right-to-left name with a Latin word: the Latin word keeps its order, at the left. + let v = visual_line("אב abc"); + assert_eq!(v.text, "abc בא"); + assert!(v.rtl_para); + let e = edges(&v); + assert_eq!(v.caret_x(0, &e), 60.0); + assert_eq!(v.caret_x(3, &e), 0.0); + assert_eq!(v.caret_x(6, &e), 30.0); +} + +#[test] +fn brackets_mirror_in_right_to_left_runs() { + assert_eq!(visual_line("(שלום)").text, "(םולש)"); + assert_eq!(visual_line("(abc)").text, "(abc)"); +} + +#[test] +fn arabic_letters_join() { + // Salam: seen (initial), lam-alef (final ligature), meem (isolated after the alef). + let v = visual_line("سلام"); + assert_eq!(v.text, "\u{FEE1}\u{FEFC}\u{FEB3}"); + assert_eq!(v.clusters.len(), 3); + assert_eq!(v.clusters.get(1).map(|c| c.logical.clone()), Some(1..3)); + // The caret between lam and alef sits in the middle of the ligature. + let e = edges(&v); + assert_eq!(v.caret_x(2, &e), 15.0); + // Beh: isolated, initial, medial, final. + assert_eq!(visual_line("ب").text, "\u{FE8F}"); + assert_eq!(visual_line("ببب").text, "\u{FE90}\u{FE92}\u{FE91}"); + // Marks stay with their letter and don't break joins. + let v = visual_line("بَب"); + assert_eq!(v.text, "\u{FE90}\u{FE91}\u{064E}"); + assert_eq!(v.clusters.len(), 2); + // Persian letters too. + assert_eq!(visual_line("پی").text, "\u{FBFD}\u{FB58}"); +} + +#[test] +fn bidi_controls_show_nothing() { + let v = visual_line("\u{200F}abc"); + assert!(!v.text.contains('\u{200F}')); + assert_eq!(v.len, 4); + let e = edges(&v); + // Every logical caret position still has a place. + for i in 0..=4 { + assert!(v.caret_x(i, &e).is_finite()); + } +} + +#[test] +fn elision_keeps_the_start_and_follows_the_direction() { + let fits = |v: &VisualLine| v.text.chars().count() <= 5; + assert_eq!(elide("Frame", fits).text, "Frame"); + assert_eq!(elide("Rectangle 12", fits).text, "Rect…"); + let v = elide("שלום עולם", fits); + // The ellipsis is at the left of a right-to-left name; its right end (the start) stays. + assert_eq!(v.text, "…םולש"); + // Nothing fits: just the ellipsis. + assert_eq!(elide("abcdef", |v: &VisualLine| v.text.chars().count() <= 1).text, "…"); + // A letter is never cut from its marks. + let v = elide("بَبَبَبَ", |v: &VisualLine| v.text.chars().count() <= 3); + assert!(v.text.contains('\u{064E}')); +} + +#[test] +fn junk_input_never_panics() { + for s in ["", " ", "\u{0644}", "\u{064E}", "\u{0644}\u{0627}", "a\nב\tc", "\u{202E}\u{2067}x\u{2069}", "ـبـ", "‏‎‏"] { + let v = visual_line(s); + let e = edges(&v); + for i in 0..=v.len + 2 { + let _ = v.caret_x(i, &e); + let _ = v.step(i, true, &e); + let _ = v.step(i, false, &e); + } + let _ = v.hit(-1.0, &e); + let _ = v.hit(1e9, &e); + let _ = v.selection(0, v.len + 3, &e); + let _ = elide(s, |t: &VisualLine| t.text.len() < 3); + // Too few measured edges. + let _ = v.caret_x(1, &[]); + let _ = v.cluster_edges(&[]); + } +} + +/// The joined forms are the ones the shaper picks in context (when Noto Sans Arabic is built in). +#[test] +fn presentation_forms_match_the_shaper() { + let Some(face) = crate::find_face("Noto Sans Arabic", "Regular").filter(|f| f.family == "Noto Sans Arabic") else { + eprintln!("no Noto Sans Arabic; skipping"); + return; + }; + let mut wrong = Vec::new(); + for (base, iso, n) in FORMS { + // Isolated, then (dual-joining) initial, medial and final via tatweels around the letter. + let mut cases = vec![(base.to_string(), iso as u32)]; + if n >= 2 { + cases.push((format!("\u{0640}{base}"), iso as u32 + 1)); + } + if n == 4 { + cases.push((format!("{base}\u{0640}"), iso as u32 + 2)); + cases.push((format!("\u{0640}{base}\u{0640}"), iso as u32 + 3)); + } + for (s, form) in cases { + let form = char::from_u32(form).unwrap(); + assert!(face.has_char(form), "{base:?} form {form:?} missing"); + let mut t = figcraft_doc::TextData::new(s.clone()); + t.style.font_family = "Noto Sans Arabic".into(); + let l = crate::layout(&t, None, None); + let at = s.chars().position(|c| c == base).unwrap(); + // The font may draw a letter as a skeleton plus dots: compare the cluster's advance. + let shaped: f64 = l.glyphs.iter().filter(|g| g.char_start <= at && at < g.char_end).map(|g| g.advance).sum(); + // (Kerning and contextual alternates shift it a little: the right form is the nearest.) + let adv = |c: u32| char::from_u32(c).map_or(f64::NAN, |c| face.advance(face.glyph_for(c)) * t.style.font_size / face.upem); + let nearest = + (iso as u32..iso as u32 + u32::from(n)).min_by(|a, b| (adv(*a) - shaped).abs().total_cmp(&(adv(*b) - shaped).abs())).unwrap(); + // Noto draws a lone heh wider than its isolated presentation form (a design choice). + if (adv(nearest) - adv(form as u32)).abs() > 1e-9 && s != "\u{0647}" { + wrong.push(format!("{base:?} in {s:?} should show as {form:?} ({shaped} vs {})", adv(form as u32))); + } + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index c401402f..7a915747 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -704,3 +704,15 @@ pub fn scan_system_fonts() { crate::clear_layout_cache(); } } + +/// Whether the system fonts were indexed ([`scan_system_fonts`] finished; always false on the web). +pub fn system_fonts_scanned() -> bool { + db().read().unwrap_or_else(std::sync::PoisonError::into_inner).system_scanned +} + +/// The first of `families` (embedded or system) whose regular face draws `c`, for a UI toolkit +/// that needs a font for a script its own fonts lack. +pub fn face_with_char(c: char, families: &[&str]) -> Option> { + let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner); + families.iter().filter_map(|f| db.find(f, "Regular")).find(|f| f.has_char(c)) +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 7dd6696d..c379eab1 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -137,7 +137,7 @@ impl Bidi { } /// Characters of right-to-left scripts and the explicit right-to-left marks. -fn is_rtl_char(c: char) -> bool { +pub(crate) fn is_rtl_char(c: char) -> bool { matches!(c as u32, 0x0590..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFF | 0x10800..=0x10FFF | 0x1E800..=0x1EFFF | 0x200F | 0x202B | 0x202E | 0x2067) } @@ -378,7 +378,16 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) }; let st_idx = items.get(*s).map(|it| it.style).unwrap_or(0); let at = items.get(*s).map(|it| it.char_start).unwrap_or(chars.len()); - let marker_face = items.get(*s).map(|it| (it.face.clone(), it.coords.clone())).or_else(|| default_face.clone().map(|f| (f, no_coords()))); + // The marker takes the paragraph style's own font (not a fallback the line's first + // char needed, e.g. for Hebrew), so every bullet and number of a list matches. + let own = faces.get(st_idx).cloned().flatten().filter(|f| marker.chars().all(|c| f.has_char(c))); + let marker_face = match (own, styles.get(st_idx)) { + (Some(f), Some(st)) => { + let coords = f.coords(&f.axis_values(st)); + Some((f, coords)) + } + _ => items.get(*s).map(|it| (it.face.clone(), it.coords.clone())).or_else(|| default_face.clone().map(|f| (f, no_coords()))), + }; if let (Some(st), Some((face, coords))) = (styles.get(st_idx), marker_face) { let size = sanitize_size(st.font_size); let k = size / face.upem; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index a098ec51..65be2a55 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -6,11 +6,13 @@ //! fills [`Glyph::path`]s and the editor uses [`TextLayout::caret`] / [`TextLayout::hit`]. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod display; pub mod fontdb; mod layout; pub use fontdb::{ - Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, family_names, family_styles, find_face, no_coords, scan_system_fonts, + Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, no_coords, + scan_system_fonts, system_fonts_scanned, }; pub use layout::{Decoration, Glyph, Line, TextLayout, layout}; @@ -133,4 +135,6 @@ pub fn auto_size(n: &Node) -> Option<(f64, f64)> { #[cfg(test)] mod cache_tests; #[cfg(test)] +mod list_tests; +#[cfg(test)] mod tests; diff --git a/crates/text/src/list_tests.rs b/crates/text/src/list_tests.rs new file mode 100644 index 00000000..4086bbc0 --- /dev/null +++ b/crates/text/src/list_tests.rs @@ -0,0 +1,22 @@ +//! List markers: every bullet of a list takes the paragraph style's font, even when a line starts +//! with a char only a fallback font draws (Hebrew, Arabic…). + +use figcraft_doc::{ListStyle, TextData}; + +use crate::layout; + +#[test] +fn markers_use_the_paragraph_font() { + let mut t = TextData::new("First\n\u{05E9}\u{05DC}\u{05D5}\u{05DD}"); + t.list = ListStyle::Bulleted; + let l = layout(&t, None, None); + // Markers are the glyphs that cover no char. + let markers: Vec<_> = l.glyphs.iter().filter(|g| g.char_start == g.char_end).collect(); + if markers.len() != 2 { + eprintln!("no fonts; skipping"); + return; + } + assert_eq!(markers[0].face.id, markers[1].face.id, "both bullets in one font"); + assert_eq!(markers[0].gid, markers[1].gid); + assert!((markers[0].x - markers[1].x).abs() < 1e-9); +} diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index 47b7b1d7..93e03c1a 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -287,7 +287,14 @@ fn library_title(ui: &mut Ui, w: f32, name: &str, open: bool) -> bool { let (hr, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click()); let chevron = Rect::from_center_size(pos2(hr.min.x + PAD + 5.0, hr.center().y), vec2(12.0, 12.0)); icons::paint(ui.painter(), chevron, if open { Icon::Chevron } else { Icon::ChevronRight }, t.icon_secondary); - ui.painter().text(pos2(chevron.max.x + 6.0, hr.center().y), Align2::LEFT_CENTER, name, semibold(11.0), t.text); + crate::bidi::paint_name( + ui.painter(), + pos2(chevron.max.x + 6.0, hr.center().y), + name, + semibold(11.0), + t.text, + hr.max.x - chevron.max.x - 6.0 - PAD, + ); resp.on_hover_text(if open { "Library: click to collapse" } else { "Library: click to expand" }).clicked() } @@ -336,8 +343,7 @@ fn tiles(ui: &mut Ui, w: f32, src: &Source, list: &[Tile], thumbs: &Thumbs, act: ), } let label = Rect::from_min_size(pos2(x, r.max.y + 2.0), vec2(tile, TILE_LABEL - 4.0)); - let galley = ui.painter().layout(item.name.clone(), regular(11.0), t.text, tile); - ui.painter().with_clip_rect(label).galley(pos2(x, label.center().y - galley.size().y / 2.0), galley, t.text); + crate::bidi::paint_name(ui.painter(), pos2(x, label.center().y), &item.name, regular(11.0), t.text, tile); let resp = resp.on_hover_text(format!("{}\nDrag onto the canvas, or click to place in the middle of the view", item.name)); if resp.drag_started() { *act = Some(Act::Drag(AssetDrag { src: src.clone(), component: item.id, set: item.set })); @@ -372,7 +378,7 @@ fn style_rows(ui: &mut Ui, w: f32, src: &Source, list: &[StyleRow], act: &mut Op if let Some(i) = icon { icons::paint(ui.painter(), icon_rect, i, t.icon_secondary); } - ui.painter().text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, &s.name, regular(11.0), t.text); + crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 40.0, r.center().y), &s.name, regular(11.0), t.text, w - 40.0 - PAD); if resp.on_hover_text(format!("{}\nClick to apply to the selection", s.name)).clicked() { *act = Some(Act::Style(src.clone(), s.id.clone())); } diff --git a/crates/ui-egui/src/bidi.rs b/crates/ui-egui/src/bidi.rs new file mode 100644 index 00000000..fdb2f822 --- /dev/null +++ b/crates/ui-egui/src/bidi.rs @@ -0,0 +1,311 @@ +//! Names in right-to-left and mixed-direction scripts (Hebrew, Arabic…) in the panels: layer, page, +//! style, component and file names. egui has no bidi: it shapes each run of one font on its own, +//! reversing right-to-left runs but leaving the words and runs of a name in logical order. So +//! names go through [`figcraft_text::display`] (visual order, Arabic letters joined, mirrored +//! brackets) and every shown cluster is laid out as its own section, which egui's shaper leaves as +//! it is. Names are elided at their end (the left end of a right-to-left name) and edited by +//! [`single_line`], whose caret, clicks and selection follow the shown text (the arrow keys move +//! visually). Left-to-right names take egui's own path unchanged. + +use std::collections::HashMap; +use std::sync::Arc; + +use egui::text::{ByteIndex, CCursor, CCursorRange, LayoutJob, LayoutSection, TextFormat, TextWrapping}; +use egui::{Color32, FontId, Galley, Id, Painter, Pos2, Rect, Response, Stroke, Ui, pos2, vec2}; +use figcraft_text::display::{self, VisualLine}; + +/// Shown (visual, elided) lines by name, font and width: elision measures many candidates, so the +/// result is kept between frames (cleared when it grows large). +#[derive(Clone, Default)] +struct Elided(Arc>>); + +/// A name, its font's size and family (proportional or not) and the width it was cut to (bits). +type ElidedKey = (String, u32, bool, u32); + +const ELIDED_MAX: usize = 4096; + +/// `name` on one line in `font`, at most `max_w` wide (ending in an ellipsis when cut), ready to +/// paint: in visual order with Arabic letters joined when it has right-to-left text. +pub fn name_galley(painter: &Painter, name: &str, font: FontId, color: Color32, max_w: f32) -> Arc { + let max_w = max_w.max(1.0); + if !display::has_rtl(name) { + let mut job = LayoutJob::simple_singleline(name.to_string(), font, color); + job.wrap = TextWrapping::truncate_at_width(max_w); + return painter.layout_job(job); + } + let key = (name.to_string(), font.size.to_bits(), matches!(font.family, egui::FontFamily::Proportional), max_w.to_bits()); + let cache = painter.ctx().data_mut(|d| d.get_temp_mut_or_default::(Id::new("bidi_elided")).clone()); + let hit = cache.0.lock().ok().and_then(|m| m.get(&key).cloned()); + let shown = hit.unwrap_or_else(|| { + let fits = |v: &VisualLine| visual_galley(painter, v, &font, color).size().x <= max_w; + let v = display::elide(name, fits); + if let Ok(mut m) = cache.0.lock() { + if m.len() >= ELIDED_MAX { + m.clear(); + } + m.insert(key, v.clone()); + } + v + }); + visual_galley(painter, &shown, &font, color) +} + +/// A line in display order laid out with each cluster as its own section, so egui's shaper (which +/// would reverse a right-to-left run once more) gets one ordered, joined cluster at a time. +fn visual_galley(painter: &Painter, line: &VisualLine, font: &FontId, color: Color32) -> Arc { + let chars: Vec = line.text.chars().collect(); + let format = TextFormat::simple(font.clone(), color); + let mut job = LayoutJob::default(); + for c in &line.clusters { + let start = job.text.len(); + job.text.extend(chars.get(c.shown.clone()).unwrap_or_default()); + // Pushed as is: `LayoutJob::append` would merge it with the section before. + if job.text.len() > start { + job.sections.push(LayoutSection { leading_space: 0.0, byte_range: ByteIndex(start)..ByteIndex(job.text.len()), format: format.clone() }); + } + } + painter.layout_job(job) +} + +/// Paint `name` (see [`name_galley`]) with its left end at `left_center`'s x, centred on its y; +/// returns the painted rectangle. +pub fn paint_name(painter: &Painter, left_center: Pos2, name: &str, font: FontId, color: Color32, max_w: f32) -> Rect { + let galley = name_galley(painter, name, font, color, max_w); + let rect = Rect::from_min_size(pos2(left_center.x, left_center.y - galley.size().y / 2.0), galley.size()); + painter.galley(rect.min, galley, color); + rect +} + +/// Each shown cluster's left and right x in a galley of `v.text` laid out from x = 0. +fn edges(v: &VisualLine, galley: &Galley) -> Vec<(f64, f64)> { + let chars: Vec<(f64, f64)> = galley + .rows + .first() + .map(|r| r.row.glyphs.iter().map(|g| (f64::from(g.pos.x), f64::from(g.pos.x + g.advance_width))).collect()) + .unwrap_or_default(); + v.cluster_edges(&chars) +} + +/// The shown text of a field being edited, laid out once per frame. +struct Shown { + line: VisualLine, + galley: Arc, + edges: Vec<(f64, f64)>, +} + +impl Shown { + fn new(ui: &Ui, text: &str, font: &FontId, color: Color32) -> Shown { + let line = display::visual_line(text); + let galley = visual_galley(ui.painter(), &line, font, color); + let edges = edges(&line, &galley); + Shown { line, galley, edges } + } + + fn caret_x(&self, i: usize) -> f32 { + self.line.caret_x(i, &self.edges) as f32 + } + + fn hit(&self, x: f32) -> usize { + self.line.hit(f64::from(x), &self.edges) + } +} + +fn scroll_id(id: Id) -> Id { + id.with("bidi_scroll") +} + +fn anchor_id(id: Id) -> Id { + id.with("bidi_anchor") +} + +/// A frameless one-line text editor for a name (a [`egui::TextEdit`] with id `id`). Text with +/// right-to-left characters shows in visual order, and the caret, the selection, clicks, drags and +/// the left and right arrow keys follow what's on screen. +pub fn single_line(ui: &mut Ui, id: Id, text: &mut String, font: FontId, width: f32) -> Response { + let rtl = display::has_rtl(text); + let focused = ui.memory(|m| m.has_focus(id)); + let color = ui.visuals().text_color(); + if rtl && focused { + visual_arrows(ui, id, text, &font, color); + } + // Centred in the height it's given (a rename row), like the name it replaces. + let height = ui.available_height().max(0.0); + let edit = egui::TextEdit::singleline(text) + .id(id) + .frame(egui::Frame::NONE) + .font(font.clone()) + .desired_width(width) + .vertical_align(egui::Align::Center) + .min_size(vec2(0.0, height)); + if !rtl { + ui.data_mut(|d| d.remove::(scroll_id(id))); + return edit.show(ui).response.response; + } + // egui would draw the chars in logical order: hide its text, caret and selection and draw the + // shown text over it. + let out = ui + .scope(|ui| { + let v = ui.visuals_mut(); + v.selection.bg_fill = Color32::TRANSPARENT; + v.selection.stroke = Stroke::new(0.0, Color32::TRANSPARENT); + v.text_cursor.stroke = Stroke::NONE; + edit.text_color(Color32::TRANSPARENT).show(ui) + }) + .inner; + let response = out.response.response; + let shown = Shown::new(ui, text, &font, color); + let clip = out.text_clip_rect; + let mut scroll: f32 = ui.data(|d| d.get_temp(scroll_id(id))).unwrap_or(0.0); + let origin = |scroll: f32| pos2(clip.min.x - scroll, out.galley_pos.y); + // Clicks and drags place the caret by what's on screen. + let mut range = out.state.cursor.char_range(); + if let Some(p) = response.interact_pointer_pos() { + let at = shown.hit(p.x - origin(scroll).x); + if response.double_clicked() { + let (a, b) = word_at(text, at); + range = Some(CCursorRange::two(CCursor::new(a), CCursor::new(b))); + } else if response.clicked() || (response.is_pointer_button_down_on() && !response.dragged()) { + // Pressed (not yet dragging): the caret and the drag's anchor go here. + ui.data_mut(|d| d.insert_temp(anchor_id(id), at)); + range = Some(CCursorRange::one(CCursor::new(at))); + } else if response.dragged() { + let anchor = ui.data(|d| d.get_temp::(anchor_id(id))).unwrap_or(at); + range = Some(CCursorRange { primary: CCursor::new(at), secondary: CCursor::new(anchor), h_pos: None }); + } + if range != out.state.cursor.char_range() { + let mut state = out.state.clone(); + state.cursor.set_char_range(range); + state.store(ui.ctx(), id); + } + } + // Keep the caret in view. + let caret = range.map_or(0, |r| r.primary.index.0); + let cx = shown.caret_x(caret); + let w = clip.width().max(1.0); + if cx - scroll > w - 1.0 { + scroll = cx - w + 1.0; + } + if cx - scroll < 0.0 { + scroll = cx; + } + scroll = scroll.clamp(0.0, (shown.galley.size().x - w + 1.0).max(0.0)); + ui.data_mut(|d| d.insert_temp(scroll_id(id), scroll)); + let o = origin(scroll); + let painter = ui.painter().with_clip_rect(clip.expand2(vec2(1.0, 0.0))); + let h = shown.galley.size().y; + if focused && let Some(r) = range { + let (a, b) = (r.primary.index.0, r.secondary.index.0); + for (x0, x1) in shown.line.selection(a, b, &shown.edges) { + let sel = Rect::from_min_max(pos2(o.x + x0 as f32, o.y), pos2(o.x + x1 as f32, o.y + h)); + painter.rect_filled(sel, 0.0, ui.visuals().selection.bg_fill); + } + } + painter.galley(o, shown.galley.clone(), color); + if focused { + let x = (o.x + cx).round() + 0.5; + painter.line_segment([pos2(x, o.y), pos2(x, o.y + h)], ui.visuals().text_cursor.stroke); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + } + response +} + +/// Plain and Shift-held left and right arrows move the caret across the shown text (consumed so +/// the editor doesn't move it logically). Word and line jumps stay logical. +fn visual_arrows(ui: &mut Ui, id: Id, text: &str, font: &FontId, color: Color32) { + let moves: Vec<(bool, bool)> = ui.input_mut(|i| { + let mut out = Vec::new(); + i.events.retain(|e| match e { + egui::Event::Key { key: key @ (egui::Key::ArrowLeft | egui::Key::ArrowRight), pressed: true, modifiers, .. } + if !modifiers.alt && !modifiers.command && !modifiers.ctrl => + { + out.push((*key == egui::Key::ArrowRight, modifiers.shift)); + false + } + _ => true, + }); + out + }); + if moves.is_empty() { + return; + } + let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) else { return }; + let shown = Shown::new(ui, text, font, color); + let mut r = state.cursor.char_range().unwrap_or_default(); + for (right, shift) in moves { + let (lo, hi) = (r.primary.index.0.min(r.secondary.index.0), r.primary.index.0.max(r.secondary.index.0)); + let next = if !shift && lo != hi { + // A selection collapses to its end on the side of the arrow. + let (xa, xb) = (shown.caret_x(lo), shown.caret_x(hi)); + if (xa < xb) == right { hi } else { lo } + } else { + shown.line.step(r.primary.index.0, right, &shown.edges) + }; + r = if shift { + CCursorRange { primary: CCursor::new(next), secondary: r.secondary, h_pos: None } + } else { + CCursorRange::one(CCursor::new(next)) + }; + } + state.cursor.set_char_range(Some(r)); + state.store(ui.ctx(), id); +} + +/// The word around char `i` (letters, digits and marks; else the single char). +fn word_at(text: &str, i: usize) -> (usize, usize) { + let chars: Vec = text.chars().collect(); + let is_word = + |k: usize| chars.get(k).is_some_and(|c| c.is_alphanumeric() || matches!(*c as u32, 0x0300..=0x036F | 0x0591..=0x05C7 | 0x064B..=0x065F)); + let i = i.min(chars.len()); + let at = if is_word(i) { + i + } else if i > 0 && is_word(i - 1) { + i - 1 + } else { + return (i, (i + 1).min(chars.len())); + }; + let mut a = at; + while a > 0 && is_word(a - 1) { + a -= 1; + } + let mut b = at; + while is_word(b) { + b += 1; + } + (a, b) +} + +/// Faces the UI adds for scripts its own fonts lack (desktop: from the system), as egui font data. +pub fn script_fallbacks(have: &dyn Fn(char) -> bool) -> Vec<(String, egui::FontData)> { + // Hebrew, Arabic letters, Arabic presentation forms (what joined names are drawn with). + const SAMPLES: [char; 3] = ['\u{05D0}', '\u{0628}', '\u{FE91}']; + const FAMILIES: [&str; 11] = [ + "Segoe UI", + "Arial", + "Tahoma", + "Noto Sans Hebrew", + "Noto Sans Arabic", + "Arial Hebrew", + "Geeza Pro", + "Lucida Grande", + "DejaVu Sans", + "FreeSans", + "Noto Sans", + ]; + let mut out: Vec<(String, egui::FontData)> = Vec::new(); + let mut taken: Vec> = Vec::new(); + for c in SAMPLES { + if have(c) || taken.iter().any(|f| f.has_char(c)) { + continue; + } + let Some(face) = figcraft_text::face_with_char(c, &FAMILIES) else { continue }; + let mut data = egui::FontData::from_owned(face.bytes.data().to_vec()); + data.index = face.index; + out.push((format!("system-{}-{}", face.family, face.style), data)); + taken.push(face); + } + out +} + +#[cfg(test)] +#[path = "rtl_names_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index cbe67ed9..29d996ee 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -575,7 +575,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { }; if matches!(n.kind, NodeKind::Section { .. }) { // Section titles are pills inside the top-left. - let galley = painter.layout_no_wrap(n.name.clone(), semibold(11.0), t.text); + let galley = crate::bidi::name_galley(painter, &n.name, semibold(11.0), t.text, 480.0); let r = Rect::from_min_size(tl + vec2(0.0, 0.0), galley.size() + vec2(16.0, 8.0)); painter.rect_filled(r, CornerRadius::same(4), if selected { ACCENT } else { t.hover }); painter.galley(r.min + vec2(8.0, 4.0), galley, if selected { Color32::WHITE } else { t.text }); @@ -587,12 +587,11 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { crate::icons::paint(painter, Rect::from_min_size(pos2(x, tl.y - 16.0), vec2(12.0, 12.0)), crate::icons::Icon::Component, col); x += 14.0; } - let galley = painter.layout(n.name.clone(), regular(11.0), col, max_w.max(20.0)); - if galley.rows.len() <= 1 { - let end = x + galley.size().x; - painter.galley(pos2(x, tl.y - 16.0), galley, col); - status_badge(painter, n.dev_status, pos2(end + 6.0, tl.y - 9.0)); - } + // Names longer than the frame end in an ellipsis (a right-to-left name keeps its start). + let galley = crate::bidi::name_galley(painter, &n.name, regular(11.0), col, (max_w - (x - tl.x)).max(20.0)); + let end = x + galley.size().x; + painter.galley(pos2(x, tl.y - 16.0), galley, col); + status_badge(painter, n.dev_status, pos2(end + 6.0, tl.y - 9.0)); } } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b77a692f..c5368621 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -9,6 +9,8 @@ //! - `ui.pointer {kind: down|move|up|click|double|hover|drag, x, y (page) | sx, sy (screen), to?, shift?, alt?, cmd?}` //! - `ui.key {key, shift?, cmd?, alt?}` — a shortcut as if typed on the canvas //! - `ui.type {text}` — type into the text being edited +//! - `ui.input {events: [{move|down|up: [x, y], button?} | {key, shift?, alt?, cmd?} | {text} | {leave}]}` +//! — raw pointer and key events for the next frame, as if from the window (panels, popovers) //! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog) //! - `ui.set {rightTab?, leftTab?, theme?, home?, palette?, preview?, presentScroll?, …}` //! - `ui.screenshot {path}` — PNG of the window (needs a visible window) @@ -183,6 +185,48 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul None => Err(format!("unknown key `{key}`")), } } + "ui.input" => { + let list = p + .get("events") + .and_then(Value::as_array) + .ok_or("`events` required: [{move|down|up: [x, y]} | {key, shift?, alt?, cmd?} | {text} | {leave: true}]")?; + let pos = |v: &Value| -> Option { + let a = v.as_array()?; + Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32)) + }; + let mut events = Vec::new(); + for e in list.iter().take(256) { + let button = if e.get("button").and_then(Value::as_str) == Some("secondary") { + egui::PointerButton::Secondary + } else { + egui::PointerButton::Primary + }; + let command = b(e, "cmd") || b(e, "ctrl"); + let mods = egui::Modifiers { alt: b(e, "alt"), ctrl: command, shift: b(e, "shift"), mac_cmd: false, command }; + if let Some(p) = e.get("move").and_then(pos) { + events.push(egui::Event::PointerMoved(p)); + } else if let Some((p, pressed)) = + e.get("down").and_then(pos).map(|p| (p, true)).or_else(|| e.get("up").and_then(pos).map(|p| (p, false))) + { + events.push(egui::Event::PointerMoved(p)); + events.push(egui::Event::PointerButton { pos: p, button, pressed, modifiers: mods }); + } else if let Some(k) = e.get("key").and_then(Value::as_str) { + let key = egui::Key::from_name(k).ok_or_else(|| format!("unknown key `{k}`"))?; + for pressed in [true, false] { + events.push(egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: mods }); + } + } else if let Some(t) = e.get("text").and_then(Value::as_str) { + events.push(egui::Event::Text(t.to_string())); + } else if b(e, "leave") { + events.push(egui::Event::PointerGone); + } else { + return Err(format!("unknown input event {e}")); + } + } + app.control_input.extend(events); + ctx.request_repaint(); + Ok(json!({"queued": list.len().min(256)})) + } "ui.type" => { let text = p.get("text").and_then(Value::as_str).ok_or("`text` required")?; figcraft_engine::textedit::insert(&mut app.session, text).map_err(|e| e.to_string())?; @@ -293,7 +337,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul }; player.ok_or("nothing is presented")?.scroll_to(at(0), at(1)); } - // The style picker: a slot (fill, stroke, effect, grid) opens it for the selection, null + // The style picker: a slot (fill, stroke, effect, grid, text) opens it for the selection, null // closes it; `stylePickerEdit` (a style id or name) opens its editor; `stylePickerView` // is `list` or `grid`. if let Some(v) = p.get("stylePicker") { diff --git a/crates/ui-egui/src/credits_tests.rs b/crates/ui-egui/src/credits_tests.rs index a36e53db..4dc7a2d2 100644 --- a/crates/ui-egui/src/credits_tests.rs +++ b/crates/ui-egui/src/credits_tests.rs @@ -43,7 +43,7 @@ struct Ui { impl Ui { fn new() -> Self { let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); + crate::theme::install_fonts(&ctx, false); crate::theme::apply(&ctx, Tokens::light()); let mut u = Self { ctx, time: 0.0 }; // Fonts set now take effect on the next frame. diff --git a/crates/ui-egui/src/font_preview.rs b/crates/ui-egui/src/font_preview.rs new file mode 100644 index 00000000..a16177dc --- /dev/null +++ b/crates/ui-egui/src/font_preview.rs @@ -0,0 +1,100 @@ +//! Text drawn in its own font by FigCraft's text engine, as textures for the panels: font family +//! names in the font picker, text styles in the style browser. Rendered lazily (a few per frame: +//! callers pass a budget) and cached for the session. Textures are white; paint them tinted. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, Mutex}; + +use egui::Id; +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Document, Node, NodeKind, TextData, TextStyle}; + +/// Most previews kept before the cache starts over. +const MAX: usize = 2000; + +#[derive(Clone, Default)] +struct Cache(Arc>>>); + +/// What a preview looks like: the typography that shows plus the text and the screen's scale. +fn key(style: &TextStyle, text: &str, ppp: f32) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + (&style.font_family, &style.font_style, style.font_size.to_bits(), text, ppp.to_bits()).hash(&mut h); + (style.line_height.label(), style.letter_spacing.label(), style.decoration as u8, style.case as u8).hash(&mut h); + for (k, v) in &style.variations { + (k, v.to_bits()).hash(&mut h); + } + h.finish() +} + +/// `text` in `style` (one line, at most 400 × 60 points), or None when it can't be drawn: no such +/// font, or `budget` previews were already rendered this frame (asks for another frame then). +pub fn texture(ctx: &egui::Context, style: &TextStyle, text: &str, budget: &mut usize) -> Option { + let ppp = ctx.pixels_per_point(); + let k = key(style, text, ppp); + let cache = ctx.data_mut(|d| d.get_temp_mut_or_default::(Id::new("font_previews")).clone()); + if let Some(t) = cache.0.lock().ok()?.get(&k) { + return t.clone(); + } + if *budget == 0 { + ctx.request_repaint(); + return None; + } + *budget -= 1; + let tex = render(ctx, style, text, f64::from(ppp)); + let mut map = cache.0.lock().ok()?; + if map.len() > MAX { + map.clear(); + } + map.insert(k, tex.clone()); + tex +} + +/// A family's name in its own regular style (None when the family doesn't draw itself: symbol +/// fonts, or it resolves to another family). +pub fn family(ctx: &egui::Context, family: &str, size: f64, budget: &mut usize) -> Option { + let styles = figcraft_text::family_styles(family); + let style = styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first())?.clone(); + let face = figcraft_text::find_face(family, &style)?; + if !face.family.eq_ignore_ascii_case(family) || !family.chars().all(|c| c == ' ' || face.has_char(c)) { + return None; + } + let st = TextStyle { font_family: family.to_string(), font_style: style, font_size: size, ..TextStyle::default() }; + texture(ctx, &st, family, budget) +} + +fn render(ctx: &egui::Context, style: &TextStyle, text: &str, ppp: f64) -> Option { + let mut doc = Document::new("preview"); + let page = doc.first_page()?; + let id = doc.alloc_id(); + let mut td = TextData::new(text); + td.style = TextStyle { fills: None, link: None, style_id: None, ..style.clone() }; + let mut n = Node::new(id, NodeKind::Text(td), "preview", 1.0, 1.0); + n.fills = vec![Paint::solid(Color::WHITE)]; + let (w, h) = figcraft_text::auto_size(&n)?; + n.width = w.min(400.0); + n.height = h.min(60.0); + doc.insert(n, page, None).ok()?; + let pix = figcraft_render::render_node(&doc, id, ppp.clamp(1.0, 3.0))?; + let (pw, ph) = (pix.width() as usize, pix.height() as usize); + let rgba = figcraft_render::to_rgba8(&pix); + if rgba.len() != pw * ph * 4 || pw == 0 || ph == 0 { + return None; + } + let img = egui::ColorImage::from_rgba_unmultiplied([pw, ph], &rgba); + Some(ctx.load_texture("font-preview", img, egui::TextureOptions::LINEAR)) +} + +/// Paint a preview texture with its left edge at `left_center`'s x, centred on its y, clipped to +/// `max_w` × `max_h` points, tinted `color`; returns the painted width. +pub fn paint(painter: &egui::Painter, tex: &egui::TextureHandle, left_center: egui::Pos2, max_w: f32, max_h: f32, color: egui::Color32) -> f32 { + let ppp = painter.ctx().pixels_per_point().max(0.5); + let size = tex.size_vec2() / ppp; + let shown = egui::vec2(size.x.min(max_w.max(0.0)), size.y.min(max_h.max(0.0))); + // Clip from the middle vertically (tall line heights), from the start horizontally. + let v0 = ((size.y - shown.y) / 2.0 / size.y.max(1.0)).max(0.0); + let uv = egui::Rect::from_min_max(egui::pos2(0.0, v0), egui::pos2(shown.x / size.x.max(1.0), v0 + shown.y / size.y.max(1.0))); + let r = egui::Rect::from_min_size(egui::pos2(left_center.x, left_center.y - shown.y / 2.0), shown); + painter.image(tex.id(), r, uv, color); + shown.x +} diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 30f952c9..91e7caa9 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -217,13 +217,8 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { rename_done = Some((id, new)); } } else { - ui.painter().text( - pos2(r.min.x + 10.0, r.center().y), - Align2::LEFT_CENTER, - &name, - if id == cur { semibold(11.0) } else { regular(11.0) }, - t.text, - ); + let font = if id == cur { semibold(11.0) } else { regular(11.0) }; + crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 10.0, r.center().y), &name, font, t.text, r.width() - 14.0); } if resp.clicked() { select = Some(id); @@ -637,13 +632,9 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { regular(11.0) }; let name = if n.name.is_empty() { n.kind.label().to_string() } else { n.name.clone() }; + // Room left of the lock and eye buttons. let max_w = (rr.max.x - 56.0 - name_x).max(20.0); - let galley = ui.painter().layout(name, font, col, max_w); - ui.painter().with_clip_rect(Rect::from_min_max(pos2(name_x, r.min.y), pos2(rr.max.x - 52.0, r.max.y))).galley( - pos2(name_x, r.center().y - galley.size().y / 2.0), - galley, - col, - ); + crate::bidi::paint_name(ui.painter(), pos2(name_x, r.center().y), &name, font, col, max_w); } // Lock / eye on hover (or when set). let show_lock = resp.hovered() || n.locked; @@ -741,7 +732,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { n => format!("{n} layers"), }; let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::new("layer_drag_ghost"))); - let galley = painter.layout_no_wrap(label, regular(11.0), t.text); + let galley = crate::bidi::name_galley(&painter, &label, regular(11.0), t.text, 240.0); let r = Rect::from_min_size(p + vec2(14.0, 10.0), galley.size() + vec2(16.0, 8.0)); painter.rect_filled(r, CornerRadius::same(5), t.panel); painter.rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, ACCENT), egui::StrokeKind::Inside); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 30da60bc..009decc3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod assets_panel; +pub mod bidi; pub mod canvas; pub mod colorpicker; pub mod comments; @@ -14,6 +15,7 @@ pub mod control; pub mod credits; pub mod dialogs; pub mod export_panel; +pub mod font_preview; pub mod frame_presets; pub mod guides; pub mod handles; @@ -186,6 +188,11 @@ pub struct UiState { pub style_grid: bool, /// Style folders closed in the style picker. pub style_folds: BTreeSet, + /// The live text preview the panels show (`text.preview`'s command and params) and the + /// document revision it was shown at: it ends the first frame nothing asks for it. + pub text_preview: Option<(String, Value, u64)>, + /// A panel asked for the preview this frame. + pub text_preview_wanted: bool, /// The Design panel's Export section shows its preview. pub export_thumb: bool, pub zoom_menu: bool, @@ -248,6 +255,8 @@ impl Default for UiState { style_picker: None, style_grid: false, style_folds: BTreeSet::new(), + text_preview: None, + text_preview_wanted: false, export_thumb: false, zoom_menu: false, find: None, @@ -270,8 +279,12 @@ pub struct FigApp { pub services: Box, worker: Option, fonts_installed: bool, + /// System faces for scripts the built-in fonts lack were added (after the font scan). + script_fonts: bool, /// Control-channel requests waiting for the next frame. pub control_queue: Vec, + /// Input events from the control channel (`ui.input`) for the next frame's raw input. + pub control_input: Vec, } impl FigApp { @@ -283,7 +296,9 @@ impl FigApp { services, worker: None, fonts_installed: false, + script_fonts: false, control_queue: Vec::new(), + control_input: Vec::new(), } } @@ -305,6 +320,41 @@ impl FigApp { } } + /// Show what `command params` would do on the canvas as a live preview (`text.preview`) while + /// a text style, a named instance or an axis slider is hovered or dragged. Call it every frame + /// the preview should stay: it ends on the first frame nothing asks for it. + pub fn preview_text(&mut self, ctx: &egui::Context, command: &str, params: Value) { + self.ui.text_preview_wanted = true; + let revision = self.session.ds().map_or(0, |d| d.revision); + if let Some((c, p, rev)) = &self.ui.text_preview + && c == command + && *p == params + && (*rev == revision || figcraft_engine::textpreview::active(&self.session)) + { + return; + } + let shown = self.session.execute("text.preview", &json!({"command": command, "params": params})).is_ok(); + let revision = self.session.ds().map_or(0, |d| d.revision); + self.ui.text_preview = shown.then(|| (command.to_string(), params, revision)); + // The canvas drew before the panels: show the preview next frame. + ctx.request_repaint(); + } + + /// End the live text preview the panels show (Escape, or nothing hovered any more). + pub fn end_text_preview(&mut self, ctx: &egui::Context) { + if self.ui.text_preview.take().is_some() { + let _ = self.session.execute("text.endPreview", &json!({})); + ctx.request_repaint(); + } + } + + /// Once a frame, after the panels: a preview nothing asked for this frame ends. + fn end_unwanted_text_preview(&mut self, ctx: &egui::Context) { + if !std::mem::take(&mut self.ui.text_preview_wanted) { + self.end_text_preview(ctx); + } + } + pub fn toast(&mut self, msg: impl Into) { self.ui.toast = Some((msg.into(), 0.0)); } @@ -418,8 +468,12 @@ impl FigApp { /// Per-frame logic that doesn't draw (control requests, theme). pub fn logic(&mut self, ctx: &egui::Context) { if !self.fonts_installed { - theme::install_fonts(ctx); + theme::install_fonts(ctx, false); self.fonts_installed = true; + } else if !self.script_fonts && figcraft_text::system_fonts_scanned() { + // Once the system fonts are indexed: faces for names in scripts the UI fonts lack. + self.script_fonts = true; + theme::install_fonts(ctx, true); } let dark = match self.session.prefs.theme.as_str() { "dark" => true, @@ -525,6 +579,7 @@ impl FigApp { menus::popups(self, &ctx); variable_ui::picker(self, &ctx); style_picker::show(self, &ctx); + self.end_unwanted_text_preview(&ctx); dialogs::show(self, &ctx); self.toast_ui(&ctx); #[cfg(not(target_arch = "wasm32"))] @@ -577,6 +632,7 @@ impl FigApp { "colorProfileDialog": self.ui.color_profile.map(|p| p.id()), "stylePicker": self.ui.style_picker.as_ref().map(|p| p.slot().key()), "stylePickerView": if self.ui.style_grid { "grid" } else { "list" }, + "textPreview": figcraft_engine::textpreview::active(&self.session), "comments": {"open": self.ui.comments.open, "draft": self.ui.comments.draft.as_ref().map(|d| [d.0, d.1]), "showResolved": self.ui.comments.show_resolved}, "frameMs": self.ui.frame_ms, "renderMs": self.ui.render_ms, diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 9b13df87..f7da398a 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -181,11 +181,16 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { } pub fn menu_item(ui: &mut Ui, label: &str, shortcut: &str) -> bool { - menu_item_with(ui, label, shortcut, |_, _, _| {}) + menu_row(ui, label, shortcut, |_, _, _| {}).clicked() } /// A menu row with art drawn into it after the label (`art(painter, row rect, text colour)`). pub fn menu_item_with(ui: &mut Ui, label: &str, shortcut: &str, art: impl FnOnce(&egui::Painter, Rect, Color32)) -> bool { + menu_row(ui, label, shortcut, art).clicked() +} + +/// A menu row (see [`menu_item_with`]); its response tells hovering too. +pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art: impl FnOnce(&egui::Painter, Rect, Color32)) -> egui::Response { let t = Tokens::get(ui.ctx()); let w = ui.available_width().max(180.0); let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click()); @@ -204,7 +209,7 @@ pub fn menu_item_with(ui: &mut Ui, label: &str, shortcut: &str, art: impl FnOnce ); } art(ui.painter(), r, col); - resp.clicked() + resp } /// A menu item with a check mark when `on` (the unchecked ones line up with it). @@ -229,7 +234,8 @@ pub(crate) fn block(ui: &mut Ui, h: f32, inset_y: f32, add: impl FnOnce(&mut Ui) fn design(app: &mut FigApp, ui: &mut Ui) { let Ok(ds) = app.session.ds() else { return }; - let doc = ds.doc.clone(); + // The panel shows the document itself, not a live text preview over it. + let doc = ds.text_preview.clone().unwrap_or_else(|| ds.doc.clone()); let sel = ds.selection.clone(); let vector_edit = ds.vector_edit.clone(); if sel.is_empty() { diff --git a/crates/ui-egui/src/rtl_names_tests.rs b/crates/ui-egui/src/rtl_names_tests.rs new file mode 100644 index 00000000..4b117511 --- /dev/null +++ b/crates/ui-egui/src/rtl_names_tests.rs @@ -0,0 +1,149 @@ +//! Right-to-left and mixed-direction names: elided display and the rename field's visual caret, +//! driven through egui headless. + +use egui::{Event, Key, Modifiers, Pos2, RawInput, Rect, vec2}; + +use super::*; +use crate::theme::regular; + +fn frame(ctx: &egui::Context, events: Vec, mut f: impl FnMut(&mut Ui)) { + let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }; + ctx.run_ui(input, |ui| f(ui)).textures_delta.clear(); +} + +fn key(k: Key, shift: bool) -> Event { + Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: if shift { Modifiers::SHIFT } else { Modifiers::NONE } } +} + +fn caret(ctx: &egui::Context, id: Id) -> (usize, usize) { + let r = egui::TextEdit::load_state(ctx, id).and_then(|s| s.cursor.char_range()).unwrap_or_default(); + (r.primary.index.0, r.secondary.index.0) +} + +fn set_caret(ctx: &egui::Context, id: Id, i: usize) { + let mut s = egui::TextEdit::load_state(ctx, id).unwrap_or_default(); + s.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(i)))); + s.store(ctx, id); +} + +#[test] +fn names_elide_at_their_end() { + let ctx = egui::Context::default(); + frame(&ctx, vec![], |ui| { + let p = ui.painter(); + let g = name_galley(p, "Rectangle with a rather long name", regular(11.0), Color32::WHITE, 60.0); + assert!(g.elided); + assert!(g.size().x <= 61.0); + let g = name_galley(p, "Frame", regular(11.0), Color32::WHITE, 200.0); + assert_eq!(g.text(), "Frame"); + // Right to left: shown in visual order; cut names keep their start, at the right. + let g = name_galley(p, "שלום", regular(11.0), Color32::WHITE, 500.0); + assert_eq!(g.text(), "םולש"); + let g = name_galley(p, "שלום עולם ומלואו", regular(11.0), Color32::WHITE, 40.0); + assert!(g.text().starts_with('…'), "{}", g.text()); + assert!(g.size().x <= 41.0); + // Arabic letters join. + let g = name_galley(p, "سلام", regular(11.0), Color32::WHITE, 500.0); + assert_eq!(g.text(), "\u{FEE1}\u{FEFC}\u{FEB3}"); + }); +} + +#[test] +fn rename_caret_moves_visually() { + let ctx = egui::Context::default(); + let id = Id::new("rename_test"); + let mut text = "abc אבג".to_string(); + let run = |events: Vec, text: &mut String| { + frame(&ctx, events, |ui| { + ui.memory_mut(|m| m.request_focus(id)); + single_line(ui, id, text, regular(11.0), 200.0); + }); + }; + run(vec![], &mut text); + run(vec![], &mut text); + set_caret(&ctx, id, 3); + // Right from the space: into the Hebrew word at its visual left, which is its logical end. + run(vec![key(Key::ArrowRight, false)], &mut text); + assert_eq!(caret(&ctx, id).0, 7); + run(vec![key(Key::ArrowRight, false)], &mut text); + assert_eq!(caret(&ctx, id).0, 6); + // Shift extends from where the caret was. + run(vec![key(Key::ArrowRight, true)], &mut text); + assert_eq!(caret(&ctx, id), (5, 6)); + // Without Shift a selection collapses to its side. + run(vec![key(Key::ArrowLeft, false)], &mut text); + assert_eq!(caret(&ctx, id), (6, 6)); + run(vec![key(Key::ArrowLeft, false), key(Key::ArrowLeft, false)], &mut text); + assert_eq!(caret(&ctx, id).0, 3); + // Typing still goes in at the caret. + run(vec![Event::Text("ד".into())], &mut text); + assert_eq!(text, "abcד אבג"); + // Plain left-to-right text keeps egui's own caret. + let mut latin = "Frame".to_string(); + let lid = Id::new("latin"); + let mut run = |events: Vec| { + frame(&ctx, events, |ui| { + ui.memory_mut(|m| m.request_focus(lid)); + single_line(ui, lid, &mut latin, regular(11.0), 200.0); + }); + }; + run(vec![]); + run(vec![]); + set_caret(&ctx, lid, 1); + run(vec![key(Key::ArrowRight, false)]); + assert_eq!(caret(&ctx, lid).0, 2); +} + +#[test] +fn clicks_place_the_caret_by_what_is_shown() { + let ctx = egui::Context::default(); + let id = Id::new("click_test"); + let mut text = "שלום".to_string(); + let mut rect = Rect::NOTHING; + let run = |events: Vec, text: &mut String, rect: &mut Rect| { + frame(&ctx, events, |ui| { + ui.memory_mut(|m| m.request_focus(id)); + *rect = single_line(ui, id, text, regular(11.0), 200.0).rect; + }); + }; + run(vec![], &mut text, &mut rect); + run(vec![], &mut text, &mut rect); + // The text's right end is its start. + let galley = ctx.fonts_mut(|f| f.layout_no_wrap("םולש".into(), regular(11.0), Color32::WHITE)); + let right = Pos2::new(rect.min.x + galley.size().x - 1.0, rect.center().y); + let press = |p: Pos2, pressed: bool| Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + run(vec![Event::PointerMoved(right), press(right, true)], &mut text, &mut rect); + run(vec![press(right, false)], &mut text, &mut rect); + assert_eq!(caret(&ctx, id).0, 0); + let left = Pos2::new(rect.min.x + 1.0, rect.center().y); + run(vec![Event::PointerMoved(left), press(left, true)], &mut text, &mut rect); + run(vec![press(left, false)], &mut text, &mut rect); + assert_eq!(caret(&ctx, id).0, 4); +} + +#[test] +fn words_for_double_click() { + assert_eq!(word_at("abc אבג", 5), (4, 7)); + assert_eq!(word_at("abc אבג", 7), (4, 7)); + assert_eq!(word_at("abc אבג", 1), (0, 3)); + assert_eq!(word_at("a b", 2), (2, 3)); + assert_eq!(word_at("", 3), (0, 0)); +} + +/// egui shapes runs of one font itself (reversing right-to-left ones): shown names are laid out a +/// cluster at a time, in the order computed here, one glyph per shown char. +#[test] +fn shown_glyphs_keep_the_visual_order() { + let ctx = egui::Context::default(); + frame(&ctx, vec![], |ui| { + for name in + ["\u{05D0}\u{05D1}", "abc \u{05D0}\u{05D1}\u{05D2} (1)", "\u{0633}\u{0644}\u{0627}\u{0645} \u{0639}\u{0644}\u{064A}\u{0643}\u{0645}"] + { + let v = display::visual_line(name); + let g = visual_galley(ui.painter(), &v, ®ular(11.0), Color32::WHITE); + let glyphs: Vec<(char, f32)> = g.rows.iter().flat_map(|r| r.row.glyphs.iter().map(|gl| (gl.chr, gl.pos.x))).collect(); + assert_eq!(glyphs.iter().map(|g| g.0).collect::(), v.text, "{name}"); + assert!(glyphs.windows(2).all(|w| w[0].1 <= w[1].1), "{name}: left to right"); + } + }); +} diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index e4219721..2d0433ec 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -1,14 +1,18 @@ -//! The style picker: the popover a Fill, Stroke, Effects or Layout grid section's Styles button -//! opens, laid out like the variable picker. Search; styles grouped by the folders of their -//! `/`-separated names (folders collapse); a list or, for colour styles, a grid of swatches. Click -//! applies a style; its edit button (or a right-click) edits the name, description and values; `+` -//! creates a style from the selection's current values; the detach button unlinks it. Colour slots -//! also list colour variables to bind (this file's, then each enabled library's). +//! The style browser: the popover a Fill, Stroke, Effects, Layout grid or Typography section's +//! Styles button opens, laid out like the variable picker. Search; this file's styles, then each +//! enabled library's published ones, grouped by the folders of their `/`-separated names (folders +//! collapse); a list or, for colour and text styles, a grid. Text styles show in their own font, +//! and while the pointer is over one the canvas shows it on the selected text (or the selected +//! chars of the text being edited). Click applies a style (a library's is copied in, linked); a +//! local style's edit button (or a right-click) edits its name, description and values; `+` +//! creates a style from the selection (for text, the selected chars); the detach button unlinks +//! it. Colour slots also list colour variables to bind (this file's, then each enabled library's). use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, Ui, pos2, vec2}; use figcraft_color::Paint; -use figcraft_doc::{Document, GridKind, Node, NodeId, Style, StyleKind, VariableType}; -use serde_json::json; +use figcraft_doc::{Document, GridKind, Node, NodeId, PublishedLibrary, Style, StyleKind, TextStyle, VariableType, trim_num}; +use figcraft_engine::library; +use serde_json::{Value, json}; use crate::FigApp; use crate::colorpicker::PickerTarget; @@ -20,8 +24,14 @@ use crate::widgets; const W: f32 = 240.0; /// Height of a list row. const ROW_H: f32 = 26.0; +/// Height of a text style's list row (its name in its own font). +const TEXT_ROW_H: f32 = 32.0; /// Side of a swatch in the grid view. const SWATCH: f32 = 24.0; +/// A text style's tile in the grid view. +const TILE: egui::Vec2 = egui::vec2(54.0, 44.0); +/// Largest size a text style's name previews at in the list (the grid's "Ag" goes to 24). +const LIST_PREVIEW_MAX: f64 = 16.0; /// A style slot on a layer: what a style picker lists and what a style chip shows. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -30,6 +40,7 @@ pub enum StyleSlot { Stroke, Effect, Grid, + Text, } impl StyleSlot { @@ -39,6 +50,7 @@ impl StyleSlot { "stroke" => Some(StyleSlot::Stroke), "effect" => Some(StyleSlot::Effect), "grid" => Some(StyleSlot::Grid), + "text" => Some(StyleSlot::Text), _ => None, } } @@ -50,6 +62,7 @@ impl StyleSlot { StyleSlot::Stroke => "stroke", StyleSlot::Effect => "effect", StyleSlot::Grid => "grid", + StyleSlot::Text => "text", } } @@ -59,6 +72,7 @@ impl StyleSlot { StyleSlot::Stroke => n.styles.stroke.clone(), StyleSlot::Effect => n.styles.effect.clone(), StyleSlot::Grid => n.styles.grid.clone(), + StyleSlot::Text => n.styles.text.clone(), } } @@ -66,21 +80,29 @@ impl StyleSlot { matches!(self, StyleSlot::Fill | StyleSlot::Stroke) } + /// Whether the slot's styles also show as a grid (swatches, "Ag" tiles). + fn has_grid(self) -> bool { + self.paints() || self == StyleSlot::Text + } + fn title(self) -> &'static str { match self { StyleSlot::Fill | StyleSlot::Stroke => "Color styles", StyleSlot::Effect => "Effect styles", StyleSlot::Grid => "Grid styles", + StyleSlot::Text => "Text styles", } } - /// `style.create {type}` for this slot (taken from the layer's fills, strokes, effects or grids). + /// `style.create {type}` for this slot (taken from the layer's fills, strokes, effects, grids + /// or text). fn create_type(self) -> &'static str { match self { StyleSlot::Fill => "paint", StyleSlot::Stroke => "stroke", StyleSlot::Effect => "effect", StyleSlot::Grid => "grid", + StyleSlot::Text => "text", } } @@ -90,6 +112,7 @@ impl StyleSlot { StyleSlot::Fill | StyleSlot::Stroke => "Color", StyleSlot::Effect => "Effect", StyleSlot::Grid => "Grid", + StyleSlot::Text => "Text", } } @@ -99,11 +122,16 @@ impl StyleSlot { (StyleKind::Paint { .. }, StyleSlot::Fill | StyleSlot::Stroke) | (StyleKind::Effect { .. }, StyleSlot::Effect) | (StyleKind::Grid { .. }, StyleSlot::Grid) + | (StyleKind::Text { .. }, StyleSlot::Text) ) } fn icon(self) -> Icon { - if self == StyleSlot::Grid { Icon::Grid } else { Icon::Effects } + match self { + StyleSlot::Grid => Icon::Grid, + StyleSlot::Text => Icon::Text, + _ => Icon::Effects, + } } } @@ -151,15 +179,33 @@ fn editing(s: &Style) -> Editing { Editing { id: s.id.clone(), name: s.name.clone(), description: s.description.clone(), focus: false } } -/// The style `slot` of every one of `ids` is linked to, if they agree. -fn linked_style(doc: &Document, ids: &[NodeId], slot: StyleSlot) -> Option { - crate::right::common(doc, ids, |n| slot.linked(n)).flatten() +/// The style `slot` of `ids` links to: `Some(Some(id))` one style, `Some(None)` none, None when +/// they differ. Text goes by chars: the selected chars of the text being edited, else every char. +fn link_of(app: &FigApp, doc: &Document, ids: &[NodeId], slot: StyleSlot) -> Option> { + if slot != StyleSlot::Text { + return crate::right::common(doc, ids, |n| slot.linked(n)); + } + let range = figcraft_engine::textedit::selected_range(&app.session); + let styles = crate::typography::target_styles(doc, ids, range); + let mut links = styles.iter().map(|s| s.style_id.clone()); + let Some(first) = links.next() else { return Some(None) }; + links.all(|l| l == first).then_some(first) } -/// The applied style's name (click opens the picker) with a detach button. +/// The text style the targeted chars of `ids` link to (see [`link_of`]). +pub fn text_link(app: &FigApp, doc: &Document, ids: &[NodeId]) -> Option> { + link_of(app, doc, ids, StyleSlot::Text) +} + +/// The applied style's name (click opens the picker) with a detach button; for text, "Mixed" +/// when the chars link to different styles (or some to none). pub fn chip(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], slot: StyleSlot) { let t = Tokens::get(ui.ctx()); - let Some(name) = linked_style(doc, ids, slot).and_then(|s| doc.style(&s).map(|st| st.name.clone())) else { return }; + let (name, mixed) = match link_of(app, doc, ids, slot) { + Some(Some(id)) => (doc.style(&id).map_or_else(|| "Missing style".to_string(), |st| st.name.clone()), false), + None if slot == StyleSlot::Text => ("Mixed".to_string(), true), + _ => return, + }; let mut open_it = false; crate::right::row(ui, 32.0, |ui| { let w = (ui.available_width() - 32.0).max(40.0); @@ -167,11 +213,14 @@ pub fn chip(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], slot: if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } - icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(14.0, 14.0)), Icon::Styles, t.icon_secondary); - let galley = ui.painter().layout(name.clone(), regular(11.0), t.text, r.width() - 32.0); - ui.painter().galley(pos2(r.min.x + 26.0, r.center().y - galley.size().y / 2.0), galley, t.text); - open_it = resp.on_hover_text("Change style").clicked(); - if icons::button(ui, Icon::Detach, 24.0, false, "Detach style").clicked() { + let icon = if slot == StyleSlot::Text { Icon::Text } else { Icon::Styles }; + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(14.0, 14.0)), icon, t.icon_secondary); + let col = if mixed { t.text_secondary } else { t.text }; + crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 26.0, r.center().y), &name, regular(11.0), col, r.width() - 32.0); + let tip = if mixed { "The selection uses several text styles: click to apply one" } else { "Change style" }; + open_it = resp.on_hover_text(tip).clicked(); + let detach = if mixed { "Detach styles" } else { "Detach style" }; + if icons::button(ui, Icon::Detach, 24.0, false, detach).clicked() { app.run("style.detach", json!({"ids": ids, "which": slot.key()})); } }); @@ -180,21 +229,60 @@ pub fn chip(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], slot: } } +/// A listed style: by its library's id (None: this file's) and its id there. +#[derive(Clone, Debug, PartialEq)] +struct Key { + library: Option, + style: String, +} + +impl Key { + /// What `local` (a style in this document) is: a library style's copy, or this file's own. + fn of_local(doc: &Document, local: &str) -> Key { + match doc.libraries.style_import(local) { + Some(r) => Key { library: Some(r.library.clone()), style: r.key.clone() }, + None => Key { library: None, style: local.to_string() }, + } + } + + /// The command (and its params) applying this style to `ids`. + fn apply(&self, ids: &[NodeId], slot: StyleSlot) -> (&'static str, Value) { + let mut q = match &self.library { + Some(lib) => json!({"library": lib, "style": self.style, "ids": ids}), + None => json!({"ids": ids, "style": self.style}), + }; + if slot.paints() { + q["target"] = json!(slot.key()); + } + (if self.library.is_some() { "library.applyStyle" } else { "style.apply" }, q) + } +} + /// What the user asked the picker to do this frame. enum Act { - Apply(String), + Apply(Key), Bind(crate::variable_ui::Pick), Create, Detach, Edit(String), Back, EditPaint(String, usize), + /// A text style's new definition (from the editor's fields). + SetText(String, TextStyle), UpdateFromSelection(String), Delete(String), ToggleFolder(String), ToggleView, } +/// What a listing reports besides acts: the style under the pointer, and the preview budget. +struct Frame { + act: Option, + hover: Option, + /// Text previews still allowed to render this frame. + budget: usize, +} + /// An icon button on the dark popover. fn menu_icon(ui: &mut Ui, rect: Rect, icon: Icon, tip: &str, on: bool) -> Response { let t = Tokens::get(ui.ctx()); @@ -238,6 +326,48 @@ fn next_name(doc: &Document, base: &str) -> String { (1..).map(|n| format!("{base} {n}")).find(|n| doc.styles.iter().all(|s| &s.name != n)).unwrap_or_else(|| base.to_string()) } +/// Styles by folder, folders in order of first appearance, top-level styles first. +fn by_folder<'a>(styles: impl Iterator) -> Vec<(&'a str, Vec<&'a Style>)> { + let mut groups: Vec<(&str, Vec<&Style>)> = Vec::new(); + for s in styles { + let f = folder(&s.name); + match groups.iter_mut().find(|(g, _)| *g == f) { + Some((_, list)) => list.push(s), + None => groups.push((f, vec![s])), + } + } + groups.sort_by_key(|(f, _)| !f.is_empty()); + groups +} + +/// A text style's definition, if `s` is one. +fn text_def(s: &Style) -> Option<&TextStyle> { + match &s.kind { + StyleKind::Text { style } => Some(style), + _ => None, + } +} + +/// "32/Auto": a text style's size and line height. +fn text_meta(def: &TextStyle) -> String { + format!("{}/{}", trim_num(def.font_size), def.line_height.label()) +} + +/// A text style's sample (`text` in its own font at most `max` big), painted from `left_center`; +/// falls back to plain UI text until the preview renders (or when the font is missing). +fn text_sample(ui: &Ui, def: &TextStyle, text: &str, max: f64, left_center: egui::Pos2, max_w: f32, max_h: f32, budget: &mut usize) { + let t = Tokens::get(ui.ctx()); + let shown = TextStyle { font_size: def.font_size.clamp(6.0, max), ..def.clone() }; + match crate::font_preview::texture(ui.ctx(), &shown, text, budget) { + Some(tex) => { + crate::font_preview::paint(ui.painter(), &tex, left_center, max_w, max_h, t.menu_text); + } + None => { + crate::bidi::paint_name(ui.painter(), left_center, text, regular(11.0), t.menu_text, max_w); + } + } +} + /// Draw a style's preview (its first paint, or the slot's icon) centred in `r`. fn preview(ui: &Ui, r: Rect, s: &Style, slot: StyleSlot) { let t = Tokens::get(ui.ctx()); @@ -250,68 +380,133 @@ fn preview(ui: &Ui, r: Rect, s: &Style, slot: StyleSlot) { } } -/// One style in the list: preview, name, a check when applied and an edit button on hover. -fn style_row(ui: &mut Ui, s: &Style, slot: StyleSlot, applied: bool, editable: bool, act: &mut Option) { +/// Click, right-click and hover on a listed style become acts. +fn react(resp: &Response, key: Key, editable: bool, out: &mut Frame) { + if resp.hovered() { + out.hover = Some(key.clone()); + } + if resp.secondary_clicked() && editable { + out.act = Some(Act::Edit(key.style)); + } else if resp.clicked() { + out.act = Some(Act::Apply(key)); + } +} + +/// One style in the list: preview (a text style: its name in its own font and its size), name, +/// a check when applied and an edit button on hover. +fn style_row(ui: &mut Ui, s: &Style, key: Key, slot: StyleSlot, applied: bool, editable: bool, out: &mut Frame) { let t = Tokens::get(ui.ctx()); - let (r, resp) = ui.allocate_exact_size(vec2(W, ROW_H), Sense::click()); + let h = if slot == StyleSlot::Text { TEXT_ROW_H } else { ROW_H }; + let (r, resp) = ui.allocate_exact_size(vec2(W, h), Sense::click()); let edit_r = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(22.0, 22.0)); let hovered = resp.hovered() || ui.rect_contains_pointer(edit_r); if hovered { ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } - preview(ui, Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(14.0, 14.0)), s, slot); - let galley = ui.painter().layout(leaf(&s.name).to_string(), regular(11.0), t.menu_text, W - 64.0); - ui.painter().galley(pos2(r.min.x + 26.0, r.center().y - galley.size().y / 2.0), galley, t.menu_text); + match text_def(s) { + Some(def) => { + let meta = text_meta(def); + let meta_w = ui.painter().layout_no_wrap(meta.clone(), regular(10.0), t.menu_dim).size().x; + ui.painter().text( + pos2(edit_r.min.x - 4.0, r.center().y), + Align2::RIGHT_CENTER, + meta, + regular(10.0), + if hovered { t.menu_text } else { t.menu_dim }, + ); + let max_w = edit_r.min.x - 12.0 - meta_w - (r.min.x + 8.0); + text_sample(ui, def, leaf(&s.name), LIST_PREVIEW_MAX, pos2(r.min.x + 8.0, r.center().y), max_w, h - 4.0, &mut out.budget); + } + None => { + preview(ui, Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(14.0, 14.0)), s, slot); + crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 26.0, r.center().y), leaf(&s.name), regular(11.0), t.menu_text, W - 64.0); + } + } if hovered && editable { if menu_icon(ui, edit_r, Icon::Adjust, "Edit style", false).clicked() { - *act = Some(Act::Edit(s.id.clone())); + out.act = Some(Act::Edit(s.id.clone())); return; } } else if applied { icons::paint(ui.painter(), edit_r.shrink(5.0), Icon::Check, t.menu_text); } - let resp = if s.description.is_empty() { resp } else { resp.on_hover_text(&s.description) }; - if resp.secondary_clicked() && editable { - *act = Some(Act::Edit(s.id.clone())); - } else if resp.clicked() { - *act = Some(Act::Apply(s.id.clone())); - } + let tip = match (s.description.is_empty(), text_def(s)) { + (true, None) => None, + (true, Some(def)) => Some(format!("{}\n{} {} · {}", s.name, def.font_family, def.font_style, text_meta(def))), + (false, _) => Some(s.description.clone()), + }; + let resp = match tip { + Some(tip) => resp.on_hover_text(tip), + None => resp, + }; + react(&resp, key, editable, out); } -/// Colour styles as a grid of swatches (name on hover). -fn swatch_grid(ui: &mut Ui, list: &[&Style], applied: Option<&str>, editable: impl Fn(&str) -> bool, act: &mut Option) { +/// Colour styles as swatches and text styles as "Ag" tiles in their own font (names on hover). +fn style_grid(ui: &mut Ui, list: &[(&Style, Key)], applied: Option<&Key>, editable: impl Fn(&Key) -> bool, out: &mut Frame) { let t = Tokens::get(ui.ctx()); - let per_row = ((W - 8.0 + 6.0) / (SWATCH + 6.0)).floor().max(1.0) as usize; + let text = list.first().is_some_and(|(s, _)| text_def(s).is_some()); + let cell = if text { TILE } else { vec2(SWATCH, SWATCH) }; + let per_row = ((W - 8.0 + 6.0) / (cell.x + 6.0)).floor().max(1.0) as usize; for chunk in list.chunks(per_row) { ui.horizontal(|ui| { ui.add_space(4.0); ui.spacing_mut().item_spacing.x = 6.0; - for s in chunk { - let (r, resp) = ui.allocate_exact_size(vec2(SWATCH, SWATCH), Sense::click()); - preview(ui, r, s, StyleSlot::Fill); - if applied == Some(s.id.as_str()) { - ui.painter().rect_stroke(r.expand(2.0), CornerRadius::same(4), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); - } else if resp.hovered() { + for (s, key) in chunk { + let (r, resp) = ui.allocate_exact_size(cell, Sense::click()); + match text_def(s) { + Some(def) => { + let fill = if resp.hovered() { t.menu_hover } else { Color32::from_white_alpha(14) }; + ui.painter().rect_filled(r, CornerRadius::same(5), fill); + let sample_w = ui.painter().layout_no_wrap("Ag".into(), regular(18.0), t.menu_text).size().x; + text_sample( + ui, + def, + "Ag", + 24.0, + pos2(r.center().x - sample_w / 2.0, r.center().y), + r.width() - 6.0, + r.height() - 4.0, + &mut out.budget, + ); + } + None => preview(ui, r, s, StyleSlot::Fill), + } + if applied == Some(key) { + ui.painter().rect_stroke(r.expand(2.0), CornerRadius::same(5), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + } else if resp.hovered() && !text { ui.painter().rect_stroke(r.expand(2.0), CornerRadius::same(4), Stroke::new(1.0, t.menu_text), egui::StrokeKind::Inside); } - let resp = resp.on_hover_text(&s.name); - if resp.secondary_clicked() && editable(&s.id) { - *act = Some(Act::Edit(s.id.clone())); - } else if resp.clicked() { - *act = Some(Act::Apply(s.id.clone())); - } + let tip = match text_def(s) { + Some(def) => format!("{}\n{} {} · {}", s.name, def.font_family, def.font_style, text_meta(def)), + None => s.name.clone(), + }; + let resp = resp.on_hover_text(tip); + react(&resp, key.clone(), editable(key), out); } }); ui.add_space(2.0); } } -/// The style list: header, search, folders of styles and (colour slots) colour variables. -fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, act: &mut Option) { +/// The styles a source offers (this file's, or a library's published ones) that the slot holds +/// and the search matches. +fn offered<'a>(doc: &'a Document, lib: Option<&'a PublishedLibrary>, slot: StyleSlot, q: &str) -> Vec<&'a Style> { + let matches = |s: &&Style| slot.holds(&s.kind) && (q.is_empty() || s.name.to_lowercase().contains(q)); + match lib { + Some(l) => l.doc.styles.iter().filter(|s| l.manifest.style(&s.id).is_some_and(|x| x.published)).filter(matches).collect(), + None => library::local_styles(doc).filter(matches).collect(), + } +} + +/// The style list: header, search, this file's and the libraries' styles by folder, and (colour +/// slots) colour variables. +fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) { let t = Tokens::get(ui.ctx()); let slot = p.slot; - let applied = linked_style(doc, &p.ids, slot); - let grid = slot.paints() && app.ui.style_grid; + let link = link_of(app, doc, &p.ids, slot); + let applied = link.clone().flatten().map(|id| Key::of_local(doc, &id)); + let grid = slot.has_grid() && app.ui.style_grid; // Header: title, then view toggle, detach and create. let (hr, _) = ui.allocate_exact_size(vec2(W, 24.0), Sense::hover()); ui.painter().text(pos2(hr.min.x + 2.0, hr.center().y), Align2::LEFT_CENTER, slot.title(), semibold(11.0), t.menu_text); @@ -321,16 +516,17 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, act: &mut x -= 26.0; r }; - if menu_icon(ui, slot_rect(), Icon::Plus, "Create style", false).clicked() { - *act = Some(Act::Create); + let create_tip = if slot == StyleSlot::Text { "Create style from the selected text" } else { "Create style" }; + if menu_icon(ui, slot_rect(), Icon::Plus, create_tip, false).clicked() { + out.act = Some(Act::Create); } - if applied.is_some() && menu_icon(ui, slot_rect(), Icon::Detach, "Detach style", false).clicked() { - *act = Some(Act::Detach); + if link != Some(None) && menu_icon(ui, slot_rect(), Icon::Detach, "Detach style", false).clicked() { + out.act = Some(Act::Detach); } - if slot.paints() { + if slot.has_grid() { let (icon, tip) = if grid { (Icon::ViewList, "Show as list") } else { (Icon::ViewGrid, "Show as grid") }; if menu_icon(ui, slot_rect(), icon, tip, false).clicked() { - *act = Some(Act::ToggleView); + out.act = Some(Act::ToggleView); } } ui.add_space(4.0); @@ -340,52 +536,75 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, act: &mut } ui.add_space(4.0); let q = p.query.trim().to_lowercase(); - let matches = |name: &str| q.is_empty() || name.to_lowercase().contains(&q); - // Styles by folder, folders in order of first appearance. - let mut groups: Vec<(&str, Vec<&Style>)> = Vec::new(); - for s in doc.styles.iter().filter(|s| slot.holds(&s.kind) && matches(&s.name)) { - let f = folder(&s.name); - match groups.iter_mut().find(|(g, _)| *g == f) { - Some((_, list)) => list.push(s), - None => groups.push((f, vec![s])), + let libs = crate::variable_ui::libraries(app, doc); + let mut sources: Vec<(Option<&PublishedLibrary>, Vec<&Style>)> = vec![(None, offered(doc, None, slot, &q))]; + for l in &libs { + let list = offered(doc, Some(l), slot, &q); + if !list.is_empty() { + sources.push((Some(l.as_ref()), list)); } } - groups.sort_by_key(|(f, _)| !f.is_empty()); - let editable = |id: &str| !figcraft_engine::library::is_remote_style(doc, id); - let libs = if slot.paints() { crate::variable_ui::libraries(app, doc) } else { Vec::new() }; - let colors = - if slot.paints() { crate::variable_ui::groups(doc, &libs, |_, v| v.kind == VariableType::Color && matches(&v.name)) } else { Vec::new() }; + let colors = if slot.paints() { + crate::variable_ui::groups(doc, &libs, |_, v| v.kind == VariableType::Color && (q.is_empty() || v.name.to_lowercase().contains(&q))) + } else { + Vec::new() + }; egui::ScrollArea::vertical().id_salt("style_picker_scroll").max_height(320.0).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; - if groups.is_empty() { - let any = doc.styles.iter().any(|s| slot.holds(&s.kind)); - caption(ui, if any { "No matching styles" } else { "No styles yet. Click + to create one from the selection." }); + if sources.iter().all(|(_, l)| l.is_empty()) { + let any = library::local_styles(doc).any(|s| slot.holds(&s.kind)); + let hint = match (any, slot) { + (true, _) => "No matching styles", + (false, StyleSlot::Text) => "No text styles yet. Click + to create one from the selected text.", + (false, _) => "No styles yet. Click + to create one from the selection.", + }; + caption(ui, hint); } - for (f, list) in &groups { - if !f.is_empty() { - // Searching shows every match, folders open. - let closed = q.is_empty() && app.ui.style_folds.contains(*f); - let (r, resp) = ui.allocate_exact_size(vec2(W, ROW_H), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); - } - let chevron = Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0)); - icons::paint(ui.painter(), chevron, if closed { Icon::ChevronRight } else { Icon::Chevron }, t.menu_dim); - let label = f.split('/').map(str::trim).collect::>().join(" / "); - ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.menu_dim); - if resp.clicked() && q.is_empty() { - *act = Some(Act::ToggleFolder(f.to_string())); - } - if closed { - continue; - } + for (lib, list) in &sources { + if let Some(l) = lib { + ui.add_space(6.0); + caption(ui, &l.manifest.name); + } else if list.is_empty() { + continue; + } else if sources.len() > 1 { + caption(ui, "This file"); } - if grid { - ui.add_space(4.0); - swatch_grid(ui, list, applied.as_deref(), editable, act); - } else { - for s in list { - style_row(ui, s, slot, applied.as_deref() == Some(s.id.as_str()), editable(&s.id), act); + let lib_id = lib.map(|l| l.manifest.id.clone()); + for (f, styles) in by_folder(list.iter().copied()) { + let fold = match &lib_id { + Some(id) => format!("{id}/{f}"), + None => f.to_string(), + }; + if !f.is_empty() { + // Searching shows every match, folders open. + let closed = q.is_empty() && app.ui.style_folds.contains(&fold); + let (r, resp) = ui.allocate_exact_size(vec2(W, ROW_H), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); + } + let chevron = Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0)); + icons::paint(ui.painter(), chevron, if closed { Icon::ChevronRight } else { Icon::Chevron }, t.menu_dim); + let label = f.split('/').map(str::trim).collect::>().join(" / "); + crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 22.0, r.center().y), &label, regular(11.0), t.menu_dim, W - 26.0); + if resp.clicked() && q.is_empty() { + out.act = Some(Act::ToggleFolder(fold.clone())); + } + if closed { + continue; + } + } + let keyed: Vec<(&Style, Key)> = styles.into_iter().map(|s| (s, Key { library: lib_id.clone(), style: s.id.clone() })).collect(); + // Library styles are read-only here. + let editable = |k: &Key| k.library.is_none(); + if grid { + ui.add_space(4.0); + style_grid(ui, &keyed, applied.as_ref(), editable, out); + } else { + for (s, key) in keyed { + let on = applied.as_ref() == Some(&key); + let can_edit = editable(&key); + style_row(ui, s, key, slot, on, can_edit, out); + } } } } @@ -398,7 +617,7 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, act: &mut let all = lib.map_or(&doc.variables, |l| &l.doc.variables); for v in vars { if crate::variable_ui::variable_row(ui, all, v, W) { - *act = Some(Act::Bind(crate::variable_ui::Pick { library: lib.map(|l| l.manifest.id.clone()), id: v.id.clone() })); + out.act = Some(Act::Bind(crate::variable_ui::Pick { library: lib.map(|l| l.manifest.id.clone()), id: v.id.clone() })); } } } @@ -430,20 +649,93 @@ fn grid_label(g: &figcraft_doc::LayoutGrid) -> String { } } -/// Editing one style: name, description, its values (paints open the colour picker), update from -/// the selection, delete. -fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, act: &mut Option) { +/// A text style's typography in the editor: a sample, then font, style, size, line height and +/// letter spacing; a change redefines the style (every linked char follows). +fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(W, 40.0), Sense::hover()); + ui.painter().rect_filled(r, CornerRadius::same(5), Color32::from_white_alpha(14)); + text_sample(ui, def, "Ag", 28.0, pos2(r.min.x + 10.0, r.center().y), 60.0, r.height() - 4.0, &mut out.budget); + let summary = format!("{} {}\n{}", def.font_family, def.font_style, text_meta(def)); + let g = ui.painter().layout(summary, regular(10.5), t.menu_dim, W - 80.0); + ui.painter().galley(pos2(r.min.x + 72.0, r.center().y - g.size().y / 2.0), g, t.menu_dim); + ui.add_space(6.0); + let set = |out: &mut Frame, f: &dyn Fn(&mut TextStyle)| { + let mut d = def.clone(); + f(&mut d); + if d != *def { + out.act = Some(Act::SetText(id.to_string(), d)); + } + }; + let half = (W - 8.0) / 2.0; + // The dropdowns' menus open outside the dark scope (they're regular popups). + let (family_resp, style_resp) = crate::theme::on_dark(ui, |ui| { + ui.spacing_mut().item_spacing = vec2(8.0, 6.0); + let family_resp = widgets::elided_dropdown(ui, &def.font_family, W).on_hover_text("Font"); + let mut style_resp = None; + ui.horizontal(|ui| { + style_resp = Some(widgets::elided_dropdown(ui, &def.font_style, half).on_hover_text("Font style")); + if let Some(widgets::FieldEdit::Set(v) | widgets::FieldEdit::Scrub(v, true)) = + widgets::number_field(ui, Id::new(("ts_size", id)), widgets::Lead::Text("Size"), Some(def.font_size), "", half, 1.0) + { + set(out, &|d| d.font_size = v.clamp(1.0, 5000.0)); + } + }); + ui.horizontal(|ui| { + if let Some(v) = widgets::text_field(ui, Id::new(("ts_lh", id)), &def.line_height.label(), half, "Line height") + && let Some(lh) = crate::typography::parse_line_height(&v, Some(def.line_height)).and_then(|v| serde_json::from_value(v).ok()) + { + set(out, &|d| d.line_height = lh); + } + if let Some(v) = widgets::text_field(ui, Id::new(("ts_ls", id)), &def.letter_spacing.label(), half, "Letter spacing") + && let Some(ls) = crate::typography::parse_letter_spacing(&v, Some(def.letter_spacing)).and_then(|v| serde_json::from_value(v).ok()) + { + set(out, &|d| d.letter_spacing = ls); + } + }); + (family_resp, style_resp) + }); + egui::Popup::menu(&family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()) { + set(out, &|d| { + d.font_family = f.clone(); + let styles = figcraft_text::family_styles(&f); + if !styles.is_empty() && !styles.contains(&d.font_style) { + d.set_font_style(styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first()).cloned().unwrap_or_default()); + } + }); + ui.close(); + } + }); + if let Some(sr) = style_resp { + egui::Popup::menu(&sr).width(200.0).show(|ui| { + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for s in figcraft_text::family_styles(&def.font_family) { + let mark = if def.font_style == s { "✓ " } else { " " }; + if crate::right::menu_item(ui, &format!("{mark}{s}"), "") { + set(out, &|d| d.set_font_style(s.clone())); + ui.close(); + } + } + }); + }); + } +} + +/// Editing one style: name, description, its values (paints open the colour picker; text styles +/// have their typography), update from the selection, delete. +fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) { let t = Tokens::get(ui.ctx()); let slot = p.slot; let has_selection = !p.ids.is_empty(); let Some(e) = p.edit.as_mut() else { return }; let Some(style) = doc.style(&e.id).cloned() else { - *act = Some(Act::Back); + out.act = Some(Act::Back); return; }; let (hr, _) = ui.allocate_exact_size(vec2(W, 24.0), Sense::hover()); if menu_icon(ui, Rect::from_min_size(hr.min, vec2(24.0, 24.0)), Icon::ChevronLeft, "Back to styles", false).clicked() { - *act = Some(Act::Back); + out.act = Some(Act::Back); } ui.painter().text(pos2(hr.min.x + 30.0, hr.center().y), Align2::LEFT_CENTER, "Edit style", semibold(11.0), t.menu_text); ui.add_space(6.0); @@ -471,7 +763,7 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, act: &mut Option) { for (i, paint) in paints.iter().enumerate().rev() { let label = format!("{} {:.0}%", paint.label(), paint.opacity * 100.0); if property_row(ui, &label, Some(paint), slot) { - *act = Some(Act::EditPaint(style.id.clone(), i)); + out.act = Some(Act::EditPaint(style.id.clone(), i)); } } } @@ -485,16 +777,16 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, act: &mut Option) { property_row(ui, &grid_label(g), None, slot); } } - StyleKind::Text { .. } => {} + StyleKind::Text { style: def } => text_properties(ui, &style.id, def, out), } }); ui.add_space(8.0); ui.horizontal(|ui| { if has_selection && menu_button(ui, "Update from selection").on_hover_text("Take the values from the first selected layer").clicked() { - *act = Some(Act::UpdateFromSelection(style.id.clone())); + out.act = Some(Act::UpdateFromSelection(style.id.clone())); } if menu_button(ui, "Delete").on_hover_text("Delete the style (layers keep their values)").clicked() { - *act = Some(Act::Delete(style.id.clone())); + out.act = Some(Act::Delete(style.id.clone())); } }); } @@ -518,7 +810,7 @@ fn commit_edit(app: &mut FigApp, doc: &Document, e: &Editing) { /// The picker popover (call once per frame). pub fn show(app: &mut FigApp, ctx: &egui::Context) { let Some(mut p) = app.ui.style_picker.take() else { return }; - let Ok(doc) = app.session.ds().map(|d| d.doc.clone()) else { return }; + let Ok(doc) = app.session.ds().map(|d| d.text_preview.clone().unwrap_or_else(|| d.doc.clone())) else { return }; // A new selection closes the picker (it acts on the layers it was opened for). if app.session.selection() != p.ids { if let Some(e) = &p.edit { @@ -534,14 +826,14 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { x -= 260.0; } let y = p.y.clamp(screen.min.y + 48.0, (screen.max.y - 460.0).max(screen.min.y + 48.0)); - let mut act = None; + let mut out = Frame { act: None, hover: None, budget: 4 }; let area = egui::Area::new(Id::new("style_picker_area")).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ctx, |ui| { egui::Frame::NONE.fill(t.menu).corner_radius(CornerRadius::same(10)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { ui.set_width(W); if p.edit.is_some() { - editor(ui, &doc, &mut p, &mut act); + editor(ui, &doc, &mut p, &mut out); } else { - listing(app, ui, &doc, &mut p, &mut act); + listing(app, ui, &doc, &mut p, &mut out); } }); }); @@ -552,22 +844,30 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { Stroke::new(1.0, Color32::from_black_alpha(40)), egui::StrokeKind::Inside, ); - // Clicks in the colour picker editing this picker's style keep it open. + // Clicks in the colour picker editing this picker's style, or in a menu the editor opened, + // keep it open. let picker_rect = app.ui.picker.as_ref().filter(|t| matches!(t, PickerTarget::Style { .. })).and(ctx.memory(|m| m.area_rect(Id::new("color_picker")))); + let in_popup = egui::Popup::is_any_open(ctx); let outside = ctx.input(|i| { i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|pos| !rect.contains(pos) && !picker_rect.is_some_and(|r| r.contains(pos))) - }); - let mut close = outside || ctx.input(|i| i.key_pressed(egui::Key::Escape)); + }) && !in_popup; + let mut close = outside || (ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !in_popup); let ids = p.ids.clone(); let slot = p.slot; - match act { - Some(Act::Apply(style)) => { - let mut q = json!({"ids": ids, "style": style}); - if slot.paints() { - q["target"] = json!(slot.key()); - } - app.run("style.apply", q); + // The canvas shows the text style under the pointer on the selected text. + if slot == StyleSlot::Text + && !close + && out.act.is_none() + && let Some(key) = &out.hover + { + let (cmd, params) = key.apply(&ids, slot); + app.preview_text(ctx, cmd, params); + } + match out.act { + Some(Act::Apply(key)) => { + let (cmd, params) = key.apply(&ids, slot); + app.run(cmd, params); close = true; } Some(Act::Bind(pick)) => { @@ -597,6 +897,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } app.ui.picker = Some(PickerTarget::Style { id, index }); } + Some(Act::SetText(id, def)) => { + app.run("style.update", json!({"style": id, "value": def})); + } Some(Act::UpdateFromSelection(id)) => { app.run("style.update", json!({"style": id, "from": ids.first()})); } diff --git a/crates/ui-egui/src/style_picker_tests.rs b/crates/ui-egui/src/style_picker_tests.rs index 002ad9c6..e4736a54 100644 --- a/crates/ui-egui/src/style_picker_tests.rs +++ b/crates/ui-egui/src/style_picker_tests.rs @@ -1,7 +1,7 @@ //! The style picker: name folders, new style names, and the popover drawn headless (list, grid, //! editor), committing a typed name when it closes. -use egui::{Pos2, RawInput, Rect, vec2}; +use egui::{Id, Pos2, RawInput, Rect, vec2}; use figcraft_engine::Session; use serde_json::json; @@ -20,14 +20,19 @@ fn names_split_into_folders() { } assert_eq!(next_name(&d, "Color"), "Color 3"); assert!(StyleSlot::Fill.holds(&StyleKind::Paint { paints: vec![] }) && !StyleSlot::Effect.holds(&StyleKind::Paint { paints: vec![] })); - for s in [StyleSlot::Fill, StyleSlot::Stroke, StyleSlot::Effect, StyleSlot::Grid] { + for s in [StyleSlot::Fill, StyleSlot::Stroke, StyleSlot::Effect, StyleSlot::Grid, StyleSlot::Text] { assert_eq!(StyleSlot::parse(s.key()), Some(s)); } + assert!(StyleSlot::Text.holds(&StyleKind::Text { style: Default::default() }) && StyleSlot::Text.has_grid()); } fn frame(ctx: &egui::Context, app: &mut FigApp, t: &mut f64) { + frame_with(ctx, app, t, vec![]); +} + +fn frame_with(ctx: &egui::Context, app: &mut FigApp, t: &mut f64, events: Vec) { *t += 1.0 / 60.0; - let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(*t), ..Default::default() }; + let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(*t), events, ..Default::default() }; let mut out = ctx.run_ui(input, |ui| app.ui(ui)); out.textures_delta.clear(); } @@ -44,7 +49,7 @@ fn picker_draws_every_view_and_commits_a_typed_name_on_close() { let mut app = FigApp::new(s, Box::new(NoServices)); let ctx = egui::Context::default(); let mut t = 0.0; - for slot in [StyleSlot::Fill, StyleSlot::Stroke, StyleSlot::Effect, StyleSlot::Grid] { + for slot in [StyleSlot::Fill, StyleSlot::Stroke, StyleSlot::Effect, StyleSlot::Grid, StyleSlot::Text] { open_for_selection(&mut app, slot, None); for grid in [false, true] { app.ui.style_grid = grid; @@ -68,3 +73,87 @@ fn picker_draws_every_view_and_commits_a_typed_name_on_close() { let doc = app.session.doc().unwrap(); assert!(doc.style("Brand/Gold").is_some(), "{:?}", doc.styles.iter().map(|s| &s.name).collect::>()); } + +/// A session with a text layer and text styles "Type/Heading" and "Type/Body" (the layer uses Body). +fn text_session() -> (Session, figcraft_doc::NodeId) { + let mut s = Session::new(); + let t = figcraft_engine::params::id_of(&s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0})).unwrap()["id"]).unwrap(); + s.execute( + "style.create", + &json!({"type": "text", "name": "Type/Heading", "value": {"fontFamily": "Inter", "fontStyle": "Bold", "fontSize": 32}}), + ) + .unwrap(); + s.execute("style.create", &json!({"type": "text", "name": "Type/Body", "value": {"fontFamily": "Inter", "fontSize": 14}})).unwrap(); + s.execute("style.apply", &json!({"ids": [t], "style": "Type/Body"})).unwrap(); + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + (s, t) +} + +#[test] +fn text_styles_preview_on_hover_and_apply_on_click() { + let (s, t) = text_session(); + let mut app = FigApp::new(s, Box::new(NoServices)); + let ctx = egui::Context::default(); + let mut time = 0.0; + open_for_selection(&mut app, StyleSlot::Text, None); + for _ in 0..3 { + frame(&ctx, &mut app, &mut time); + } + let area = ctx.memory(|m| m.area_rect(Id::new("style_picker_area"))).expect("the picker shows"); + let size = |app: &FigApp| app.session.doc().unwrap().node(t).unwrap().kind.text().unwrap().style.font_size; + let undo = app.session.ds().unwrap().history.undo.len(); + // Down the popover until a style row previews on the canvas: the heading (listed first). + let mut over = None; + for y in (area.min.y as i32..area.max.y as i32).step_by(4) { + let p = Pos2::new(area.center().x - 40.0, y as f32); + frame_with(&ctx, &mut app, &mut time, vec![egui::Event::PointerMoved(p)]); + frame(&ctx, &mut app, &mut time); + if figcraft_engine::textpreview::active(&app.session) { + over = Some(p); + break; + } + } + let p = over.expect("a hovered text style previews"); + assert_eq!(size(&app), 32.0, "the canvas shows the hovered style"); + assert_eq!(app.session.ds().unwrap().history.undo.len(), undo, "a preview is no edit"); + // Away from the popover the preview ends. + frame_with(&ctx, &mut app, &mut time, vec![egui::Event::PointerMoved(Pos2::new(600.0, 600.0))]); + frame(&ctx, &mut app, &mut time); + assert!(!figcraft_engine::textpreview::active(&app.session)); + assert_eq!(size(&app), 14.0); + // A click applies it: one undo step, and the picker closes. + let press = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame_with(&ctx, &mut app, &mut time, vec![egui::Event::PointerMoved(p)]); + frame_with(&ctx, &mut app, &mut time, vec![press(true)]); + frame_with(&ctx, &mut app, &mut time, vec![press(false)]); + frame(&ctx, &mut app, &mut time); + assert_eq!(size(&app), 32.0); + assert_eq!(app.session.ds().unwrap().history.undo.len(), undo + 1); + assert!(app.ui.style_picker.is_none()); + let doc = app.session.doc().unwrap(); + assert_eq!(doc.node(t).unwrap().styles.text.as_deref(), doc.style("Type/Heading").map(|s| s.id.as_str())); +} + +#[test] +fn chars_linked_to_several_styles_show_mixed() { + let (mut s, t) = text_session(); + s.execute("style.apply", &json!({"ids": [t], "style": "Type/Heading", "start": 0, "end": 2})).unwrap(); + let app = FigApp::new(s, Box::new(NoServices)); + let doc = app.session.doc().unwrap().clone(); + assert_eq!(text_link(&app, &doc, &[t]), None, "mixed"); + let mut app = app; + // Editing the first two chars: they link to one style. + figcraft_engine::textedit::begin(&mut app.session, t, Some(0), false).unwrap(); + figcraft_engine::textedit::set_caret(&mut app.session, 2, true).unwrap(); + let doc = app.session.doc().unwrap().clone(); + assert_eq!(text_link(&app, &doc, &[t]), Some(doc.style("Type/Heading").map(|s| s.id.clone()))); + // The panel draws the chip ("Mixed" when the edit spans both). + figcraft_engine::textedit::set_caret(&mut app.session, 4, true).unwrap(); + let ctx = egui::Context::default(); + let mut time = 0.0; + for _ in 0..3 { + frame(&ctx, &mut app, &mut time); + } + let doc = app.session.doc().unwrap().clone(); + assert_eq!(text_link(&app, &doc, &[t]), None); +} diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index b1105912..dd076609 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -62,9 +62,19 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { Icon::Logo, crate::theme::ACCENT, ); - let label = if d.is_dirty() { format!("{name} •") } else { name }; - let galley = ui.painter().layout(label, regular(12.0), t.text, w - 60.0); - ui.painter().galley(pos2(r.min.x + 32.0, r.center().y - galley.size().y / 2.0), galley, t.text); + // The unsaved-changes dot follows the name on screen, whichever way the name reads. + let dirty = d.is_dirty(); + let shown = crate::bidi::paint_name( + ui.painter(), + pos2(r.min.x + 32.0, r.center().y), + &name, + regular(12.0), + t.text, + w - if dirty { 72.0 } else { 60.0 }, + ); + if dirty { + ui.painter().text(pos2(shown.max.x + 4.0, r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text); + } let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0)); if resp.hovered() || active { let cresp = ui.interact(cr, ui.id().with(("tabclose", d.uid)), Sense::click()); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b497db48..0b1b7696 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -122,13 +122,38 @@ impl Tokens { } pub fn get(ctx: &Context) -> Tokens { - ctx.data(|d| d.get_temp::(egui::Id::new("figcraft_tokens"))).unwrap_or_else(Tokens::light) + ctx.data(|d| d.get_temp::(tokens_id())).unwrap_or_else(Tokens::light) } } +fn tokens_id() -> egui::Id { + egui::Id::new("figcraft_tokens") +} + /// Install tokens and egui visuals. pub fn apply(ctx: &Context, t: Tokens) { - ctx.data_mut(|d| d.insert_temp(egui::Id::new("figcraft_tokens"), t)); + ctx.data_mut(|d| d.insert_temp(tokens_id(), t)); + ctx.set_visuals(visuals(t)); + ctx.global_style_mut(|s| { + s.spacing.item_spacing = egui::vec2(8.0, 6.0); + s.spacing.button_padding = egui::vec2(8.0, 4.0); + s.spacing.interact_size = egui::vec2(24.0, 24.0); + s.spacing.menu_margin = egui::Margin::same(8); + s.spacing.window_margin = egui::Margin::same(12); + s.text_styles = [ + (TextStyle::Small, FontId::new(10.0, FontFamily::Proportional)), + (TextStyle::Body, FontId::new(11.0, FontFamily::Proportional)), + (TextStyle::Button, FontId::new(11.0, FontFamily::Proportional)), + (TextStyle::Heading, FontId::new(13.0, FontFamily::Name("semibold".into()))), + (TextStyle::Monospace, FontId::new(11.0, FontFamily::Monospace)), + ] + .into(); + s.interaction.tooltip_delay = 0.4; + }); +} + +/// egui visuals for tokens `t`. +fn visuals(t: Tokens) -> Visuals { let mut v = if t.dark { Visuals::dark() } else { Visuals::light() }; v.panel_fill = t.panel; v.window_fill = t.panel; @@ -159,27 +184,29 @@ pub fn apply(ctx: &Context, t: Tokens) { v.popup_shadow = egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: t.shadow }; v.window_shadow = egui::Shadow { offset: [0, 8], blur: 24, spread: 0, color: t.shadow }; v.text_cursor.stroke = Stroke::new(1.0, t.accent); - ctx.set_visuals(v); - ctx.global_style_mut(|s| { - s.spacing.item_spacing = egui::vec2(8.0, 6.0); - s.spacing.button_padding = egui::vec2(8.0, 4.0); - s.spacing.interact_size = egui::vec2(24.0, 24.0); - s.spacing.menu_margin = egui::Margin::same(8); - s.spacing.window_margin = egui::Margin::same(12); - s.text_styles = [ - (TextStyle::Small, FontId::new(10.0, FontFamily::Proportional)), - (TextStyle::Body, FontId::new(11.0, FontFamily::Proportional)), - (TextStyle::Button, FontId::new(11.0, FontFamily::Proportional)), - (TextStyle::Heading, FontId::new(13.0, FontFamily::Name("semibold".into()))), - (TextStyle::Monospace, FontId::new(11.0, FontFamily::Monospace)), - ] - .into(); - s.interaction.tooltip_delay = 0.4; - }); + v } -/// UI fonts: Inter (from the craft-fonts build input) when present, else egui's defaults. -pub fn install_fonts(ctx: &Context) { +/// Draw `f` with the dark tokens and visuals: controls on the dark popovers (the style browser's +/// style editor), whichever theme the app shows. +pub fn on_dark(ui: &mut egui::Ui, f: impl FnOnce(&mut egui::Ui) -> R) -> R { + let ctx = ui.ctx().clone(); + let saved = Tokens::get(&ctx); + let dark = Tokens::dark(); + ctx.data_mut(|d| d.insert_temp(tokens_id(), dark)); + let r = ui + .scope(|ui| { + *ui.visuals_mut() = visuals(dark); + f(ui) + }) + .inner; + ctx.data_mut(|d| d.insert_temp(tokens_id(), saved)); + r +} + +/// UI fonts: Inter (from the craft-fonts build input) when present, else egui's defaults; with +/// `system`, also system faces for scripts those lack (Hebrew, joined Arabic) so names in them show. +pub fn install_fonts(ctx: &Context, system: bool) { let mut fonts = FontDefinitions::default(); let find = |family: &str, style: &str| figcraft_text::CRAFT_FONTS.iter().find(|f| f.family == family && f.style == style).map(|f| f.bytes); let regular = find("Inter", "Regular"); @@ -214,6 +241,25 @@ pub fn install_fonts(ctx: &Context) { } } } + if system { + // What the built-in faces above already draw. + let embedded = |c: char| { + ["Inter", "BIZ UDPGothic", "Noto Sans Arabic"] + .into_iter() + .filter(|f| figcraft_text::CRAFT_FONTS.iter().any(|cf| cf.family == *f)) + .filter_map(|f| figcraft_text::find_face(f, "Regular")) + .any(|x| x.has_char(c)) + }; + let extra = crate::bidi::script_fallbacks(&embedded); + for (key, data) in extra { + fonts.font_data.insert(key.clone(), Arc::new(data)); + for fam in [FontFamily::Proportional, FontFamily::Name("semibold".into())] { + if let Some(list) = fonts.families.get_mut(&fam) { + list.push(key.clone()); + } + } + } + } ctx.set_fonts(fonts); } diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index d5eaf440..0261ce2a 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -5,11 +5,9 @@ //! edits apply to the range (`text.style`, `text.setVariations`); otherwise they apply to whole //! layers. Differing values show "Mixed". -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; -use figcraft_color::{Color, Paint}; use figcraft_doc::*; use serde_json::{Value, json}; @@ -49,7 +47,7 @@ fn uniform(styles: &[TextStyle], f: impl Fn(&TextStyle) -> /// The character styles the panel shows: the edited range's spans, else every span of every /// selected text layer. -fn target_styles(doc: &Document, ids: &[NodeId], range: Option<(NodeId, usize, usize)>) -> Vec { +pub(crate) fn target_styles(doc: &Document, ids: &[NodeId], range: Option<(NodeId, usize, usize)>) -> Vec { let mut out: Vec = Vec::new(); let mut push = |s: &TextStyle| { if !out.contains(s) { @@ -77,7 +75,6 @@ fn target_styles(doc: &Document, ids: &[NodeId], range: Option<(NodeId, usize, u } pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { - let t = Tokens::get(ui.ctx()); let ids: Vec = sel.iter().copied().filter(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)))).collect(); if ids.is_empty() { return; @@ -86,25 +83,57 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) let range = figcraft_engine::textedit::selected_range(&app.session); let editing = range.is_some(); let styles = target_styles(doc, &ids, range); - let apply_style = |app: &mut FigApp, key: &str, v: Value| { - if let Some((id, a, b)) = range - && a != b - { - app.run("text.style", json!({"id": id, "start": a, "end": b, key: v})); - } else { - app.set_props(&ids, json!({ key: v })); - } - }; + let apply_style = |app: &mut FigApp, key: &str, v: Value| commit_text(app, &ids, range, json!({ key: v })); let hdr = widgets::section_header(ui, "Typography", &[(Icon::Variable, "Apply variable to text"), (Icon::Styles, "Text styles")], false); if hdr == Some(0) { crate::variable_ui::open_at_pointer(ui, &ids, "characters", VariableType::String); } + if hdr == Some(1) { + crate::style_picker::open(app, ui, &ids, crate::style_picker::StyleSlot::Text); + } crate::variable_ui::bound_row(app, ui, &ids, "characters", VariableType::String, Lead::Icon(Icon::Text), f32::INFINITY); + // A text style: its chip stands for the font rows (they show again when the chars differ). + crate::style_picker::chip(app, ui, doc, &ids, crate::style_picker::StyleSlot::Text); + if !matches!(crate::style_picker::text_link(app, doc, &ids), Some(Some(_))) { + font_rows(app, ui, &ids, &styles, editing, &apply_style); + } + paragraph_rows(app, ui, doc, &ids, &styles, &apply_style); +} + +/// Set character `props` on the selected chars of the text being edited, else on whole layers +/// (through `node.set`, which records overrides inside instances). +fn commit_text(app: &mut FigApp, ids: &[NodeId], range: Option<(NodeId, usize, usize)>, props: Value) { + match range { + Some((_, a, b)) if a != b => { + app.run("text.style", text_style_params(ids, range, props)); + } + _ => app.set_props(ids, props), + } +} + +/// `text.style` params for `props` on the selected chars of the text being edited, else on the +/// whole of every layer in `ids` (previews; commits go through [`commit_text`]). +fn text_style_params(ids: &[NodeId], range: Option<(NodeId, usize, usize)>, mut props: Value) -> Value { + match range { + Some((id, a, b)) if a != b => { + props["id"] = json!(id); + props["start"] = json!(a); + props["end"] = json!(b); + } + _ => props["ids"] = json!(ids), + } + props +} + +/// The font rows: family, style and size, line height and letter spacing. +fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle], editing: bool, apply_style: &dyn Fn(&mut FigApp, &str, Value)) { + let t = Tokens::get(ui.ctx()); + let range = figcraft_engine::textedit::selected_range(&app.session); // Font family. - let family = uniform(&styles, |s| s.font_family.clone()); - let fstyle = uniform(&styles, |s| s.font_style.clone()); + let family = uniform(styles, |s| s.font_family.clone()); + let fstyle = uniform(styles, |s| s.font_style.clone()); row(ui, 32.0, |ui| { let w = ui.available_width(); let resp = widgets::elided_dropdown(ui, family.as_deref().unwrap_or(MIXED), w); @@ -131,22 +160,26 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { for s in list { let mark = if fstyle.as_deref() == Some(s.as_str()) { "✓ " } else { " " }; - if menu_item(ui, &format!("{mark}{s}"), "") { + let (clicked, hovered) = menu_item_hover(ui, &format!("{mark}{s}")); + if clicked { apply_style(app, "fontStyle", json!(s)); ui.close(); + } else if hovered { + // The canvas shows the style (a variable font's named instance too). + app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, json!({"fontStyle": s}))); } } }); } None => widgets::label(ui, "Pick one font family first", t.text_tertiary), }); - let size = uniform(&styles, |s| s.font_size); + let size = uniform(styles, |s| s.font_size); ui.spacing_mut().item_spacing.x = 0.0; // The whole layer's size can follow a variable; a selected range just takes a value. let e = if editing { widgets::number_field(ui, Id::new("ty_size"), Lead::None, size, "", half - 20.0, 1.0) } else { - crate::variable_ui::number_field(app, ui, &ids, "fontSize", Id::new("ty_size"), Lead::None, size, "", half - 20.0) + crate::variable_ui::number_field(app, ui, ids, "fontSize", Id::new("ty_size"), Lead::None, size, "", half - 20.0) }; match e { Some(FieldEdit::Set(v)) => apply_style(app, "fontSize", json!(v.clamp(1.0, 5000.0))), @@ -173,33 +206,44 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) // Line height + letter spacing. row(ui, 32.0, |ui| { - let lh = uniform(&styles, |s| s.line_height); + let lh = uniform(styles, |s| s.line_height); let lh_label = lh.map(|l| l.label()).unwrap_or_else(|| MIXED.into()); let field = SpacingField { field: "lineHeight", id: Id::new("ty_lh"), glyph: LeadGlyph::LineHeight, tip: "Line height" }; - if let Some(v) = spacing_field(app, ui, &ids, &field, &lh_label, half, editing) + if let Some(v) = spacing_field(app, ui, ids, &field, &lh_label, half, editing) && let Some(v) = parse_line_height(&v, lh) { apply_style(app, "lineHeight", v); } - let ls = uniform(&styles, |s| s.letter_spacing); + let ls = uniform(styles, |s| s.letter_spacing); let ls_label = ls.map(|l| l.label()).unwrap_or_else(|| MIXED.into()); let field = SpacingField { field: "letterSpacing", id: Id::new("ty_ls"), glyph: LeadGlyph::LetterSpacing, tip: "Letter spacing" }; - if let Some(v) = spacing_field(app, ui, &ids, &field, &ls_label, half, editing) + if let Some(v) = spacing_field(app, ui, ids, &field, &ls_label, half, editing) && let Some(v) = parse_letter_spacing(&v, ls) { apply_style(app, "letterSpacing", v); } }); +} +/// Paragraph spacing, resizing, alignment and the Type settings popover. +fn paragraph_rows( + app: &mut FigApp, + ui: &mut Ui, + doc: &Document, + ids: &[NodeId], + styles: &[TextStyle], + apply_style: &dyn Fn(&mut FigApp, &str, Value), +) { + let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0; // Paragraph spacing + resizing. - let ps = common(doc, &ids, |n| n.kind.text().map(|t| t.paragraph_spacing.to_bits())).flatten().map(f64::from_bits); - let resize = common(doc, &ids, |n| n.kind.text().map(|t| t.auto_resize)).flatten(); + let ps = common(doc, ids, |n| n.kind.text().map(|t| t.paragraph_spacing.to_bits())).flatten().map(f64::from_bits); + let resize = common(doc, ids, |n| n.kind.text().map(|t| t.auto_resize)).flatten(); row(ui, 32.0, |ui| { let ps_label = ps.map(trim_num).unwrap_or_else(|| MIXED.into()); if let Some(v) = lead_field(ui, Id::new("ty_ps"), LeadGlyph::ParagraphSpacing, &ps_label, half, "Paragraph spacing") && let Some(v) = widgets::eval(&v, ps) { - app.set_props(&ids, json!({"paragraphSpacing": v.clamp(0.0, 10_000.0)})); + app.set_props(ids, json!({"paragraphSpacing": v.clamp(0.0, 10_000.0)})); } let items = [(Icon::Hug, "Auto width"), (Icon::LayoutV, "Auto height"), (Icon::Fixed, "Fixed size"), (Icon::Dots, "Truncate text")]; let active = resize.map(|r| match r { @@ -210,12 +254,12 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) }); if let Some(i) = widgets::segmented(ui, &items, active, (half / 4.0).floor()) { let v = ["widthAndHeight", "height", "none", "truncate"].get(i).copied().unwrap_or("none"); - app.set_props(&ids, json!({"textAutoResize": v})); + app.set_props(ids, json!({"textAutoResize": v})); } }); // Alignment + Type settings. - let align = common(doc, &ids, |n| n.kind.text().map(|t| (t.align_h, t.align_v))).flatten(); + let align = common(doc, ids, |n| n.kind.text().map(|t| (t.align_h, t.align_v))).flatten(); row(ui, 32.0, |ui| { let items = [ (Icon::TextAlignLeft, "Align left"), @@ -231,7 +275,7 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) }); if let Some(i) = widgets::segmented(ui, &items, active, 24.0) { let v = ["left", "center", "right", "justified"].get(i).copied().unwrap_or("left"); - app.set_props(&ids, json!({"textAlign": v})); + app.set_props(ids, json!({"textAlign": v})); } let items = [(Icon::TextTop, "Align top"), (Icon::TextMiddle, "Align middle"), (Icon::TextBottom, "Align bottom")]; let active = align.map(|(_, v)| match v { @@ -241,7 +285,7 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) }); if let Some(i) = widgets::segmented(ui, &items, active, 24.0) { let v = ["top", "middle", "bottom"].get(i).copied().unwrap_or("top"); - app.set_props(&ids, json!({"textAlignVertical": v})); + app.set_props(ids, json!({"textAlignVertical": v})); } let open_id = Id::new("ty_settings_open"); let open = egui::Popup::is_id_open(ui.ctx(), open_id); @@ -250,10 +294,16 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) .id(open_id) .width(264.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .show(|ui| type_settings(app, ui, doc, &ids, &styles, editing, &apply_style)); + .show(|ui| type_settings(app, ui, doc, ids, styles, apply_style)); }); } +/// A menu item that also reports the pointer being over it: (clicked, hovered). +fn menu_item_hover(ui: &mut Ui, label: &str) -> (bool, bool) { + let r = crate::right::menu_row(ui, label, "", |_, _, _| {}); + (r.clicked(), r.hovered()) +} + /// Run `cmd` as a live preview while scrubbing; the gesture becomes one undo step when `done`. /// (While text is edited, run commands directly: the editing session already groups undo.) pub(crate) fn live(app: &mut FigApp, label: &str, cmd: &str, params: Value, done: bool) { @@ -281,7 +331,6 @@ fn type_settings( doc: &Document, ids: &[NodeId], styles: &[TextStyle], - editing: bool, apply_style: &dyn Fn(&mut FigApp, &str, Value), ) { let t = Tokens::get(ui.ctx()); @@ -355,19 +404,23 @@ fn type_settings( } _ => {} } - let max = common(doc, ids, |n| n.kind.text().map(|t| t.max_lines)).flatten(); - ui.add_space(4.0); - ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new("Max lines").font(regular(11.0)).color(t.text))); - let shown = max.map(f64::from).filter(|m| *m > 0.0); - if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = - widgets::number_field(ui, Id::new("ty_maxlines"), Lead::None, shown, "", w - 118.0, 1.0) - { - app.run("text.maxLines", json!({"ids": ids, "lines": v.round().clamp(0.0, 10_000.0)})); - } - }); + // Max lines only matter while truncating; no limit shows "Auto". + if resize == Some(TextAutoResize::Truncate) { + let max = common(doc, ids, |n| n.kind.text().map(|t| t.max_lines)).flatten(); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new("Max lines").font(regular(11.0)).color(t.text))); + let shown = max.map(f64::from).filter(|m| *m > 0.0); + let empty = if max.is_some() { "Auto" } else { MIXED }; + if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = + widgets::number_field_or(ui, Id::new("ty_maxlines"), shown, empty, w - 118.0, 1.0) + { + app.run("text.maxLines", json!({"ids": ids, "lines": v.round().clamp(0.0, 10_000.0)})); + } + }); + } } else if let (2, Some(face)) = (tab, &vf) { - variable_axes(app, ui, ids, styles, face, editing); + variable_axes(app, ui, ids, styles, face); } else { // Which features the font has (first style shown). let face = uniform(styles, |s| (s.font_family.clone(), s.font_style.clone())).and_then(|(f, s)| figcraft_text::find_face(&f, &s)); @@ -389,11 +442,15 @@ fn type_settings( } } -/// Type settings ▸ Variable: a slider and a field per visible axis of the variable font (values -/// the style name implies show until changed; "Mixed" when the shown styles differ). -fn variable_axes(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle], face: &figcraft_text::Face, editing: bool) { +/// Type settings ▸ Variable: the font's named instances, then a slider and a field per visible +/// axis (values the style name implies show until changed; "Mixed" when the shown styles differ). +/// The canvas previews a hovered instance and a dragged slider or field live; letting go commits +/// one undo step, Escape cancels. +fn variable_axes(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle], face: &figcraft_text::Face) { let t = Tokens::get(ui.ctx()); + let range = figcraft_engine::textedit::selected_range(&app.session); widgets::label(ui, &face.family, t.text_secondary); + named_instances(app, ui, ids, range, styles, face); // Each style's axis values; a style drawn by another face (e.g. a separate italic file) is mixed. let per_style: Vec>> = styles .iter() @@ -403,19 +460,29 @@ fn variable_axes(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextSt let field_w = 52.0; let label_w = 84.0; let slider_w = (w - label_w - field_w - 12.0).max(40.0); + let cancel_id = Id::new("ty_axis_cancelled"); + // A cancelled gesture ends when the pointer is let go. + if !ui.input(|i| i.pointer.any_down()) { + ui.data_mut(|d| d.remove::(cancel_id)); + } for (i, axis) in face.axes.iter().enumerate().filter(|(_, a)| !a.hidden) { let first = per_style.first().cloned().flatten().and_then(|v| v.get(i).copied()); let value = first.filter(|v0| per_style.iter().all(|vs| vs.as_ref().and_then(|v| v.get(i)).is_some_and(|v| (v - v0).abs() < 1e-9))); - let value = value.map(|v| round_axis(axis, v)); - // (value, gesture ended, live preview while dragging) - let mut set: Option<(Value, bool, bool)> = None; + // While a drag previews, the controls show the dragged value (the panel shows the document). + let live_id = Id::new(("ty_axis_live", &axis.tag)); + if app.ui.text_preview.is_none() { + ui.data_mut(|d| d.remove::(live_id)); + } + let shown = ui.data(|d| d.get_temp::(live_id)).or(value).map(|v| round_axis(axis, v)); + // (value, gesture ended) + let mut set: Option<(Value, bool)> = None; ui.add_space(2.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; let (lr, _) = ui.allocate_exact_size(vec2(label_w, FIELD_H), Sense::hover()); let name = ui.painter().layout(axis.name.clone(), regular(11.0), t.text, label_w); let tip = format!( - "{} ({}): {} – {}, default {}. Double-click the slider to go back to the style's value.", + "{} ({}): {} – {}, default {}. Double-click the slider to go back to the style's value; Escape while dragging cancels.", axis.name, axis.tag, trim_num(axis.min), @@ -424,25 +491,42 @@ fn variable_axes(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextSt ); ui.interact(lr, Id::new(("ty_axis_label", &axis.tag)), Sense::hover()).on_hover_text(tip); ui.painter().galley(pos2(lr.min.x, lr.center().y - name.size().y / 2.0), name, t.text); - match axis_slider(ui, Id::new(("ty_axis_slider", &axis.tag)), axis, value, slider_w) { - Some((Some(v), done)) => set = Some((json!(round_axis(axis, v)), done, true)), - Some((None, _)) => set = Some((Value::Null, true, false)), + match axis_slider(ui, Id::new(("ty_axis_slider", &axis.tag)), axis, shown, slider_w) { + Some((Some(v), done)) => set = Some((json!(round_axis(axis, v)), done)), + Some((None, _)) => set = Some((Value::Null, true)), None => {} } let step = if axis.max - axis.min > 20.0 { 1.0 } else { 0.1 }; - match widgets::number_field(ui, Id::new(("ty_axis_field", &axis.tag)), Lead::None, value, "", field_w, step) { - Some(FieldEdit::Set(v)) => set = Some((json!(axis.clamp(round_axis(axis, v))), true, false)), - Some(FieldEdit::Scrub(v, done)) => set = Some((json!(axis.clamp(round_axis(axis, v))), done, true)), + match widgets::number_field(ui, Id::new(("ty_axis_field", &axis.tag)), Lead::None, shown, "", field_w, step) { + Some(FieldEdit::Set(v)) => set = Some((json!(axis.clamp(round_axis(axis, v))), true)), + Some(FieldEdit::Scrub(v, done)) => set = Some((json!(axis.clamp(round_axis(axis, v))), done)), None => {} } }); - if let Some((v, done, preview)) = set { - let params = json!({"ids": ids, "variations": {axis.tag.clone(): v}}); - if preview && !editing { - live(app, "Variable font axis", "text.setVariations", params, done); - } else { - app.run("text.setVariations", params); + let Some((v, done)) = set else { continue }; + // Escape cancels a previewing gesture (egui may end the drag on it too): the preview ends + // and the rest of the gesture is ignored until the pointer is let go. + let previewing = ui.data(|d| d.get_temp::(live_id)).is_some(); + if previewing && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + ui.data_mut(|d| { + d.insert_temp(cancel_id, true); + d.remove::(live_id); + }); + app.end_text_preview(ui.ctx()); + continue; + } + if ui.data(|d| d.get_temp::(cancel_id)).unwrap_or(false) { + continue; + } + let params = json!({"ids": ids, "variations": {axis.tag.clone(): v}}); + if done { + ui.data_mut(|d| d.remove::(live_id)); + app.run("text.setVariations", params); + } else { + if let Some(x) = v.as_f64() { + ui.data_mut(|d| d.insert_temp(live_id, x)); } + app.preview_text(ui.ctx(), "text.setVariations", params); } } if styles.iter().any(|s| !s.variations.is_empty()) { @@ -454,6 +538,52 @@ fn variable_axes(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextSt } } +/// The variable font's named instances as a menu: the one the text shows ("Custom" once an axis +/// moved), hovering one previews it on the canvas, clicking applies it (its custom axis values go). +fn named_instances( + app: &mut FigApp, + ui: &mut Ui, + ids: &[NodeId], + range: Option<(NodeId, usize, usize)>, + styles: &[TextStyle], + face: &figcraft_text::Face, +) { + if face.instances.is_empty() { + return; + } + let current = uniform(styles, |s| (s.variations.is_empty(), s.font_style.clone())); + let label = match ¤t { + Some((true, st)) if face.instance(st).is_some() => st.clone(), + Some(_) => "Custom".to_string(), + None => MIXED.to_string(), + }; + ui.add_space(4.0); + // An inline list (a menu would close the Type settings popover it opens from). + let open_id = Id::new("ty_instances_open"); + let mut open = ui.data(|d| d.get_temp::(open_id)).unwrap_or(false); + if widgets::elided_dropdown(ui, &label, ui.available_width()).on_hover_text("Named instance").clicked() { + open = !open; + } + if open { + ui.add_space(2.0); + egui::ScrollArea::vertical().id_salt("ty_instances").max_height(220.0).show(ui, |ui| { + for inst in &face.instances { + let mark = if label == inst.name { "✓ " } else { " " }; + let (clicked, hovered) = menu_item_hover(ui, &format!("{mark}{}", inst.name)); + let props = json!({"fontStyle": inst.name, "fontVariations": null}); + if clicked { + commit_text(app, ids, range, props); + open = false; + } else if hovered { + app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, props)); + } + } + }); + } + ui.data_mut(|d| d.insert_temp(open_id, open)); + ui.add_space(2.0); +} + /// Axis values snap to whole numbers on wide axes (weight, width…) and tenths on narrow ones. fn round_axis(axis: &figcraft_text::Axis, v: f64) -> f64 { if axis.max - axis.min > 20.0 { v.round() } else { (v * 10.0).round() / 10.0 } @@ -676,7 +806,7 @@ fn lead_field(ui: &mut Ui, id: Id, glyph: LeadGlyph, value: &str, width: f32, ti /// "Auto", "24", "150%" → the `lineHeight` value. A bare number keeps the current unit when it is /// a percentage, else means pixels. -fn parse_line_height(s: &str, cur: Option) -> Option { +pub(crate) fn parse_line_height(s: &str, cur: Option) -> Option { let s = s.trim(); if s.is_empty() || s.eq_ignore_ascii_case("auto") { return Some(json!("auto")); @@ -700,7 +830,7 @@ fn parse_line_height(s: &str, cur: Option) -> Option { /// "2%", "1.5px", "-0.5" → the `letterSpacing` value. A bare number keeps the current unit /// (percent by default). -fn parse_letter_spacing(s: &str, cur: Option) -> Option { +pub(crate) fn parse_letter_spacing(s: &str, cur: Option) -> Option { let s = s.trim(); if let Some(p) = s.strip_suffix('%') { return widgets::eval(p.trim(), None).filter(|v| v.is_finite()).map(|v| json!(format!("{}%", trim_num(v)))); @@ -739,7 +869,7 @@ enum PickRow { } /// The searchable family list. Returns the family picked. -fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> Option { +pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> Option { let t = Tokens::get(ui.ctx()); let q_id = Id::new("font_query"); let mut q: String = ui.data(|d| d.get_temp(q_id)).unwrap_or_default(); @@ -771,7 +901,6 @@ fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> Option< return None; } rows.extend(matches.into_iter().map(PickRow::Family)); - let previews = Previews::get(ui.ctx()); let mut budget = 4; let mut picked = None; egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show_rows(ui, ROW, rows.len(), |ui, range| { @@ -797,15 +926,16 @@ fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> Option< ); } let text_min = pos2(rect.min.x + 22.0, rect.min.y); - match previews.texture(ui.ctx(), f, &mut budget) { + match crate::font_preview::family(ui.ctx(), f, 13.0, &mut budget) { Some(tex) => { - let ppp = ui.ctx().pixels_per_point().max(0.5); - let size = tex.size_vec2() / ppp; - let avail = rect.max.x - text_min.x - 6.0; - let shown = vec2(size.x.min(avail), size.y.min(ROW)); - let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(shown.x / size.x.max(1.0), shown.y / size.y.max(1.0))); - let r = Rect::from_min_size(pos2(text_min.x, rect.center().y - shown.y / 2.0), shown); - ui.painter().image(tex.id(), r, uv, col); + crate::font_preview::paint( + ui.painter(), + &tex, + pos2(text_min.x, rect.center().y), + rect.max.x - text_min.x - 6.0, + ROW, + col, + ); } None => { ui.painter().text(pos2(text_min.x, rect.center().y), Align2::LEFT_CENTER, f, regular(12.0), col); @@ -818,72 +948,9 @@ fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> Option< } } }); - if budget == 0 { - ui.ctx().request_repaint(); - } picked } -/// Family-name previews drawn in their own font by FigCraft's text engine (rendered lazily, a -/// few per frame, cached for the session). `None` = no usable preview: the name shows plain. -#[derive(Clone, Default)] -struct Previews(Arc>>>); - -impl Previews { - fn get(ctx: &egui::Context) -> Previews { - ctx.data_mut(|d| d.get_temp_mut_or_default::(Id::new("font_previews")).clone()) - } - - fn texture(&self, ctx: &egui::Context, family: &str, budget: &mut usize) -> Option { - let mut map = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(t) = map.get(family) { - return t.clone(); - } - if *budget == 0 { - return None; - } - *budget -= 1; - let tex = render_preview(ctx, family); - if map.len() > 2000 { - map.clear(); - } - map.insert(family.to_string(), tex.clone()); - tex - } -} - -fn render_preview(ctx: &egui::Context, family: &str) -> Option { - // Only when the family resolves to itself and can draw its own name (not symbol fonts). - let styles = figcraft_text::family_styles(family); - let style = styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first())?.clone(); - let face = figcraft_text::find_face(family, &style)?; - if !face.family.eq_ignore_ascii_case(family) || !family.chars().all(|c| c == ' ' || face.has_char(c)) { - return None; - } - let mut doc = Document::new("preview"); - let page = doc.first_page()?; - let id = doc.alloc_id(); - let mut td = TextData::new(family); - td.style.font_family = family.to_string(); - td.style.font_style = style; - td.style.font_size = 13.0; - let mut n = Node::new(id, NodeKind::Text(td), "preview", 1.0, 1.0); - n.fills = vec![Paint::solid(Color::WHITE)]; - let (w, h) = figcraft_text::auto_size(&n)?; - n.width = w.min(400.0); - n.height = h.min(40.0); - doc.insert(n, page, None).ok()?; - let ppp = f64::from(ctx.pixels_per_point()).clamp(1.0, 3.0); - let pix = figcraft_render::render_node(&doc, id, ppp)?; - let (pw, ph) = (pix.width() as usize, pix.height() as usize); - let rgba = figcraft_render::to_rgba8(&pix); - if rgba.len() != pw * ph * 4 { - return None; - } - let img = egui::ColorImage::from_rgba_unmultiplied([pw, ph], &rgba); - Some(ctx.load_texture(format!("font-preview-{family}"), img, egui::TextureOptions::LINEAR)) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 72b36ce8..c621a3a8 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -48,6 +48,11 @@ pub fn number_field(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix: number_field_bindable(ui, id, lead, value, suffix, width, step, None).0 } +/// [`number_field`] that shows `empty` (dim) for no value instead of "Mixed" (e.g. "Auto"). +pub fn number_field_or(ui: &mut Ui, id: Id, value: Option, empty: &str, width: f32, step: f64) -> Option { + number_field_impl(ui, id, Lead::None, value, "", width, step, None, empty).0 +} + /// [`number_field`] that can bind a variable: `binding` None = not bindable, `Some(None)` = shows /// a variable button on hover, `Some(Some(name))` = bound (shows the name; click to change, the /// detach button unbinds). @@ -60,6 +65,20 @@ pub fn number_field_bindable( width: f32, step: f64, binding: Option>, +) -> (Option, Option) { + number_field_impl(ui, id, lead, value, suffix, width, step, binding, "Mixed") +} + +fn number_field_impl( + ui: &mut Ui, + id: Id, + lead: Lead, + value: Option, + suffix: &str, + width: f32, + step: f64, + binding: Option>, + empty: &str, ) -> (Option, Option) { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); @@ -171,7 +190,7 @@ pub fn number_field_bindable( None => { let s = match value { Some(v) => format!("{}{}", fmt_num(v), suffix), - None => "Mixed".into(), + None => empty.to_string(), }; let col = if value.is_some() { t.text } else { t.text_secondary }; ui.painter().text(pos2(edit_rect.min.x, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), col); @@ -590,13 +609,18 @@ pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egu if buf.is_none() && (focused || resp.clicked()) { buf = Some(value.to_string()); ui.memory_mut(|m| m.request_focus(te_id)); + // A rename starts with the whole name selected. + let mut state = egui::TextEdit::load_state(ui.ctx(), te_id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(value.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), te_id); } match buf { Some(mut text) => { ui.painter().rect_filled(rect.expand(2.0), CornerRadius::same(4), t.panel); ui.painter().rect_stroke(rect.expand(2.0), CornerRadius::same(4), Stroke::new(1.0, t.accent), StrokeKind::Inside); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect)); - let r = child.add(egui::TextEdit::singleline(&mut text).id(te_id).frame(egui::Frame::NONE).font(font).desired_width(rect.width())); + let r = crate::bidi::single_line(&mut child, te_id, &mut text, font, rect.width()); let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); let esc = ui.input(|i| i.key_pressed(egui::Key::Escape)); if esc { @@ -616,8 +640,7 @@ pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egu if resp.hovered() { ui.painter().rect_filled(rect.expand(2.0), CornerRadius::same(4), t.hover); } - let galley = ui.painter().layout(value.to_string(), font, t.text, rect.width()); - ui.painter().with_clip_rect(rect).galley(pos2(rect.min.x, rect.center().y - galley.size().y / 2.0), galley, t.text); + crate::bidi::paint_name(ui.painter(), rect.left_center(), value, font, t.text, rect.width()); None } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4c2ed92f..696144da 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -72,6 +72,8 @@ was resized to (`responsive`: `[w, h]`, else `null`), the size option (`scale`: `fit|fitWidth|fill|actual`, `deviceFrame`), the `zoom` it shows at, how far the view is panned (`pan`, `panRange`) and whether a scroll is still coasting (`flinging`) (`null` when nothing plays). `presentOptions` holds the options the next presentation starts with. +`textPreview` is true while a live text preview shows on the canvas (a hovered text style or named +instance, a dragged axis slider; see `text.preview` in [mcp.md](mcp.md)). ### `ui.pointer {kind, x, y | sx, sy, to?, steps?, shift?, alt?, cmd?, ctrl?}` @@ -95,6 +97,22 @@ edited, keys go to the text editor. Returns `{"command": id}` for shortcuts. Type into the text being edited (start editing with a double-click, `text.edit.begin {id}`, or the text tool). +### `ui.input {events}` + +Raw window input for the next frame, as if from the mouse and keyboard: it reaches the panels, +popovers and fields (`ui.pointer` and `ui.key` drive the canvas tools and shortcuts instead). +`events` is a list of `{move: [x, y]}`, `{down: [x, y], button?: "secondary"}`, +`{up: [x, y], button?}`, `{key, shift?, alt?, cmd?}` (pressed and released; egui key names such as +`ArrowLeft`, `Escape`, `A`), `{text}` (typed text) and `{leave: true}` (the pointer left the +window); positions are window points. Events of one call arrive in the same frame, so send a press +and its release (or a double-click's two clicks) as separate calls. Returns `{queued}`. + +```json +{"id":6,"method":"ui.input","params":{"events":[{"move":[1000,315]}]}} +{"id":7,"method":"ui.input","params":{"events":[{"down":[1000,315]}]}} +{"id":8,"method":"ui.input","params":{"events":[{"up":[1000,315]}]}} +``` + ### `ui.menu.invoke {id}` Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`. @@ -112,11 +130,12 @@ layers tree), `exportPreview` (bool: the Design panel Export section's preview), frame id: open the prototype preview pane like `proto.preview`; false or `null` closes it), `presentScroll` (`[x, y]`: scroll the presented screen, the full window's else the pane's, clamped to what it can scroll), `colorProfileDialog` (`srgb|displayP3`: open File ▸ Color profile with that -profile chosen; `null` closes it), `stylePicker` (`fill|stroke|effect|grid`: open the style picker -for the selection; `null` closes it) with `stylePickerEdit` (a style id or name: open its editor), -`stylePickerView` (`list|grid`), `protoEditor` (`[layer id, interaction index]`: open that -interaction's editor in the Prototype panel; `null` closes it). Returns `ui.inspect` (which reports -`stylePicker` and `stylePickerView`). +profile chosen; `null` closes it), `stylePicker` (`fill|stroke|effect|grid|text`: open the style +picker for the selection, `text` being the text style browser of the Typography section; `null` +closes it) with `stylePickerEdit` (a style id or name: open its editor), `stylePickerView` +(`list|grid`), `protoEditor` (`[layer id, interaction index]`: open that interaction's editor in the +Prototype panel; `null` closes it). Returns `ui.inspect` (which reports `stylePicker` and +`stylePickerView`). ### `ui.screenshot {path}` diff --git a/docs/mcp.md b/docs/mcp.md index 3bf5a1d7..2f5933db 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -103,6 +103,19 @@ Or in a project's `.mcp.json`: `text.setVariations {variations, replace?}` merges instead. `text.fonts {family, style?, axes: true}` lists a font's axes and named instances (named instances are also valid `fontStyle` values). OpenType features: `features {"liga": false, "tnum": true}`. +- **Text styles** link chars, not just layers: `style.apply {style, ids?, start?, end?}` with a text + style links chars `start`..`end` (while text is edited, the selected chars; else whole layers), and + `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]`). + Changing what a text style sets (font, size, line height, letter spacing, decoration, case, axis + values) on linked chars unlinks them; `style.update` moves every linked char. `style.create + {type: "text", name}` takes the selected chars while editing; a `value` may use the `node.set` + spellings (`{"fontSize": 32, "lineHeight": "120%", "letterSpacing": "2%"}`). +- **Live previews**: `text.preview {command, params}` shows what `style.apply`, + `library.applyStyle`, `text.setVariations` or `text.style` would do on the canvas without an undo + step (in place of an earlier preview); `text.endPreview` puts the document back. Any other command + ends the preview first, so running the command for real commits it as one undo step. - Every mutation is one undo step (`batch` makes several one step). A failing command leaves the document as it was. - Programmatic calls never open dialogs. diff --git a/docs/parity.md b/docs/parity.md index 84fe628b..53944652 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -1,6 +1,6 @@ # Parity report -Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (396 commands in the engine). +Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (398 commands in the engine). **197 / 202 menu items (97.5%)**