M7: per-paragraph paragraph attributes

Each paragraph has its own alignment, indents, spacing, hyphenation, tab
stops and paragraph style (TextObject.paras; para stays paragraph 0's, so
old files and readers work). Layout, editing (split/merge), threads, the
Paragraph/Tabs/Paragraph Styles panels, ranged text.setStyle/setFormat/
tabs/paraStyle.apply, SVG export/import and the fuzzers follow.
This commit is contained in:
haven
2026-10-08 15:07:13 -07:00
parent 6def232852
commit f7761bf5bc
30 changed files with 991 additions and 169 deletions
+11 -5
View File
@@ -44,6 +44,10 @@ enum Mode {
struct Typing {
base: Vec<TextRun>,
cur: Vec<TextRun>,
/// The first byte any edit of the session touched (the text before it is unchanged): the
/// previewed span starts no later, so Return at a paragraph's end splits that paragraph (its
/// style continues), not the next one.
lo: usize,
}
/// IME marked text inside the typing session, not yet committed.
@@ -174,9 +178,10 @@ impl TypeTool {
if self.typing.is_none() {
out.push(Action::Begin("Typing".into()));
}
let ty = self.typing.get_or_insert_with(|| Typing { base: t.runs.clone(), cur: t.runs.clone() });
let ty = self.typing.get_or_insert_with(|| Typing { base: t.runs.clone(), cur: t.runs.clone(), lo: usize::MAX });
let len = edit::runs_len(&ty.cur);
let (a, b) = (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len));
ty.lo = ty.lo.min(a);
let caret = match &styled {
Some(r) => edit::replace_range_styled(&mut ty.cur, a, b, r),
None => edit::replace_range(&mut ty.cur, a, b, insert),
@@ -187,7 +192,7 @@ impl TypeTool {
// One preview for the whole session: the changed span of base → cur, with its runs.
let base: String = ty.base.iter().map(|r| r.text.as_str()).collect();
let cur: String = ty.cur.iter().map(|r| r.text.as_str()).collect();
let (p, s) = common_affixes(&base, &cur);
let (p, s) = common_affixes(&base, &cur, ty.lo);
let runs = edit::slice_runs(&ty.cur, p, cur.len() - s);
let runs = if runs.is_empty() { json!([]) } else { serde_json::to_value(&runs).unwrap_or(json!([])) };
out.push(Action::Preview("text.editRange".into(), json!({"id": id.0, "start": p, "end": base.len() - s, "runs": runs})));
@@ -332,11 +337,12 @@ fn char_range_to_bytes(s: &str, r: Range<usize>) -> Option<Range<usize>> {
(a <= b).then_some(a..b)
}
/// Lengths of the common prefix and suffix of `a` and `b` (on char boundaries, not overlapping).
fn common_affixes(a: &str, b: &str) -> (usize, usize) {
/// Lengths of the common prefix (at most `max_prefix`) and suffix of `a` and `b` (on char
/// boundaries, not overlapping).
fn common_affixes(a: &str, b: &str, max_prefix: usize) -> (usize, usize) {
let mut p = 0;
for ((i, x), y) in a.char_indices().zip(b.chars()) {
if x != y {
if x != y || i + x.len_utf8() > max_prefix {
break;
}
p = i + x.len_utf8();
+7 -4
View File
@@ -221,10 +221,13 @@ fn select_all_and_styled_paste() {
#[test]
fn common_affixes_are_char_safe() {
assert_eq!(common_affixes("hello", "help"), (3, 0));
assert_eq!(common_affixes("aXb", "ab"), (1, 1));
assert_eq!(common_affixes("aaa", "aaaa"), (3, 0));
assert_eq!(common_affixes("é1", "è1"), (0, 1));
assert_eq!(common_affixes("hello", "help", usize::MAX), (3, 0));
assert_eq!(common_affixes("aXb", "ab", usize::MAX), (1, 1));
assert_eq!(common_affixes("aaa", "aaaa", usize::MAX), (3, 0));
assert_eq!(common_affixes("é1", "è1", usize::MAX), (0, 1));
// Return at the end of a paragraph: the span starts where the edit did (before the old break).
assert_eq!(common_affixes("abc\ndef", "abc\n\ndef", 3), (3, 4));
assert_eq!(common_affixes("abc\ndef", "abc\n\ndef", 4), (4, 3));
}
#[test]