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%)**