Files
designcraft/crates/engine/src/cmd/text.rs
T
moppeeer d4a00a207f Merge remote-tracking branch 'origin/main' into character-style-from-selection
# Conflicts:
#	crates/engine/src/cmd/style.rs
#	crates/engine/src/cmd/text.rs
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/i18n.rs
2026-10-29 11:51:59 +01:00

435 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Text editing (Type tool) and character/paragraph formatting.
use designcraft_compose as compose;
use designcraft_doc::story::floor_char_boundary;
use designcraft_doc::{CellAddr, CharAttrs, Content, ItemId, ParaAttrs, Selection, StoryId, TextSel};
use designcraft_geom::Point;
use serde_json::{Value, json};
use super::{CommandSpec, bad, bool_or, cmd, has_doc, has_text, has_text_or_frames, id_param, ok, point_param, str_param};
use crate::{Result, Session};
mod asian;
mod caret;
mod edit;
mod format;
mod opentype;
mod story;
use asian::{auto_tcy_cmd, grid_settings_cmd, kenten_cmd, ruby_cmd, shatai_cmd, warichu_cmd};
pub(crate) use caret::story_of;
use caret::{move_caret, para_bounds, place, release, select_around, select_at, sentence_bounds};
pub use caret::{next_char, next_word, prev_char, prev_word};
pub(crate) use edit::delete_selection;
pub use edit::{PLACEHOLDER, quote_marks};
use edit::{change_case, delete, delete_surrounding, fill_placeholder, insert};
pub(crate) use format::{
Target, TypingFormat, apply_char_attrs, effective_char_format, format_chars, format_paras, format_targets, format_targets_pub,
rename_typing_swatch, validate_char_fill,
};
use format::{break_link, kern_pair, kern_step, link_style, para_toggle, pick_up_format, step_size, swap_fill_stroke};
use opentype::{open_type, open_type_features, set_features};
pub(crate) use story::set_story_direction;
use story::{selection_attrs, story_direction, story_options};
/// Build the text command registry: caret/selection movement, insertion/deletion, release, text and paragraph formatting, story queries, ruby and OpenType commands, with their parameters and enabled predicates.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "text.placeCaret", "Place Caret", [], None, "{frame, point: [x,y] (spread coords)} — converts empty frames to text frames", has_doc, |s, p| place(s, p, false)),
cmd!(
"text.release",
"Release",
[],
None,
"{frame, point, moved: bool, copy?: bool} — ends a press in text: a drag that started in the selected text moves (copy: duplicates) it to the point; a click places the caret",
has_doc,
release
),
cmd!(
noundo "text.extendTo",
"Extend Text Selection",
[],
None,
"{frame, point, unit?: word|line|paragraph|story} — moves the selection's focus to the point; with `unit`, after a text.selectAt, the selection grows by whole units from the unit first selected (a drag after a double, triple or quadruple click)",
has_doc,
|s, p| place(s, p, true)
),
cmd!(noundo "text.selectWord", "Select Word", [], None, "{frame, point} — the word (or run of spaces) under the point", has_doc, |s, p| {
select_at(s, p, "word")
}),
cmd!(
noundo "text.selectAt",
"Select Text Unit",
[],
None,
"{frame, point, unit: word|line|paragraph|story} — what a double, triple, quadruple or quintuple click selects: the word (or run of spaces) under the point, its line, its paragraph with the paragraph return, or the whole story",
has_doc,
|s, p| select_at(s, p, str_param(p, "unit").unwrap_or("word"))
),
cmd!(
"type.pickUpFormat",
"Apply Text Attributes (Eyedropper)",
[],
None,
"{frame, point} — the Eyedropper with text selected: the character and paragraph formatting (styles and overrides) of the text under the point go to the selected text",
has_text,
pick_up_format
),
cmd!(noundo "text.selectSentence", "Select Sentence", [], None, "{frame, point} — the sentence under the point", has_doc, |s, p| {
select_around(s, p, "text.selectSentence", sentence_bounds)
}),
cmd!(noundo "text.selectParagraph", "Select Paragraph", [], None, "{frame, point} — the paragraph under the point, without its paragraph return", has_doc, |s, p| {
select_around(s, p, "text.selectParagraph", para_bounds)
}),
cmd!(noundo "text.selectStory", "Select Story", [], None, "{frame, point} — all the text of the story under the point, threaded frames included", has_doc, |s, p| {
select_around(s, p, "text.selectStory", |t, _| (0, t.len()))
}),
cmd!(noundo "text.select", "Select Text", [], None, "{story, anchor, focus} (UTF-8 byte offsets into the story text, as find.find reports them: á or — counts 2 or 3)", has_doc, |s, p| {
let sid = StoryId(p.get("story").and_then(Value::as_u64).unwrap_or(0));
let st = s.doc_mut()?;
let text = &st.doc.story(sid).ok_or_else(|| bad("text.select", "no such story"))?.text;
let a = floor_char_boundary(text, p.get("anchor").and_then(Value::as_u64).unwrap_or(0) as usize);
let f = floor_char_boundary(text, p.get("focus").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a));
st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: f, frame: None, cell: None });
st.bump_revision();
s.typing_format = None;
ok()
}),
cmd!("text.insert", "Type", [], None, "{text, raw?: bool (no typographer's quotes)} — replaces the selected text", has_text, insert),
cmd!(
"text.deleteSurrounding",
"Delete Surrounding Text",
[],
None,
"{before?: int, after?: int, bytes?: bool, commit?: string} — removes the text around the selection (an input method's delete-surrounding), keeps the selection and its direction, and with `commit` types that text over it: one undo step",
has_text,
delete_surrounding
),
cmd!("text.delete", "Delete Text", [], None, "{forward?: bool, word?: bool}", has_text, delete),
cmd!(noundo "text.move", "Move Caret", [], None, "{dir: left|right|up|down|lineStart|lineEnd|storyStart|storyEnd, extend?, word?}", has_text, move_caret),
cmd!(noundo "text.exitToFrame", "Select Frame", [], None, "{}", has_doc, |s, _| {
let st = s.doc_mut()?;
if let Some(t) = st.selection.text.take() {
let f = t.frame.or_else(|| st.doc.story(t.story).and_then(|x| x.frames.first().copied()));
st.selection.items = f.into_iter().collect();
}
st.selection.cells = None;
st.bump_revision();
ok()
}),
cmd!(
"story.setDirection",
"Story Column Direction",
["Type", "Story Column Direction"],
None,
"{story?, frame?: a text frame of the story, direction: leftToRight|rightToLeft} — column progression, independent of paragraph direction",
has_doc,
|s, p| {
let sid = story_of(s, p).ok_or_else(|| bad("story.setDirection", "no story"))?;
let direction: designcraft_doc::TextDirection =
serde_json::from_value(p.get("direction").cloned().ok_or_else(|| bad("story.setDirection", "missing direction"))?)
.map_err(|e| bad("story.setDirection", e.to_string()))?;
s.edit(|d, _| {
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
st.direction = direction;
st.rev = st.rev.wrapping_add(1);
ok()
})
}
),
cmd!(
"story.options",
"Story Options",
[],
None,
"{story?, frame?: a text frame of the story (default: the stories of the text selection or the selected frames), opticalMargin?: bool — Optical Margin Alignment: punctuation and letter edges hang outside the frame in every paragraph of the story except those with ignoreOpticalMargin, opticalMarginSize?: pt (0.1–1296, default 12) — the type size the hang amounts are set for} → {stories}",
has_doc,
story_options
),
cmd!("story.setText", "Set Story Text", [], None, "{story, text} — replace a story's whole text", has_doc, |s, p| {
let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.setText", "missing story"))?);
let text = super::text_param(p, "text");
s.edit(|d, sel| {
d.set_story_text(sid, &text)?;
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) {
t.anchor = floor_char_boundary(&text, t.anchor);
t.focus = floor_char_boundary(&text, t.focus);
}
ok()
})
}),
cmd!(
"story.replaceRange",
"Edit Story",
[],
None,
"{story, start, end, text} — replace a byte range keeping surrounding formatting",
has_doc,
|s, p| {
let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.replaceRange", "missing story"))?);
let offset = |v: u64| usize::try_from(v).map_err(|_| bad("story.replaceRange", format!("offset {v} is out of range")));
let a = offset(p.get("start").and_then(Value::as_u64).unwrap_or(0))?;
let b = match p.get("end").and_then(Value::as_u64) {
Some(v) => offset(v)?,
None => a,
};
let text = super::text_param(p, "text");
s.edit(|d, sel| {
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let n = st.len();
if a > b || b > n || !st.text.is_char_boundary(a) || !st.text.is_char_boundary(b) {
let msg = format!("range {a}..{b} must be UTF-8 byte offsets on character boundaries, start <= end; the story is {n} bytes");
return Err(bad("story.replaceRange", msg));
}
st.replace(a..b, &text);
let len = st.len();
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) {
t.anchor = floor_char_boundary(&st.text, t.anchor);
t.focus = floor_char_boundary(&st.text, t.focus);
}
Ok(json!({"length": len}))
})
}
),
cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, overset", has_doc, |s, p| {
let st = s.doc()?;
let sid = story_of(s, p).ok_or_else(|| bad("story.get", "no story"))?;
let mut v = st.doc.story_summary(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let cs = s.cache.get(&st.doc, sid, None);
v["overset"] = json!(cs.overset_at);
v["lines"] = json!(cs.line_count());
Ok(v)
}),
cmd!("type.fillWithPlaceholder", "Fill with Placeholder Text", ["Type"], None, "{frame?, story?}", has_text_or_frames, fill_placeholder),
cmd!(
"type.char",
"Character Formatting",
[],
None,
"{attrs: {fontFamily?, fontStyle?, size?, leading?: {kind:auto}|{kind:points,value}, tracking?, kerning?, hScale?, vScale?, baselineShift?, fill?, capitalization?, underline?, …}}",
has_text_or_frames,
|s, p| format_chars(s, p.get("attrs").unwrap_or(p))
),
cmd!(
"type.kernPair",
"Kerning",
[],
None,
"{kerning: {kind: metrics|optical|none}|{kind: manual, value: 1/1000 em}|null} — at a caret in text, the pair at the caret (the character before the caret takes it); at the start of a story or paragraph, the next typing; a selection or frames take it like `type.char`",
has_text_or_frames,
kern_pair
),
cmd!(
"type.para",
"Paragraph Formatting",
[],
None,
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, opticalMargin?: bool (hang at each character's own size; a story with story.options opticalMargin overrides it), ignoreOpticalMargin?: bool (no hang in the paragraph), tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}",
has_text_or_frames,
|s, p| format_paras(s, p.get("attrs").unwrap_or(p))
),
cmd!("type.align", "Align", [], None, "{align: left|center|right|leftJustified|…}", has_text_or_frames, |s, p| {
format_paras(s, &json!({"align": p.get("align").cloned().unwrap_or(json!("left"))}))
}),
cmd!("type.alignLeft", "Align Left", [], Some("Cmd+Shift+L"), "{}", has_text_or_frames, |s, _| format_paras(s, &json!({"align": "left"}))),
cmd!("type.alignCenter", "Align Center", [], Some("Cmd+Shift+C"), "{}", has_text_or_frames, |s, _| format_paras(
s,
&json!({"align": "center"})
)),
cmd!("type.alignRight", "Align Right", [], Some("Cmd+Shift+R"), "{}", has_text_or_frames, |s, _| format_paras(s, &json!({"align": "right"}))),
cmd!("type.justify", "Justify Left", [], Some("Cmd+Shift+J"), "{}", has_text_or_frames, |s, _| format_paras(
s,
&json!({"align": "leftJustified"})
)),
cmd!(
"type.bold",
"Bold",
[],
Some("Cmd+Shift+B"),
"{on?: bool} — the family's bold style, keeping italic (style linking); toggles when `on` is left out",
has_text_or_frames,
|s, p| link_style(s, p, true)
),
cmd!(
"type.italic",
"Italic",
[],
Some("Cmd+Shift+I"),
"{on?: bool} — the family's italic style, keeping the weight (style linking); toggles when `on` is left out",
has_text_or_frames,
|s, p| link_style(s, p, false)
),
cmd!(
"type.swapFillStroke",
"Swap Text Fill and Stroke",
[],
None,
"{} — swap each selected character's resolved fill/stroke and tints; at a caret, swap the next typing's colors",
has_text_or_frames,
swap_fill_stroke
),
cmd!("type.underline", "Underline", [], Some("Cmd+Shift+U"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"underline": true}))),
cmd!("type.allCaps", "All Caps", [], Some("Cmd+Shift+K"), "{}", has_text_or_frames, |s, _| format_chars(
s,
&json!({"capitalization": "allCaps"})
)),
cmd!(
"type.tateChuYoko",
"Tate-Chu-Yoko",
[],
None,
"{on?: bool} — set the selected text horizontally within one em of a vertical line (toggles by default)",
has_text_or_frames,
|s, p| {
let on = match p.get("on").and_then(Value::as_bool) {
Some(v) => v,
None => !format_targets(s).first().is_some_and(|t| {
s.doc().ok().and_then(|d| d.doc.text_story(t.story, t.cell)).is_some_and(|st| {
st.char_format_at(if t.range.is_empty() { t.range.start } else { t.range.start + 1 }).over.tate_chu_yoko == Some(true)
})
}),
};
format_chars(s, &json!({"tate_chu_yoko": on}))
}
),
cmd!(
"type.ruby",
"Ruby",
[],
None,
"{text?, type?: group|perCharacter, alignment?: left|center|right|fullJustify|jis|equalAki|oneAki, position?: aboveRight|belowLeft, xOffset?, yOffset? (pt), font?, fontStyle? (\"\" = the text's), size? (pt; null = half the text size), xScale?, yScale? (%), openTypePro?: bool, autoTcyDigits?: 0-9, autoTcyIncludeRoman?: bool, autoTcyAutoScale?: bool, overhang?: none|oneRuby|halfRuby|oneChar|halfChar|noLimit, parentSpacing?: noAdjustment|bothSides|aki121|equalAki|fullJustify, autoAlign?: bool, autoScaling?: bool, scalingPercent? (%), fill?, stroke? (swatch; \"\" = the text's), fillTint?, strokeTint? (%; null = the text's), strokeWeight? (pt; null = the text's), overprintFill?, overprintStroke?: auto|on|off} — the reading set over the selected text and how it is placed; empty text removes it. Per-character ruby separates the readings with U+3000",
has_text_or_frames,
ruby_cmd
),
cmd!(
"type.kenten",
"Kenten",
[],
None,
"{on?: bool, kind?: sesameDot|whiteSesameDot|fisheye|blackCircle|smallBlackCircle|bullseye|blackTriangle|whiteTriangle|whiteCircle|smallWhiteCircle|custom, character?: text (custom kind), font?: family (custom kind), fontStyle?, size?: pt (null: half the text size), distance?: pt, position?: aboveRight|belowLeft, alignment?: center|start, xScale?: %, yScale?: %, fill?: swatch (\"\": the text's), fillTint?: %, stroke?: swatch, strokeTint?: %, strokeWeight?: pt, overprintFill?: auto|on|off, overprintStroke?: auto|on|off} — emphasis marks beside the selected characters (toggles by default; any setting turns them on)",
has_text_or_frames,
kenten_cmd
),
cmd!(
"type.shatai",
"Shatai",
[],
None,
"{magnification?: % (0 = off), angle?: degrees, adjustRotation?: bool, adjustTsume?: bool} — slant the selected characters by compressing them across a direction",
has_text_or_frames,
shatai_cmd
),
cmd!(
"type.autoTateChuYoko",
"Auto Tate-Chu-Yoko",
[],
None,
"{digits?: 0..10 (0 = off), includeRoman?: bool} — in vertical text, set runs of up to `digits` half-width digits (and roman letters) across one em, for the selected paragraphs",
has_text_or_frames,
auto_tcy_cmd
),
cmd!(
"type.gridSettings",
"Grid Settings",
[],
None,
"{alignment?: none|romanBaseline|emTop|emCenter|emBottom|icfTop|icfBottom, firstLineOnly?: bool, gyoudori?: 0..100 (0 = auto), paragraphGyoudori?: bool} — how the selected paragraphs sit on the baseline grid",
has_text_or_frames,
grid_settings_cmd
),
cmd!(
"type.rotateRoman",
"Rotate Roman Characters in Vertical Text",
[],
None,
"{on?: bool} — half-width characters stand upright in vertical text, for the selected paragraphs (toggles by default)",
has_text_or_frames,
|s, p| para_toggle(s, p, "type.rotateRoman", "rotateRoman", |pp| pp.rotate_roman)
),
cmd!(
"type.romanWordBreak",
"Roman Word Break",
[],
None,
"{on?: bool} — roman words in the selected paragraphs may break between any two letters, without a hyphen (toggles by default)",
has_text_or_frames,
|s, p| para_toggle(s, p, "type.romanWordBreak", "romanWordBreak", |pp| pp.roman_word_break)
),
cmd!(
"type.warichu",
"Warichu",
[],
None,
"{on?: bool, lines?: int, size?: number, lineSpacing?: number, align?: auto|left|center|right|justify, charsBeforeBreak?: int, charsAfterBreak?: int} — set the selection as an inline note in smaller lines inside one line height (toggles by default)",
has_text_or_frames,
warichu_cmd
),
cmd!(
"type.storyDirection",
"Story Direction",
[],
None,
"{vertical: bool} — the stories of the text selection or the selected frames: vertical lines run top to bottom and follow each other right to left, in every frame of the thread",
has_text_or_frames,
story_direction
),
cmd!(
"type.kernStep",
"Kerning/Tracking Step",
[],
None,
"{steps?: n (default 1; negative tightens), amount?: 1/1000 em (in place of steps × the document's kerningIncrement)} — with a caret, the manual kerning between the characters at the caret (from the pair's manual value, else 0); with a selection, the tracking of the selected text",
has_text_or_frames,
kern_step
),
cmd!("type.sizeUp", "Increase Point Size", [], Some("Cmd+Shift+."), "{}", has_text_or_frames, |s, _| step_size(s, 2.0)),
cmd!("type.sizeDown", "Decrease Point Size", [], Some("Cmd+Shift+,"), "{}", has_text_or_frames, |s, _| step_size(s, -2.0)),
cmd!(
"type.changeCase",
"Change Case",
["Type"],
None,
"{case: upper|lower|title|sentence} — the selected text, or the stories of selected frames (formatting kept)",
has_text_or_frames,
change_case
),
cmd!(
"type.openType",
"OpenType",
[],
None,
"{feature?: dlig|frac|ordn|swsh|titl|calt|zero|ssNN|any tag, on?: bool (default: toggle), figures?: tabularLining|proportionalOldstyle|proportionalLining|tabularOldstyle|default, stylisticSets?: [1–20]} → {features, figures}",
has_text_or_frames,
open_type
),
cmd!(
"style.breakLink",
"Break Link to Style",
[],
None,
"{kind: paragraph|character} — the selected text keeps its look as local formatting, with [No Paragraph Style] / [None]",
has_text_or_frames,
break_link
),
cmd!(
query "type.selectionAttrs",
"Selection Attributes",
[],
None,
"{mixed?: bool} → resolved character/paragraph attributes at the text selection; `ownChars`: what the text has of its own over its paragraph (its character style's attributes and its overrides, what a character style made from the selection sets); with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters); `storyOptions` {opticalMargin, opticalMarginSize} of the stories story.options would change (null where they differ)",
has_doc,
selection_attrs
),
cmd!(query "type.openTypeFeatures", "OpenType Features", [], None, "{} → the OpenType features and stylistic sets the selected text's fonts offer, each with its applied and available state (none, some or all of the selection)", has_doc, open_type_features),
cmd!(
"type.feature",
"Set OpenType Features",
[],
None,
"{features: {tag: true|false, …}} — turns OpenType features on or off across the selected text, keeping each run's other features",
has_doc,
set_features
),
]
}