mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-26 03:17:28 -06:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74867b4c46 | ||
|
|
d0e3b8a93e |
+37
-38
@@ -362,43 +362,6 @@ mod tests {
|
||||
assert!(near(corner_radius(Some(10_000), -5.0, 100.0), 0.0));
|
||||
}
|
||||
|
||||
/// One rerouting case: the free end settles and the attached end keeps its site.
|
||||
fn assert_transformed_reroute(kind: ShapeKind, rot: f32, flip_h: bool, flip_v: bool, free: Option<ConnEnd>, free_start: bool) {
|
||||
let attached = Some(ConnEnd { id: 7, site: 3 });
|
||||
let (start, end) = if free_start { (free, attached) } else { (attached, free) };
|
||||
let mut obj = shape(kind, 40.0, 20.0, ShapeExtra { start, end, ..Default::default() });
|
||||
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
|
||||
float.rot = rot;
|
||||
float.flip_h = flip_h;
|
||||
float.flip_v = flip_v;
|
||||
// Frame (10, 30, 40, 20), centre (30, 40): mirror, then rotate
|
||||
// clockwise in y-down group space. Do not use the routing transform
|
||||
// to compute the expectation.
|
||||
let sign = if free_start { -1.0 } else { 1.0 };
|
||||
let dx = sign * if flip_h { -20.0 } else { 20.0 };
|
||||
let dy = sign * if flip_v { -10.0 } else { 10.0 };
|
||||
let (sin, cos) = rot.to_radians().sin_cos();
|
||||
let expected = (30.0 + dx * cos - dy * sin, 40.0 + dx * sin + dy * cos);
|
||||
let kids = vec![
|
||||
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
|
||||
GroupChild { x: 10.0, y: 30.0, obj },
|
||||
];
|
||||
let mut group = group(kids);
|
||||
assert!(reroute_group(&mut group));
|
||||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||||
let c = &children[1];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||||
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
|
||||
let (free, attached) = if free_start { (start, end) } else { (end, start) };
|
||||
assert!(
|
||||
(free.0 - expected.0).abs() < 0.001 && (free.1 - expected.1).abs() < 0.001,
|
||||
"{kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {free:?} != {expected:?}"
|
||||
);
|
||||
assert_eq!(attached, (140.0, 210.0));
|
||||
assert_eq!(float.rot, 0.0);
|
||||
assert!(!reroute_group(&mut group), "rerouting must settle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerouting_preserves_transformed_free_and_unresolved_ends() {
|
||||
for kind in [ShapeKind::StraightConnector, ShapeKind::ElbowConnector, ShapeKind::CurvedConnector] {
|
||||
@@ -406,7 +369,43 @@ mod tests {
|
||||
for (flip_h, flip_v) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
for free in [None, Some(ConnEnd { id: 404, site: 0 }), Some(ConnEnd { id: 7, site: 99 })] {
|
||||
for free_start in [false, true] {
|
||||
assert_transformed_reroute(kind, rot, flip_h, flip_v, free, free_start);
|
||||
let attached = Some(ConnEnd { id: 7, site: 3 });
|
||||
let (start, end) = if free_start { (free, attached) } else { (attached, free) };
|
||||
let mut obj = shape(kind, 40.0, 20.0, ShapeExtra { start, end, ..Default::default() });
|
||||
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
|
||||
float.rot = rot;
|
||||
float.flip_h = flip_h;
|
||||
float.flip_v = flip_v;
|
||||
// Frame (10, 30, 40, 20), centre (30, 40): mirror, then rotate
|
||||
// clockwise in y-down group space. Do not use the routing transform
|
||||
// to compute the expectation.
|
||||
let sign = if free_start { -1.0 } else { 1.0 };
|
||||
let dx = sign * if flip_h { -20.0 } else { 20.0 };
|
||||
let dy = sign * if flip_v { -10.0 } else { 10.0 };
|
||||
let (sin, cos) = rot.to_radians().sin_cos();
|
||||
let expected = (30.0 + dx * cos - dy * sin, 40.0 + dx * sin + dy * cos);
|
||||
let kids = vec![
|
||||
GroupChild {
|
||||
x: 100.0,
|
||||
y: 200.0,
|
||||
obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }),
|
||||
},
|
||||
GroupChild { x: 10.0, y: 30.0, obj },
|
||||
];
|
||||
let mut group = group(kids);
|
||||
assert!(reroute_group(&mut group));
|
||||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||||
let c = &children[1];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||||
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
|
||||
let (free, attached) = if free_start { (start, end) } else { (end, start) };
|
||||
assert!(
|
||||
(free.0 - expected.0).abs() < 0.001 && (free.1 - expected.1).abs() < 0.001,
|
||||
"{kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {free:?} != {expected:?}"
|
||||
);
|
||||
assert_eq!(attached, (140.0, 210.0));
|
||||
assert_eq!(float.rot, 0.0);
|
||||
assert!(!reroute_group(&mut group), "rerouting must settle");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,6 +313,39 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-case shape/geometry of one scaled-group reroute case.
|
||||
struct ScaledGroupSpec {
|
||||
w: f32,
|
||||
h: f32,
|
||||
kind: ShapeKind,
|
||||
rot: f32,
|
||||
flip_h: bool,
|
||||
flip_v: bool,
|
||||
group: InlineObject,
|
||||
}
|
||||
|
||||
impl ScaledGroupSpec {
|
||||
fn new(w: f32, h: f32, kind: ShapeKind, rot: f32, flip_h: bool, flip_v: bool) -> Self {
|
||||
let group = InlineObject::Group {
|
||||
w,
|
||||
h,
|
||||
ch_w: 200.0,
|
||||
ch_h: 300.0,
|
||||
float: Float { rot: 25.0, flip_h: true, ..Default::default() },
|
||||
canvas: None,
|
||||
children: vec![
|
||||
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, ShapeExtra { id: 7, ..Default::default() }, Float::default()) },
|
||||
GroupChild {
|
||||
x: 10.0,
|
||||
y: 30.0,
|
||||
obj: shape(kind, ShapeExtra { start: None, end: None, ..Default::default() }, Float { rot, flip_h, flip_v, ..Default::default() }),
|
||||
},
|
||||
],
|
||||
};
|
||||
Self { w, h, kind, rot, flip_h, flip_v, group }
|
||||
}
|
||||
}
|
||||
|
||||
fn visual_ends(group: &InlineObject) -> ((f32, f32), (f32, f32)) {
|
||||
let InlineObject::Group { w, h, float, .. } = group else { panic!("group") };
|
||||
let rects = group.group_rects(0.0, 0.0, *w, *h);
|
||||
@@ -328,12 +361,12 @@ mod tests {
|
||||
}
|
||||
|
||||
/// One scaled-group case: rerouting keeps the visual free end and the resolved sites.
|
||||
fn assert_scaled_group_reroute(w: f32, h: f32, kind: ShapeKind, rot: f32, flip_h: bool, flip_v: bool, free: Option<ConnEnd>, free_start: bool) {
|
||||
fn assert_scaled_group_reroute(spec: ScaledGroupSpec, free: Option<ConnEnd>, free_start: bool) {
|
||||
let attached = Some(ConnEnd { id: 7, site: 3 });
|
||||
let (start, end) = if free_start { (free, attached) } else { (attached, free) };
|
||||
let group = InlineObject::Group {
|
||||
w,
|
||||
h,
|
||||
w: spec.w,
|
||||
h: spec.h,
|
||||
ch_w: 200.0,
|
||||
ch_h: 300.0,
|
||||
float: Float { rot: 25.0, flip_h: true, ..Default::default() },
|
||||
@@ -343,7 +376,7 @@ mod tests {
|
||||
GroupChild {
|
||||
x: 10.0,
|
||||
y: 30.0,
|
||||
obj: shape(kind, ShapeExtra { start, end, ..Default::default() }, Float { rot, flip_h, flip_v, ..Default::default() }),
|
||||
obj: shape(spec.kind, ShapeExtra { start, end, ..Default::default() }, Float { rot: spec.rot, flip_h: spec.flip_h, flip_v: spec.flip_v, ..Default::default() }),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -359,7 +392,8 @@ mod tests {
|
||||
let actual = if free_start { after.0 } else { after.1 };
|
||||
assert!(
|
||||
(actual.0 - expected.0).abs() < 0.001 && (actual.1 - expected.1).abs() < 0.001,
|
||||
"{w}x{h}, {kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {actual:?} != {expected:?}"
|
||||
"{}x{}, {:?}, {}, {}, {}, {}: {:?} != {:?}",
|
||||
spec.w, spec.h, spec.kind, spec.rot, spec.flip_h, spec.flip_v, free_start, actual, expected
|
||||
);
|
||||
let InlineObject::Group { children, .. } = group else { panic!("group") };
|
||||
let c = &children[1];
|
||||
@@ -369,7 +403,6 @@ mod tests {
|
||||
assert_eq!(float.rot, 0.0);
|
||||
assert!(!reroute(&mut s), "rerouting must settle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerouting_scaled_group_preserves_visual_free_and_unresolved_ends() {
|
||||
for (w, h) in [(200.0, 300.0), (400.0, 600.0), (400.0, 150.0)] {
|
||||
@@ -378,7 +411,7 @@ mod tests {
|
||||
for (flip_h, flip_v) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
for free in [None, Some(ConnEnd { id: 404, site: 0 }), Some(ConnEnd { id: 7, site: 99 })] {
|
||||
for free_start in [false, true] {
|
||||
assert_scaled_group_reroute(w, h, kind, rot, flip_h, flip_v, free, free_start);
|
||||
assert_scaled_group_reroute(ScaledGroupSpec::new(w, h, kind, rot, flip_h, flip_v), free, free_start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,31 +356,6 @@ pub enum Piece {
|
||||
|
||||
/// Read Replace with text: `^` codes (unless `literal`), `\1`–`\9` with wildcards, and with a
|
||||
/// regex also `$n`, `${n}`, `${name}`, `$name`, `$$`, `\n` (paragraph mark), `\t` and `\\`.
|
||||
/// Pushes the pending literal text, if any.
|
||||
fn flush_literal(cur: &mut String, out: &mut Vec<Piece>) {
|
||||
if !cur.is_empty() {
|
||||
out.push(Piece::Lit(std::mem::take(cur)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The `^` code whose letter is `chars[i]` (`^&`, `^c`, a replace code or a numeric one): pushes
|
||||
/// its piece and returns the characters it used, or `None` when it isn't a code.
|
||||
fn caret_code(chars: &[char], i: usize, cur: &mut String, out: &mut Vec<Piece>) -> Option<usize> {
|
||||
let &n = chars.get(i)?;
|
||||
if n == '&' || n == 'c' {
|
||||
flush_literal(cur, out);
|
||||
out.push(if n == '&' { Piece::Found } else { Piece::Clip });
|
||||
return Some(1);
|
||||
}
|
||||
if let Some(ch) = replace_code(n) {
|
||||
cur.push(ch);
|
||||
return Some(1);
|
||||
}
|
||||
let (ch, used) = numeric_code(chars, i)?;
|
||||
cur.push(ch);
|
||||
Some(used)
|
||||
}
|
||||
|
||||
pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
|
||||
// With a regex, `special: false` turns off only the `^` codes; group references still expand.
|
||||
if o.literal && !o.regex {
|
||||
@@ -389,6 +364,11 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
|
||||
let chars: Vec<char> = with.chars().collect();
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let flush = |cur: &mut String, out: &mut Vec<Piece>| {
|
||||
if !cur.is_empty() {
|
||||
out.push(Piece::Lit(std::mem::take(cur)));
|
||||
}
|
||||
};
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
i += 1;
|
||||
@@ -399,7 +379,7 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
|
||||
i += used;
|
||||
}
|
||||
Some((Ref::Group(pc), used)) => {
|
||||
flush_literal(&mut cur, &mut out);
|
||||
flush(&mut cur, &mut out);
|
||||
out.extend(pc);
|
||||
i += used;
|
||||
}
|
||||
@@ -411,7 +391,7 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
|
||||
if c == '\\' && o.wildcards {
|
||||
match chars.get(i) {
|
||||
Some(d) if d.is_ascii_digit() && *d != '0' => {
|
||||
flush_literal(&mut cur, &mut out);
|
||||
flush(&mut cur, &mut out);
|
||||
out.push(Piece::Group(d.to_digit(10).unwrap_or(0) as usize));
|
||||
i += 1;
|
||||
continue;
|
||||
@@ -426,14 +406,28 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
|
||||
}
|
||||
if c == '^'
|
||||
&& !o.literal
|
||||
&& let Some(used) = caret_code(&chars, i, &mut cur, &mut out)
|
||||
&& let Some(&n) = chars.get(i)
|
||||
{
|
||||
i += used;
|
||||
continue;
|
||||
if n == '&' || n == 'c' {
|
||||
flush(&mut cur, &mut out);
|
||||
out.push(if n == '&' { Piece::Found } else { Piece::Clip });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if let Some(ch) = replace_code(n) {
|
||||
cur.push(ch);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if let Some((ch, used)) = numeric_code(&chars, i) {
|
||||
cur.push(ch);
|
||||
i += used;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
cur.push(c);
|
||||
}
|
||||
flush_literal(&mut cur, &mut out);
|
||||
flush(&mut cur, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -401,19 +401,9 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool {
|
||||
if let Some(rows) = column_rows(s) {
|
||||
for (a, b) in rows {
|
||||
let Some(p) = s.doc.para_at(&a) else { continue };
|
||||
if a == b || (a.off == 0 && b.off == p.len()) {
|
||||
seen = true;
|
||||
if !matches(p.props.style.as_deref(), &p.mark) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (r, c) in p.run_ranges() {
|
||||
if r.end > a.off && r.start < b.off {
|
||||
seen = true;
|
||||
if !matches(p.props.style.as_deref(), c) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let mark = a == b || (a.off == 0 && b.off == p.len());
|
||||
if !span_matches(p, mark, a.off, b.off, &matches, &mut seen) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if seen {
|
||||
@@ -426,19 +416,9 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool {
|
||||
let from = if path == a.path { a.off } else { 0 };
|
||||
let to = if path == b.path { b.off } else { p.len() };
|
||||
// Match format_range: fully selected paragraphs include their mark.
|
||||
if to == p.len() && (from == 0 || path != a.path) {
|
||||
seen = true;
|
||||
if !matches(p.props.style.as_deref(), &p.mark) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (r, c) in p.run_ranges() {
|
||||
if r.end > from && r.start < to {
|
||||
seen = true;
|
||||
if !matches(p.props.style.as_deref(), c) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let mark = to == p.len() && (from == 0 || path != a.path);
|
||||
if !span_matches(p, mark, from, to, &matches, &mut seen) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -446,6 +426,34 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool {
|
||||
seen || resolved(s).iter().all(|r| r.highlight == h.rgb())
|
||||
}
|
||||
|
||||
/// Whether the paragraph's mark (when `mark`) and the runs overlapping `from..to` all match
|
||||
/// `matches`; `seen` records that something was checked.
|
||||
fn span_matches(
|
||||
p: &wordcraft_doc::Paragraph,
|
||||
mark: bool,
|
||||
from: usize,
|
||||
to: usize,
|
||||
matches: &impl Fn(Option<&str>, &CharProps) -> bool,
|
||||
seen: &mut bool,
|
||||
) -> bool {
|
||||
let style = p.props.style.as_deref();
|
||||
if mark {
|
||||
*seen = true;
|
||||
if !matches(style, &p.mark) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (r, c) in p.run_ranges() {
|
||||
if r.end > from && r.start < to {
|
||||
*seen = true;
|
||||
if !matches(style, c) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Explicit palette/automation colours set highlighting; the shortcut (no colour) toggles
|
||||
/// the last choice off only when the whole formatting target already has that colour.
|
||||
fn highlight(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|
||||
@@ -460,6 +460,51 @@ pub(crate) fn move_shown(s: &mut Session, up: bool) -> Option<CmdResult> {
|
||||
Some(sel_result(s))
|
||||
}
|
||||
|
||||
/// Keeps collapsed headings collapsed after an edit: each one moves to the nearest heading with the
|
||||
/// same text that is still free.
|
||||
fn remap_collapsed_headings(s: &mut Session) {
|
||||
let hashes: Vec<Option<u64>> = (0..s.doc.body.len()).map(|i| text_hash(&s.doc, i)).collect();
|
||||
let levels = ol::levels(&s.doc);
|
||||
// The headings not yet taken, by text hash.
|
||||
let mut by_hash: HashMap<u64, BTreeSet<usize>> = HashMap::new();
|
||||
for (j, h) in hashes.iter().enumerate() {
|
||||
if let Some(h) = h
|
||||
&& levels.get(j).is_some_and(|l| *l < BODY)
|
||||
{
|
||||
by_hash.entry(*h).or_default().insert(j);
|
||||
}
|
||||
}
|
||||
let mut used = BTreeSet::new();
|
||||
let mut out = Vec::new();
|
||||
for (i, h) in std::mem::take(&mut s.view.outline.collapsed) {
|
||||
let free = |j: usize, used: &BTreeSet<usize>| levels.get(j).is_some_and(|l| *l < BODY) && !used.contains(&j);
|
||||
let same = |j: usize| hashes.get(j).copied().flatten() == Some(h);
|
||||
let found = if free(i, &used) && same(i) {
|
||||
Some(i)
|
||||
} else {
|
||||
// The nearest free heading with the same text (the earlier one on a tie).
|
||||
let nearest = by_hash.get(&h).and_then(|set| {
|
||||
let below = set.range(..=i).next_back().copied();
|
||||
let above = set.range(i..).next().copied();
|
||||
match (below, above) {
|
||||
(Some(b), Some(a)) => Some(if a.abs_diff(i) < b.abs_diff(i) { a } else { b }),
|
||||
(b, a) => b.or(a),
|
||||
}
|
||||
});
|
||||
nearest.or_else(|| free(i, &used).then_some(i))
|
||||
};
|
||||
if let Some(j) = found {
|
||||
used.insert(j);
|
||||
if let Some(set) = hashes.get(j).copied().flatten().and_then(|hj| by_hash.get_mut(&hj)) {
|
||||
set.remove(&j);
|
||||
}
|
||||
out.push((j, hashes.get(j).copied().flatten().unwrap_or(h)));
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
s.view.outline.collapsed = out;
|
||||
}
|
||||
|
||||
/// After every command: collapsed headings are found again where edits moved them (by their
|
||||
/// text; one whose text changed in place stays collapsed), and in Outline view a caret the
|
||||
/// outline doesn't show (in collapsed text, or past a body paragraph's first line) moves on to
|
||||
@@ -467,46 +512,7 @@ pub(crate) fn move_shown(s: &mut Session, up: bool) -> Option<CmdResult> {
|
||||
/// `changed` says whether the document changed (its revision moved); if not, nothing moved.
|
||||
pub(crate) fn after_command(s: &mut Session, before: &Selection, changed: bool) {
|
||||
if changed && !s.view.outline.collapsed.is_empty() {
|
||||
let hashes: Vec<Option<u64>> = (0..s.doc.body.len()).map(|i| text_hash(&s.doc, i)).collect();
|
||||
let levels = ol::levels(&s.doc);
|
||||
// The headings not yet taken, by text hash.
|
||||
let mut by_hash: HashMap<u64, BTreeSet<usize>> = HashMap::new();
|
||||
for (j, h) in hashes.iter().enumerate() {
|
||||
if let Some(h) = h
|
||||
&& levels.get(j).is_some_and(|l| *l < BODY)
|
||||
{
|
||||
by_hash.entry(*h).or_default().insert(j);
|
||||
}
|
||||
}
|
||||
let mut used = BTreeSet::new();
|
||||
let mut out = Vec::new();
|
||||
for (i, h) in std::mem::take(&mut s.view.outline.collapsed) {
|
||||
let free = |j: usize, used: &BTreeSet<usize>| levels.get(j).is_some_and(|l| *l < BODY) && !used.contains(&j);
|
||||
let same = |j: usize| hashes.get(j).copied().flatten() == Some(h);
|
||||
let found = if free(i, &used) && same(i) {
|
||||
Some(i)
|
||||
} else {
|
||||
// The nearest free heading with the same text (the earlier one on a tie).
|
||||
let nearest = by_hash.get(&h).and_then(|set| {
|
||||
let below = set.range(..=i).next_back().copied();
|
||||
let above = set.range(i..).next().copied();
|
||||
match (below, above) {
|
||||
(Some(b), Some(a)) => Some(if a.abs_diff(i) < b.abs_diff(i) { a } else { b }),
|
||||
(b, a) => b.or(a),
|
||||
}
|
||||
});
|
||||
nearest.or_else(|| free(i, &used).then_some(i))
|
||||
};
|
||||
if let Some(j) = found {
|
||||
used.insert(j);
|
||||
if let Some(set) = hashes.get(j).copied().flatten().and_then(|hj| by_hash.get_mut(&hj)) {
|
||||
set.remove(&j);
|
||||
}
|
||||
out.push((j, hashes.get(j).copied().flatten().unwrap_or(h)));
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
s.view.outline.collapsed = out;
|
||||
remap_collapsed_headings(s);
|
||||
}
|
||||
if s.view.mode != ViewMode::Outline || !s.sel.is_collapsed() || s.sel.focus.story != StoryRef::Body {
|
||||
return;
|
||||
|
||||
@@ -606,6 +606,44 @@ fn resolve_sel_then(s: &mut Session, v: &Value, accept: bool) -> CmdResult {
|
||||
resolve_sel(s, accept, p::bool(v, "next") == Some(true))
|
||||
}
|
||||
|
||||
/// The span of the tracked change around `off` in `p`: its run, joined across the comment anchors
|
||||
/// that split one revision's text into several runs.
|
||||
fn revision_span(p: &Paragraph, off: usize) -> Option<std::ops::Range<usize>> {
|
||||
let runs: Vec<_> = p.run_ranges().collect();
|
||||
let i = runs.iter().position(|(r, c)| r.start <= off && off <= r.end && (c.ins.is_some() || c.del.is_some() || c.fmt_change.is_some()))?;
|
||||
let (mut start, mut end) = (runs[i].0.start, runs[i].0.end);
|
||||
let key = (runs[i].1.ins, runs[i].1.del);
|
||||
if key == (None, None) {
|
||||
return Some(start..end);
|
||||
}
|
||||
// Comment anchors split a revision's text into several runs; the revision goes on past them.
|
||||
let is_anchor = |k: usize| matches!(p.object_at(runs[k].0.start), Some(InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. }));
|
||||
let same = |k: usize| (runs[k].1.ins, runs[k].1.del) == key;
|
||||
let mut k = i;
|
||||
while k > 0 {
|
||||
if same(k - 1) {
|
||||
k -= 1;
|
||||
} else if is_anchor(k - 1) && k > 1 && same(k - 2) {
|
||||
k -= 2;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
start = runs[k].0.start;
|
||||
}
|
||||
let mut k = i;
|
||||
while k + 1 < runs.len() {
|
||||
if same(k + 1) {
|
||||
k += 1;
|
||||
} else if is_anchor(k + 1) && k + 2 < runs.len() && same(k + 2) {
|
||||
k += 2;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
end = runs[k].0.end;
|
||||
}
|
||||
Some(start..end)
|
||||
}
|
||||
|
||||
fn resolve_sel(s: &mut Session, accept: bool, next: bool) -> CmdResult {
|
||||
let (a, b) = s.sel.ordered();
|
||||
// A caret right before a tracked paragraph mark (and no tracked text around it): that mark.
|
||||
@@ -616,44 +654,7 @@ fn resolve_sel(s: &mut Session, accept: bool, next: bool) -> CmdResult {
|
||||
let (a, b) = if a == b {
|
||||
// The change at the caret: the run around it, else the paragraph mark right after it,
|
||||
// else the paragraph's formatting, else the table cell's.
|
||||
let found = s.doc.para_at(&a).and_then(|p| {
|
||||
let runs: Vec<_> = p.run_ranges().collect();
|
||||
let i = runs
|
||||
.iter()
|
||||
.position(|(r, c)| r.start <= a.off && a.off <= r.end && (c.ins.is_some() || c.del.is_some() || c.fmt_change.is_some()))?;
|
||||
let (mut start, mut end) = (runs[i].0.start, runs[i].0.end);
|
||||
let key = (runs[i].1.ins, runs[i].1.del);
|
||||
if key == (None, None) {
|
||||
return Some(start..end);
|
||||
}
|
||||
// Comment anchors split a revision's text into several runs; the revision goes on past them.
|
||||
let is_anchor =
|
||||
|k: usize| matches!(p.object_at(runs[k].0.start), Some(InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. }));
|
||||
let same = |k: usize| (runs[k].1.ins, runs[k].1.del) == key;
|
||||
let mut k = i;
|
||||
while k > 0 {
|
||||
if same(k - 1) {
|
||||
k -= 1;
|
||||
} else if is_anchor(k - 1) && k > 1 && same(k - 2) {
|
||||
k -= 2;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
start = runs[k].0.start;
|
||||
}
|
||||
let mut k = i;
|
||||
while k + 1 < runs.len() {
|
||||
if same(k + 1) {
|
||||
k += 1;
|
||||
} else if is_anchor(k + 1) && k + 2 < runs.len() && same(k + 2) {
|
||||
k += 2;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
end = runs[k].0.end;
|
||||
}
|
||||
Some(start..end)
|
||||
});
|
||||
let found = s.doc.para_at(&a).and_then(|p| revision_span(p, a.off));
|
||||
let para_fmt = s.doc.para_at(&a).is_some_and(|p| p.props.fmt_change.is_some() || p.props.num_change.is_some());
|
||||
let mark_fmt = s.doc.para_at(&a).is_some_and(|p| a.off == p.len() && p.mark.fmt_change.is_some());
|
||||
match found {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Installed system fonts: found by family name by every lookup, whatever ran before it (#22).
|
||||
#[allow(unused_imports)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Right-to-left and bidirectional layout: Persian paragraphs, mixed Persian/Latin lines,
|
||||
//! numbers and punctuation, caret and selection geometry.
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
use crate::para::{bidi_levels, visual_order};
|
||||
use wordcraft_doc::props::{Align, ParaProps};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Document grid (`w:docGrid`): line pitch and character pitch (#391). Assertions are relations
|
||||
//! to the pitch, never font metrics (fonts differ between machines; CJK glyphs may be missing).
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
use wordcraft_doc::props::LineSpacing;
|
||||
use wordcraft_doc::section::{DocGrid, DocGridType};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Text boxes' text direction, alignment and linked boxes (Shape Format › Text).
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
use wordcraft_doc::props::{TextVert, VAlign};
|
||||
use wordcraft_doc::{PartKind, Pos, TextBody, para_block};
|
||||
|
||||
Reference in New Issue
Block a user