mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-03 00:39:44 -06:00
M2: text depth: OpenType features, small caps, lists, title case across runs, truncation; font picker with previews, type settings; text editing selection and IME
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
23c54a6663
commit
b943e12f3d
@@ -16,7 +16,8 @@ clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, paddi
|
||||
mainAlign (min|center|max|spaceBetween), crossAlign (min|center|max|baseline), wrap, gridColumns, gridRows, sizingH / sizingV (fixed|hug|fill), \
|
||||
absolute, minWidth, maxWidth, minHeight, maxHeight, constraints {horizontal, vertical}, grids, exports, characters, fontFamily, fontStyle, \
|
||||
fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (left|center|right|justified), textAlignVertical, textAutoResize \
|
||||
(widthAndHeight|height|none|truncate), textDecoration, textCase, paragraphSpacing, listStyle, pointCount, innerRadius, arcStart, arcSweep, \
|
||||
(widthAndHeight|height|none|truncate), textDecoration, textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \
|
||||
listStyle (none|bulleted|numbered), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), pointCount, innerRadius, arcStart, arcSweep, \
|
||||
arcInner, constrainProportions, description, interactions, overflow, pluginData {k: v}";
|
||||
|
||||
/// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values).
|
||||
@@ -45,7 +46,7 @@ pub fn snake(v: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
|
||||
pub(crate) fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
|
||||
let s = v.as_str()?;
|
||||
// Accept camelCase, snake/upper-case and spaced variants.
|
||||
let norm: String = {
|
||||
@@ -358,6 +359,16 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
"paragraphIndent" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_indent = f64_of(v).unwrap_or(0.0).max(0.0),
|
||||
"listStyle" => text_mut(cmd, doc.get_mut(id)?)?.list = enum_of(v).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?,
|
||||
"maxLines" => text_mut(cmd, doc.get_mut(id)?)?.max_lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32,
|
||||
"features" | "textFeatures" | "fontFeatures" | "openTypeFeatures" => {
|
||||
let f = features_of(cmd, v)?;
|
||||
let t = text_mut(cmd, doc.get_mut(id)?)?;
|
||||
t.features.clear();
|
||||
for (k, on) in f {
|
||||
if let Some(on) = on {
|
||||
t.features.insert(k, on);
|
||||
}
|
||||
}
|
||||
}
|
||||
"start" | "end" => {}
|
||||
"pointCount" => {
|
||||
let c = f64_of(v).unwrap_or(5.0).clamp(3.0, 120.0) as u32;
|
||||
@@ -488,6 +499,7 @@ pub fn override_group(k: &str) -> &'static str {
|
||||
"visible" => "visible",
|
||||
"name" => "name",
|
||||
"characters" | "text" => "characters",
|
||||
"features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "maxLines" => "textStyle",
|
||||
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
|
||||
"width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size",
|
||||
k if k.starts_with("font") || k.starts_with("text") || k.starts_with("letter") || k.starts_with("line") || k.starts_with("paragraph") => {
|
||||
@@ -556,7 +568,7 @@ pub fn apply_text_style(cmd: &str, t: &mut TextData, start: usize, end: usize, k
|
||||
t.restyle(start, end, |s| s.decoration = d);
|
||||
}
|
||||
"textCase" => {
|
||||
let c: TextCase = enum_of(v).ok_or_else(|| bad(cmd, "textCase: original|upper|lower|title"))?;
|
||||
let c: TextCase = enum_of(v).ok_or_else(|| bad(cmd, "textCase: original|upper|lower|title|smallCaps"))?;
|
||||
t.restyle(start, end, |s| s.case = c);
|
||||
}
|
||||
"hyperlink" => {
|
||||
@@ -572,6 +584,24 @@ pub fn apply_text_style(cmd: &str, t: &mut TextData, start: usize, end: usize, k
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// OpenType feature toggles from `{tag: bool | null}` (null = back to the font's default).
|
||||
pub fn features_of(cmd: &str, v: &Value) -> Result<Vec<(String, Option<bool>)>> {
|
||||
let Value::Object(m) = v else { return Err(bad(cmd, "features: {tag: bool}, e.g. {\"liga\": false}")) };
|
||||
let mut out = Vec::with_capacity(m.len());
|
||||
for (k, on) in m {
|
||||
if !valid_feature_tag(k) {
|
||||
return Err(bad(cmd, format!("`{k}` is not a 4-character OpenType feature tag")));
|
||||
}
|
||||
let on = match on {
|
||||
Value::Null => None,
|
||||
Value::Bool(b) => Some(*b),
|
||||
other => Some(f64_of(other).ok_or_else(|| bad(cmd, format!("feature `{k}` must be true, false or null")))? != 0.0),
|
||||
};
|
||||
out.push((k.clone(), on));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn weight_name(w: u16) -> &'static str {
|
||||
match w {
|
||||
0..=149 => "Thin",
|
||||
|
||||
@@ -7,6 +7,10 @@ use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{Result, Session, bad, spec, textedit};
|
||||
|
||||
fn finite(v: f64) -> f64 {
|
||||
if v.is_finite() { v } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Apply a style change to the edited range, or to whole text layers in the selection.
|
||||
fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value> {
|
||||
if let Some((id, a, b)) = textedit::selected_range(s) {
|
||||
@@ -154,6 +158,67 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("text.caseUpper", "Uppercase", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Upper)),
|
||||
spec!("text.caseLower", "Lowercase", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Lower)),
|
||||
spec!("text.caseTitle", "Title case", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Title)),
|
||||
spec!("text.caseSmallCaps", "Small caps", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::SmallCaps)),
|
||||
spec!("text.case", "Letter case", "{case: original|upper|lower|title|smallCaps, ids?}", true, |s, p| {
|
||||
let c: TextCase =
|
||||
get(p, "case").and_then(super::props::enum_of).ok_or_else(|| bad("text.case", "`case`: original|upper|lower|title|smallCaps"))?;
|
||||
style(s, p, |st| st.case = c)
|
||||
}),
|
||||
spec!("text.decoration", "Decoration", "{decoration: none|underline|strikethrough, ids?}", true, |s, p| {
|
||||
let d: TextDecoration = get(p, "decoration")
|
||||
.and_then(super::props::enum_of)
|
||||
.ok_or_else(|| bad("text.decoration", "`decoration`: none|underline|strikethrough"))?;
|
||||
style(s, p, |st| st.decoration = d)
|
||||
}),
|
||||
spec!("text.listStyle", "List style", "{style: none|bulleted|numbered, ids?}", true, |s, p| {
|
||||
let l: ListStyle = get(p, "style")
|
||||
.or_else(|| get(p, "list"))
|
||||
.and_then(super::props::enum_of)
|
||||
.ok_or_else(|| bad("text.listStyle", "`style`: none|bulleted|numbered"))?;
|
||||
para(s, p, |t| t.list = l)
|
||||
}),
|
||||
spec!("text.paragraphIndent", "Paragraph indent", "{indent (px), ids?}", true, |s, p| {
|
||||
let v = finite(req_num("text.paragraphIndent", p, "indent")?).clamp(0.0, 10_000.0);
|
||||
para(s, p, |t| t.paragraph_indent = v)
|
||||
}),
|
||||
spec!("text.paragraphSpacing", "Paragraph spacing", "{spacing (px), ids?}", true, |s, p| {
|
||||
let v = finite(req_num("text.paragraphSpacing", p, "spacing")?).clamp(0.0, 10_000.0);
|
||||
para(s, p, |t| t.paragraph_spacing = v)
|
||||
}),
|
||||
spec!("text.maxLines", "Max lines", "{lines (0 = no limit; truncates), ids?}", true, |s, p| {
|
||||
let v = finite(req_num("text.maxLines", p, "lines")?).clamp(0.0, 10_000.0) as u32;
|
||||
para(s, p, |t| {
|
||||
t.max_lines = v;
|
||||
if v > 0 {
|
||||
t.auto_resize = TextAutoResize::Truncate;
|
||||
}
|
||||
})
|
||||
}),
|
||||
spec!(
|
||||
"text.setFeatures",
|
||||
"OpenType features",
|
||||
"{features: {tag: bool | null (font default)}, replace? (clear others first), id?/ids?}",
|
||||
true,
|
||||
|s, p| {
|
||||
let f = super::props::features_of("text.setFeatures", get(p, "features").unwrap_or(&Value::Null))?;
|
||||
let replace = boolean(p, "replace").unwrap_or(false);
|
||||
para(s, p, |t| {
|
||||
if replace {
|
||||
t.features.clear();
|
||||
}
|
||||
for (k, on) in &f {
|
||||
match on {
|
||||
Some(on) => {
|
||||
t.features.insert(k.clone(), *on);
|
||||
}
|
||||
None => {
|
||||
t.features.remove(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
),
|
||||
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)?;
|
||||
@@ -217,15 +282,41 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
textedit::set_caret(s, i, boolean(p, "extend").unwrap_or(false))?;
|
||||
ok()
|
||||
}),
|
||||
spec!("text.edit.selectWord", "Select word", "{index}", false, |s, p| {
|
||||
let i = finite(req_num("text.edit.selectWord", p, "index")?).max(0.0) as usize;
|
||||
textedit::select_word(s, i)?;
|
||||
ok()
|
||||
}),
|
||||
spec!("text.edit.selectLine", "Select line", "{index}", false, |s, p| {
|
||||
let i = finite(req_num("text.edit.selectLine", p, "index")?).max(0.0) as usize;
|
||||
textedit::select_line(s, i)?;
|
||||
ok()
|
||||
}),
|
||||
spec!("text.edit.selection", "Text selection", "{}", false, |s, _| {
|
||||
Ok(match s.ds()?.text_edit.clone() {
|
||||
Some(te) => json!({"id": te.id, "caret": te.caret, "anchor": te.anchor, "text": textedit::selected_text(s)}),
|
||||
None => Value::Null,
|
||||
})
|
||||
}),
|
||||
spec!("text.edit.end", "Stop editing text", "{}", false, |s, _| {
|
||||
textedit::end(s)?;
|
||||
ok()
|
||||
}),
|
||||
spec!("text.fonts", "List fonts", "{family? (list its styles)}", false, |_, p| {
|
||||
Ok(match string(p, "family") {
|
||||
Some(f) => json!(figcraft_text::family_styles(&f)),
|
||||
None => json!(figcraft_text::family_names()),
|
||||
})
|
||||
}),
|
||||
spec!(
|
||||
"text.fonts",
|
||||
"List fonts",
|
||||
"{family? (list its styles), features? (with family+style: the face's OpenType features)}",
|
||||
false,
|
||||
|_, p| {
|
||||
Ok(match string(p, "family") {
|
||||
Some(f) if boolean(p, "features").unwrap_or(false) => {
|
||||
let st = string(p, "style").unwrap_or_else(|| "Regular".into());
|
||||
json!(figcraft_text::find_face(&f, &st).map(|face| face.feature_tags()).unwrap_or_default())
|
||||
}
|
||||
Some(f) => json!(figcraft_text::family_styles(&f)),
|
||||
None => json!(figcraft_text::family_names()),
|
||||
})
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -76,9 +76,18 @@ fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Type `text` replacing the selection.
|
||||
/// Type (or paste) `text` replacing the selection. Line endings are normalised and other control
|
||||
/// characters dropped, so pasted plain text from any platform lands cleanly.
|
||||
pub fn insert(s: &mut Session, text: &str) -> Result<()> {
|
||||
let text = text.replace("\r\n", "\n").replace('\r', "\n");
|
||||
let text: String = text
|
||||
.replace("\r\n", "\n")
|
||||
.replace(['\r', '\u{2028}', '\u{2029}'], "\n")
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
|
||||
.collect();
|
||||
if text.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
edit(s, |t, te| {
|
||||
let (a, b) = range(te);
|
||||
t.replace_range(a, b, &text);
|
||||
@@ -161,10 +170,16 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R
|
||||
figcraft_text::layout_node(node).map(|(l, _)| l)
|
||||
};
|
||||
let (a, b) = range(&te);
|
||||
let cur = te.caret;
|
||||
// Without Shift, a selection collapses first: plain arrows stop at its edge, word/line moves
|
||||
// continue from that edge.
|
||||
let cur = match key {
|
||||
"Left" | "Up" | "Home" if !shift && a != b => a,
|
||||
"Right" | "Down" | "End" if !shift && a != b => b,
|
||||
_ => te.caret,
|
||||
};
|
||||
let next = match key {
|
||||
"Left" if !shift && a != b => a,
|
||||
"Right" if !shift && a != b => b,
|
||||
"Left" if !shift && a != b && !word && !line => a,
|
||||
"Right" if !shift && a != b && !word && !line => b,
|
||||
"Left" if line => line_start(&chars, cur),
|
||||
"Right" if line => line_end(&chars, cur),
|
||||
"Left" if word => word_left(&chars, cur),
|
||||
@@ -235,6 +250,11 @@ fn line_end(chars: &[char], mut i: usize) -> usize {
|
||||
|
||||
/// Place the caret (and optionally extend the selection) at char `index`.
|
||||
pub fn set_caret(s: &mut Session, index: usize, extend: bool) -> Result<()> {
|
||||
let len = match s.ds()?.text_edit.as_ref() {
|
||||
Some(te) => text_of(s, te.id).map(|t| t.char_count()).unwrap_or(0),
|
||||
None => 0,
|
||||
};
|
||||
let index = index.min(len);
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(te) = ds.text_edit.as_mut() {
|
||||
te.caret = index;
|
||||
@@ -251,12 +271,28 @@ pub fn select_word(s: &mut Session, index: usize) -> Result<()> {
|
||||
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) };
|
||||
let chars: Vec<char> = text_of(s, te.id)?.characters.chars().collect();
|
||||
let mut a = index.min(chars.len());
|
||||
let mut b = a;
|
||||
while a > 0 && chars.get(a - 1).is_some_and(|c| is_word(*c)) {
|
||||
// Double-clicking just after a word's last char (or at the end) selects that word.
|
||||
if a > 0 && chars.get(a).is_none_or(|c| !is_word(*c)) && chars.get(a - 1).is_some_and(|c| is_word(*c)) {
|
||||
a -= 1;
|
||||
}
|
||||
while b < chars.len() && chars.get(b).is_some_and(|c| is_word(*c)) {
|
||||
b += 1;
|
||||
let mut b = a;
|
||||
let class = |c: char| {
|
||||
if is_word(c) {
|
||||
0
|
||||
} else if c.is_whitespace() {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
};
|
||||
// A word, a run of spaces or a run of punctuation, like the incumbent; newlines stay unselected.
|
||||
if let Some(k) = chars.get(a).filter(|c| **c != '\n').map(|c| class(*c)) {
|
||||
while a > 0 && chars.get(a - 1).is_some_and(|c| *c != '\n' && class(*c) == k) {
|
||||
a -= 1;
|
||||
}
|
||||
while b < chars.len() && chars.get(b).is_some_and(|c| *c != '\n' && class(*c) == k) {
|
||||
b += 1;
|
||||
}
|
||||
}
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(te) = ds.text_edit.as_mut() {
|
||||
@@ -267,9 +303,280 @@ pub fn select_word(s: &mut Session, index: usize) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Select the line (paragraph) containing `index` (triple-click).
|
||||
pub fn select_line(s: &mut Session, index: usize) -> Result<()> {
|
||||
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) };
|
||||
let chars: Vec<char> = text_of(s, te.id)?.characters.chars().collect();
|
||||
let i = index.min(chars.len());
|
||||
let (a, b) = (line_start(&chars, i), line_end(&chars, i));
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(te) = ds.text_edit.as_mut() {
|
||||
te.anchor = a;
|
||||
te.caret = b;
|
||||
}
|
||||
ds.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The selected text while editing (for copy).
|
||||
pub fn selected_text(s: &Session) -> Option<String> {
|
||||
let (id, a, b) = selected_range(s)?;
|
||||
let doc = s.doc().ok()?;
|
||||
let t = doc.node(id)?.kind.text()?;
|
||||
Some(t.characters.chars().skip(a).take(b.saturating_sub(a)).collect())
|
||||
}
|
||||
|
||||
/// The selected range while editing (for styling commands), or None.
|
||||
pub fn selected_range(s: &Session) -> Option<(NodeId, usize, usize)> {
|
||||
let te = s.ds().ok()?.text_edit.clone()?;
|
||||
let (a, b) = range(&te);
|
||||
Some((te.id, a, b))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
use figcraft_doc::{ListStyle, TextAutoResize, TextCase, TextDecoration};
|
||||
use serde_json::json;
|
||||
|
||||
fn setup(text: &str) -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("text.create", &json!({"text": text, "x": 0, "y": 0, "props": {"fontSize": 24}})).unwrap();
|
||||
let id = crate::params::id_of(&r["id"]).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn data(s: &Session, id: NodeId) -> TextData {
|
||||
s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone()
|
||||
}
|
||||
|
||||
fn sel(s: &Session) -> (usize, usize) {
|
||||
let te = s.ds().unwrap().text_edit.clone().unwrap();
|
||||
(te.anchor, te.caret)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_editing_session_is_one_undo_step() {
|
||||
let (mut s, id) = setup("Hello");
|
||||
let undo_before = s.ds().unwrap().history.undo.len();
|
||||
begin(&mut s, id, Some(5), false).unwrap();
|
||||
insert(&mut s, " big").unwrap();
|
||||
insert(&mut s, " world").unwrap();
|
||||
key(&mut s, "Backspace", false, false, false).unwrap();
|
||||
// Styling a range mid-session joins the same step.
|
||||
set_caret(&mut s, 0, false).unwrap();
|
||||
set_caret(&mut s, 5, true).unwrap();
|
||||
s.execute("text.bold", &json!({})).unwrap();
|
||||
s.execute("text.alignCenter", &json!({})).unwrap();
|
||||
end(&mut s).unwrap();
|
||||
let t = data(&s, id);
|
||||
assert_eq!(t.characters, "Hello big worl");
|
||||
assert_eq!(t.style_at(0).font_style, "Bold");
|
||||
assert_eq!(t.style_at(6).font_style, "Regular");
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1);
|
||||
s.undo().unwrap();
|
||||
let t = data(&s, id);
|
||||
assert_eq!(t.characters, "Hello");
|
||||
assert!(t.runs.is_empty());
|
||||
assert_eq!(t.align_h, figcraft_doc::TextAlignH::Left);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_replaces_selection_and_paste_is_plain() {
|
||||
let (mut s, id) = setup("Hello world");
|
||||
begin(&mut s, id, None, false).unwrap();
|
||||
assert_eq!(sel(&s), (0, 11), "begin without caret selects all");
|
||||
select_word(&mut s, 7).unwrap();
|
||||
assert_eq!(sel(&s), (6, 11));
|
||||
insert(&mut s, "there").unwrap();
|
||||
assert_eq!(data(&s, id).characters, "Hello there");
|
||||
assert_eq!(sel(&s), (11, 11));
|
||||
// Pasted text: CRLF / CR / Unicode separators become newlines, other controls are dropped.
|
||||
insert(&mut s, "\r\na\rb\u{2029}c\u{0}\u{7}\td").unwrap();
|
||||
assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td");
|
||||
insert(&mut s, "\u{1}").unwrap();
|
||||
assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td");
|
||||
// Select the line (paragraph) then type over it.
|
||||
select_line(&mut s, 0).unwrap();
|
||||
assert_eq!(sel(&s), (0, 11));
|
||||
insert(&mut s, "Hi").unwrap();
|
||||
assert_eq!(data(&s, id).characters, "Hi\na\nb\nc\td");
|
||||
assert_eq!(selected_text(&s).as_deref(), Some(""));
|
||||
end(&mut s).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_selection_classes() {
|
||||
let (mut s, id) = setup("foo, bar_baz!\nnext");
|
||||
begin(&mut s, id, Some(0), false).unwrap();
|
||||
select_word(&mut s, 1).unwrap();
|
||||
assert_eq!(sel(&s), (0, 3));
|
||||
// Right after a word's last char selects the word.
|
||||
select_word(&mut s, 13).unwrap();
|
||||
assert_eq!(sel(&s), (6, 13));
|
||||
// A run of spaces.
|
||||
select_word(&mut s, 4).unwrap();
|
||||
assert_eq!(sel(&s), (4, 6));
|
||||
select_line(&mut s, 16).unwrap();
|
||||
assert_eq!(sel(&s), (15, 19));
|
||||
select_line(&mut s, 999).unwrap();
|
||||
assert_eq!(sel(&s), (15, 19));
|
||||
select_word(&mut s, 999).unwrap();
|
||||
assert_eq!(sel(&s), (15, 19));
|
||||
set_caret(&mut s, 999, false).unwrap();
|
||||
assert_eq!(sel(&s), (19, 19), "carets clamp to the text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyboard_navigation_and_extension() {
|
||||
let (mut s, id) = setup("one two\nthree");
|
||||
begin(&mut s, id, Some(0), false).unwrap();
|
||||
key(&mut s, "Right", false, true, false).unwrap(); // Alt+Right: end of word
|
||||
assert_eq!(sel(&s), (3, 3));
|
||||
key(&mut s, "Right", true, true, false).unwrap(); // Shift+Alt+Right extends
|
||||
assert_eq!(sel(&s), (3, 7));
|
||||
key(&mut s, "Left", false, false, false).unwrap(); // collapses to the start
|
||||
assert_eq!(sel(&s), (3, 3));
|
||||
key(&mut s, "Right", false, false, true).unwrap(); // Cmd+Right: line end
|
||||
assert_eq!(sel(&s), (7, 7));
|
||||
key(&mut s, "Down", true, false, true).unwrap(); // Shift+Cmd+Down: to the end
|
||||
assert_eq!(sel(&s), (7, 13));
|
||||
key(&mut s, "Left", false, false, true).unwrap(); // Cmd+Left from the collapsed start
|
||||
assert_eq!(sel(&s), (0, 0));
|
||||
key(&mut s, "Delete", false, true, false).unwrap(); // Alt+Delete: word forward
|
||||
assert_eq!(data(&s, id).characters, " two\nthree");
|
||||
key(&mut s, "SelectAll", false, false, false).unwrap();
|
||||
assert_eq!(sel(&s), (0, 10));
|
||||
end(&mut s).unwrap();
|
||||
}
|
||||
|
||||
fn ev(kind: PointerKind, x: f64, y: f64, shift: bool) -> PointerEvent {
|
||||
let mut e = PointerEvent::at(kind, x, y, 1.0);
|
||||
e.shift = shift;
|
||||
e
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_double_click_drag_and_shift_click() {
|
||||
let (mut s, id) = setup("Hello world");
|
||||
let Some((l, _)) = figcraft_text::layout_node(s.doc().unwrap().node(id).unwrap()) else { return };
|
||||
if l.glyphs.is_empty() {
|
||||
return; // no fonts
|
||||
}
|
||||
let y = l.lines[0].top + l.lines[0].height / 2.0;
|
||||
let x_of = |i: usize| l.caret(i).0;
|
||||
// Double-click inside "world" edits and selects the word.
|
||||
let mid = (x_of(7) + x_of(8)) / 2.0;
|
||||
s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap();
|
||||
s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap();
|
||||
assert_eq!(sel(&s), (6, 11));
|
||||
// Drag-select from the start of "Hello" to the middle.
|
||||
s.pointer(&ev(PointerKind::Down, x_of(0) + 0.5, y, false)).unwrap();
|
||||
assert_eq!(sel(&s), (0, 0));
|
||||
s.pointer(&ev(PointerKind::Move, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap();
|
||||
assert_eq!(sel(&s), (0, 3));
|
||||
// Shift+click extends.
|
||||
s.pointer(&ev(PointerKind::Down, x_of(9) + 0.5, y, true)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, x_of(9) + 0.5, y, true)).unwrap();
|
||||
assert_eq!(sel(&s), (0, 9));
|
||||
insert(&mut s, "J").unwrap();
|
||||
assert_eq!(data(&s, id).characters, "Jld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_commands_apply_to_edited_range() {
|
||||
let (mut s, id) = setup("Hello world");
|
||||
begin(&mut s, id, Some(0), false).unwrap();
|
||||
set_caret(&mut s, 6, true).unwrap(); // "Hello "
|
||||
for (cmd, p) in [
|
||||
("text.underline", json!({})),
|
||||
("text.italic", json!({})),
|
||||
("text.sizeUp", json!({})),
|
||||
("text.trackingUp", json!({})),
|
||||
("text.lineHeightUp", json!({})),
|
||||
("text.caseSmallCaps", json!({})),
|
||||
("text.link", json!({"url": "https://example.com"})),
|
||||
] {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"));
|
||||
}
|
||||
let t = data(&s, id);
|
||||
let (a, b) = (t.style_at(0), t.style_at(8));
|
||||
assert_eq!(a.decoration, TextDecoration::Underline);
|
||||
assert!(a.italic() && !b.italic());
|
||||
assert_eq!(a.font_size, 25.0);
|
||||
assert_eq!(b.font_size, 24.0);
|
||||
assert_eq!(a.case, TextCase::SmallCaps);
|
||||
assert_eq!(a.link.as_deref(), Some("https://example.com"));
|
||||
assert!(b.link.is_none());
|
||||
// text.style with an explicit range (what the panel sends).
|
||||
s.execute("text.style", &json!({"id": id, "start": 6, "end": 11, "fontSize": 40, "textCase": "upper"})).unwrap();
|
||||
let t = data(&s, id);
|
||||
assert_eq!(t.style_at(8).font_size, 40.0);
|
||||
assert_eq!(t.style_at(8).case, TextCase::Upper);
|
||||
// Paragraph commands hit the edited node.
|
||||
s.execute("text.listStyle", &json!({"style": "numbered"})).unwrap();
|
||||
assert_eq!(data(&s, id).list, ListStyle::Numbered);
|
||||
// Every Text-menu command runs while editing.
|
||||
for item in crate::catalog::MENU.iter().filter(|i| i.id.starts_with("text.")) {
|
||||
let p = if item.id == "text.link" { json!({"url": "u"}) } else { json!({}) };
|
||||
if crate::find_command(item.id).is_some() {
|
||||
s.execute(item.id, &p).unwrap_or_else(|e| panic!("{}: {e}", item.id));
|
||||
}
|
||||
}
|
||||
end(&mut s).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_and_feature_commands_on_nodes() {
|
||||
let (mut s, id) = setup("Hello");
|
||||
let ids = json!([id]);
|
||||
s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": false, "tnum": true}})).unwrap();
|
||||
assert_eq!(data(&s, id).features.get("liga"), Some(&false));
|
||||
s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": null, "ss01": 1}})).unwrap();
|
||||
let f = data(&s, id).features;
|
||||
assert_eq!(f.get("liga"), None);
|
||||
assert_eq!(f.get("ss01"), Some(&true));
|
||||
assert_eq!(f.get("tnum"), Some(&true));
|
||||
s.execute("text.setFeatures", &json!({"ids": ids, "features": {"zero": true}, "replace": true})).unwrap();
|
||||
assert_eq!(data(&s, id).features.len(), 1);
|
||||
assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"ligatures": true}})).is_err());
|
||||
assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": "yes"}})).is_err());
|
||||
assert!(s.execute("text.setFeatures", &json!({"ids": ids})).is_err());
|
||||
s.execute("node.set", &json!({"id": id, "props": {"features": {"calt": false}}})).unwrap();
|
||||
assert_eq!(data(&s, id).features.len(), 1);
|
||||
s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": 16})).unwrap();
|
||||
s.execute("text.paragraphSpacing", &json!({"ids": ids, "spacing": 8})).unwrap();
|
||||
s.execute("text.maxLines", &json!({"ids": ids, "lines": 2})).unwrap();
|
||||
s.execute("text.decoration", &json!({"ids": ids, "decoration": "strikethrough"})).unwrap();
|
||||
s.execute("text.case", &json!({"ids": ids, "case": "smallCaps"})).unwrap();
|
||||
s.execute("text.listStyle", &json!({"ids": ids, "style": "bulleted"})).unwrap();
|
||||
let t = data(&s, id);
|
||||
assert_eq!((t.paragraph_indent, t.paragraph_spacing, t.max_lines), (16.0, 8.0, 2));
|
||||
assert_eq!(t.auto_resize, TextAutoResize::Truncate);
|
||||
assert_eq!(t.style.decoration, TextDecoration::Strikethrough);
|
||||
assert_eq!(t.style.case, TextCase::SmallCaps);
|
||||
assert_eq!(t.list, ListStyle::Bulleted);
|
||||
assert!(s.execute("text.listStyle", &json!({"ids": ids, "style": "zigzag"})).is_err());
|
||||
assert!(s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": "NaN"})).is_err());
|
||||
s.execute("node.set", &json!({"id": id, "props": {"textCase": "smallCaps", "paragraphIndent": 4, "maxLines": 0}})).unwrap();
|
||||
assert_eq!(data(&s, id).paragraph_indent, 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_commands_survive_junk_while_editing() {
|
||||
let junk = [json!({}), json!(null), json!({"index": -5, "features": {"": 1}}), json!({"index": 1e300, "style": 3, "case": null}), json!([1])];
|
||||
for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.")) {
|
||||
for j in &junk {
|
||||
let (mut s, id) = setup("Hi there\nyou");
|
||||
begin(&mut s, id, Some(2), false).unwrap();
|
||||
set_caret(&mut s, 5, true).unwrap();
|
||||
if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) {
|
||||
panic!("{cmd} panicked on {j}: {msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user