fix(lists): propagate checkbox state to ancestor list items on toggle
Toggling/cycling/rejecting a nested checkbox now recomputes each ancestor's progress marker (vimwiki's listsyms_propagate). Mirrors vimwiki's averaging — rejected items count as 100% unless every child is rejected, in which case the parent itself becomes rejected. Walks up while each ancestor has its own checkbox. Also tightens find_checkbox_span to scan only the marker line, since a parent item's span covers its nested sublist and was matching child markers as if they belonged to the parent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -231,8 +231,12 @@ fn list_checkbox(
|
||||
};
|
||||
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
|
||||
let (line, col) = nav::lsp_to_byte_pos(p.position, &doc.text, utf8);
|
||||
let propagate = backend
|
||||
.wiki_for_uri(&p.uri)
|
||||
.map(|w| w.config.listsyms_propagate)
|
||||
.unwrap_or(true);
|
||||
Ok(ops::checkbox_edit(
|
||||
&doc.text, &doc.ast, &p.uri, line, col, utf8, mutate,
|
||||
&doc.text, &doc.ast, &p.uri, line, col, utf8, mutate, propagate,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1216,6 +1220,7 @@ pub mod ops {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn checkbox_edit(
|
||||
text: &str,
|
||||
ast: &DocumentNode,
|
||||
@@ -1224,9 +1229,11 @@ pub mod ops {
|
||||
col: u32,
|
||||
utf8: bool,
|
||||
mutate: fn(&str) -> Option<&'static str>,
|
||||
propagate: bool,
|
||||
) -> Option<WorkspaceEdit> {
|
||||
let item = find_list_item_at(ast, line, col)?;
|
||||
let cb_span = find_checkbox_span(text, item.span)?;
|
||||
let path = find_path_to_item(ast, line, col)?;
|
||||
let leaf = *path.last()?;
|
||||
let cb_span = find_checkbox_span(text, leaf.span)?;
|
||||
let cur = &text[cb_span.start.offset..cb_span.end.offset];
|
||||
let new = mutate(cur)?;
|
||||
let mut b = WorkspaceEditBuilder::new();
|
||||
@@ -1234,9 +1241,132 @@ pub mod ops {
|
||||
uri.clone(),
|
||||
text_edit_replace(cb_span, new.to_string(), text, utf8),
|
||||
);
|
||||
|
||||
if propagate && path.len() > 1 {
|
||||
// Vimwiki's listsyms_propagate: walk up the chain, recomputing
|
||||
// each ancestor's marker from the average rate of its immediate
|
||||
// children (using the modified child's *new* state). Stops at
|
||||
// the first ancestor that has no checkbox.
|
||||
let mut child_node: &ListItemNode = leaf;
|
||||
let mut child_marker: &str = new;
|
||||
for parent in path.iter().rev().skip(1) {
|
||||
let parent_cb_span = match find_checkbox_span(text, parent.span) {
|
||||
Some(s) => s,
|
||||
None => break,
|
||||
};
|
||||
let parent_cur = &text[parent_cb_span.start.offset..parent_cb_span.end.offset];
|
||||
let new_parent = match compute_parent_marker(parent, child_node, child_marker) {
|
||||
Some(m) => m,
|
||||
None => break,
|
||||
};
|
||||
if new_parent == parent_cur {
|
||||
break;
|
||||
}
|
||||
b.edit(
|
||||
uri.clone(),
|
||||
text_edit_replace(parent_cb_span, new_parent.to_string(), text, utf8),
|
||||
);
|
||||
child_node = parent;
|
||||
child_marker = new_parent;
|
||||
}
|
||||
}
|
||||
|
||||
Some(b.build())
|
||||
}
|
||||
|
||||
/// Convert a marker string like `"[X]"` to its progress rate. `-1`
|
||||
/// signals rejected (handled specially when averaging). Returns
|
||||
/// `None` for unrecognised markers.
|
||||
fn marker_to_rate(marker: &str) -> Option<i32> {
|
||||
match marker {
|
||||
"[ ]" => Some(0),
|
||||
"[.]" => Some(25),
|
||||
"[o]" => Some(50),
|
||||
"[O]" => Some(75),
|
||||
"[X]" => Some(100),
|
||||
"[-]" => Some(-1),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn checkbox_state_to_rate(state: CheckboxState) -> i32 {
|
||||
match state {
|
||||
CheckboxState::Empty => 0,
|
||||
CheckboxState::Quarter => 25,
|
||||
CheckboxState::Half => 50,
|
||||
CheckboxState::ThreeQuarters => 75,
|
||||
CheckboxState::Done => 100,
|
||||
CheckboxState::Rejected => -1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of `marker_to_rate`. `rate` is 0..=100 or `-1` (rejected).
|
||||
/// Mirrors vimwiki's `s:rate_to_state` for the standard 5-symbol
|
||||
/// `' .oOX'` palette.
|
||||
fn rate_to_marker(rate: i32) -> &'static str {
|
||||
if rate == -1 {
|
||||
return "[-]";
|
||||
}
|
||||
if rate <= 0 {
|
||||
return "[ ]";
|
||||
}
|
||||
if rate >= 100 {
|
||||
return "[X]";
|
||||
}
|
||||
// n=5 symbols, so n-2=3. ceil(rate/100 * 3) ∈ {1,2,3}
|
||||
let idx = ((rate as f64) / 100.0 * 3.0).ceil() as i32;
|
||||
match idx {
|
||||
1 => "[.]",
|
||||
2 => "[o]",
|
||||
_ => "[O]",
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the new marker for `parent` given that `modified_child`
|
||||
/// (one of its immediate sublist items) just became
|
||||
/// `modified_marker`. Returns `None` if the parent has no children
|
||||
/// with checkboxes — there's nothing to average from.
|
||||
///
|
||||
/// Mirrors vimwiki's `s:update_state` averaging: rejected children
|
||||
/// count as 100% for the average, but if *every* child is rejected
|
||||
/// the parent itself becomes rejected.
|
||||
fn compute_parent_marker(
|
||||
parent: &ListItemNode,
|
||||
modified_child: &ListItemNode,
|
||||
modified_marker: &str,
|
||||
) -> Option<&'static str> {
|
||||
let sub = parent.sublist.as_ref()?;
|
||||
let mut sum: i32 = 0;
|
||||
let mut count_with_cb: i32 = 0;
|
||||
let mut count_rejected: i32 = 0;
|
||||
for item in &sub.items {
|
||||
let rate = if std::ptr::eq(item, modified_child) {
|
||||
marker_to_rate(modified_marker)?
|
||||
} else {
|
||||
match item.checkbox {
|
||||
Some(state) => checkbox_state_to_rate(state),
|
||||
None => continue,
|
||||
}
|
||||
};
|
||||
count_with_cb += 1;
|
||||
if rate == -1 {
|
||||
count_rejected += 1;
|
||||
sum += 100;
|
||||
} else {
|
||||
sum += rate;
|
||||
}
|
||||
}
|
||||
if count_with_cb == 0 {
|
||||
return None;
|
||||
}
|
||||
let new_rate = if count_rejected == count_with_cb {
|
||||
-1
|
||||
} else {
|
||||
sum / count_with_cb
|
||||
};
|
||||
Some(rate_to_marker(new_rate))
|
||||
}
|
||||
|
||||
pub fn next_task(
|
||||
ast: &DocumentNode,
|
||||
uri: &Url,
|
||||
@@ -1374,6 +1504,9 @@ pub mod ops {
|
||||
|
||||
/// Find the `[X]` (or other checkbox) substring within a list item's
|
||||
/// source span. Returns a span covering exactly the 3-byte run.
|
||||
/// Searches only the marker line — a parent item's span covers its
|
||||
/// nested sublist too, and we don't want a child's checkbox to look
|
||||
/// like the parent's.
|
||||
pub fn find_checkbox_span(text: &str, item_span: Span) -> Option<Span> {
|
||||
let start = item_span.start.offset;
|
||||
let end = item_span.end.offset.min(text.len());
|
||||
@@ -1382,6 +1515,8 @@ pub mod ops {
|
||||
}
|
||||
let segment = &text[start..end];
|
||||
let bytes = segment.as_bytes();
|
||||
let line_end = bytes.iter().position(|&b| b == b'\n').unwrap_or(bytes.len());
|
||||
let bytes = &bytes[..line_end];
|
||||
let mut i = 0;
|
||||
while i + 2 < bytes.len() {
|
||||
if bytes[i] == b'['
|
||||
@@ -1444,6 +1579,50 @@ pub mod ops {
|
||||
None
|
||||
}
|
||||
|
||||
/// Return the chain of list items from outermost ancestor to the
|
||||
/// deepest item containing `line`. Used by checkbox propagation so
|
||||
/// it can update each ancestor in turn.
|
||||
pub fn find_path_to_item(
|
||||
ast: &DocumentNode,
|
||||
line: u32,
|
||||
col: u32,
|
||||
) -> Option<Vec<&ListItemNode>> {
|
||||
for block in &ast.children {
|
||||
if let Some(path) = path_in_block(block, line, col) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn path_in_block(block: &BlockNode, line: u32, col: u32) -> Option<Vec<&ListItemNode>> {
|
||||
match block {
|
||||
BlockNode::List(l) => l.items.iter().find_map(|it| path_in_item(it, line, col)),
|
||||
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
||||
children.iter().find_map(|c| path_in_block(c, line, col))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn path_in_item(item: &ListItemNode, line: u32, col: u32) -> Option<Vec<&ListItemNode>> {
|
||||
if let Some(sub) = &item.sublist {
|
||||
for sub_it in &sub.items {
|
||||
if let Some(mut deeper) = path_in_item(sub_it, line, col) {
|
||||
let mut path = Vec::with_capacity(deeper.len() + 1);
|
||||
path.push(item);
|
||||
path.append(&mut deeper);
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (item.span.start.line..=item.span.end.line).contains(&line) {
|
||||
return Some(vec![item]);
|
||||
}
|
||||
let _ = col;
|
||||
None
|
||||
}
|
||||
|
||||
// ===== Phase 15: TOC + Links generation, workspace queries =====
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -60,7 +60,7 @@ fn reject_state_toggles_dash_marker() {
|
||||
fn toggle_empty_checkbox_to_done() {
|
||||
let src = "- [ ] task\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 4, true, ops::toggle_state)
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 4, true, ops::toggle_state, true)
|
||||
.expect("toggle edit");
|
||||
let te = one_text_edit(edit);
|
||||
assert_eq!(te.new_text, "[X]");
|
||||
@@ -72,7 +72,7 @@ fn toggle_empty_checkbox_to_done() {
|
||||
fn cycle_advances_partial_states() {
|
||||
let src = "- [.] half\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::cycle_state)
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::cycle_state, true)
|
||||
.expect("cycle edit");
|
||||
let te = one_text_edit(edit);
|
||||
assert_eq!(te.new_text, "[o]");
|
||||
@@ -82,7 +82,7 @@ fn cycle_advances_partial_states() {
|
||||
fn reject_replaces_empty_with_dash() {
|
||||
let src = "* [ ] thing\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::reject_state)
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::reject_state, true)
|
||||
.expect("reject edit");
|
||||
let te = one_text_edit(edit);
|
||||
assert_eq!(te.new_text, "[-]");
|
||||
@@ -93,7 +93,7 @@ fn checkbox_edit_returns_none_on_plain_list_item() {
|
||||
// No `[…]` at all → no edit.
|
||||
let src = "- plain item\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::toggle_state);
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::toggle_state, true);
|
||||
assert!(edit.is_none());
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ fn checkbox_edit_returns_none_on_plain_list_item() {
|
||||
fn checkbox_edit_returns_none_off_list() {
|
||||
let src = "just a paragraph\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::toggle_state);
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::toggle_state, true);
|
||||
assert!(edit.is_none());
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ fn checkbox_edit_finds_item_in_sublist() {
|
||||
let src = "- top\n - [ ] nested\n";
|
||||
let doc = parse(src);
|
||||
// Cursor on line 1, the nested item.
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 1, 5, true, ops::toggle_state)
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 1, 5, true, ops::toggle_state, true)
|
||||
.expect("edit on nested item");
|
||||
let te = one_text_edit(edit);
|
||||
assert_eq!(te.new_text, "[X]");
|
||||
@@ -123,7 +123,189 @@ fn checkbox_edit_finds_item_in_sublist() {
|
||||
fn checkbox_edit_uses_changes_map_not_document_changes() {
|
||||
let src = "- [ ] task\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 4, true, ops::toggle_state).unwrap();
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 4, true, ops::toggle_state, true).unwrap();
|
||||
assert!(doc_changes_for(edit.clone()).is_none());
|
||||
assert!(edit.changes.is_some());
|
||||
}
|
||||
|
||||
// ===== parent propagation (vimwiki listsyms_propagate) =====
|
||||
|
||||
/// Collect the (line, new_text) pairs for every TextEdit in the result,
|
||||
/// sorted by line. Lets propagation tests assert the parent edit
|
||||
/// without caring about edit ordering.
|
||||
fn edits_by_line(edit: tower_lsp::lsp_types::WorkspaceEdit) -> Vec<(u32, String)> {
|
||||
let changes = edit.changes.expect("expected changes map");
|
||||
let (_, edits) = changes.into_iter().next().expect("at least one uri");
|
||||
let mut pairs: Vec<(u32, String)> = edits
|
||||
.into_iter()
|
||||
.map(|e| (e.range.start.line, e.new_text))
|
||||
.collect();
|
||||
pairs.sort_by_key(|(line, _)| *line);
|
||||
pairs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_one_of_four_done_marks_parent_quarter() {
|
||||
// Toggle the first child of a 4-child parent. Parent should land at
|
||||
// [.] (Quarter, 25%).
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [ ] sub1
|
||||
- [ ] sub2
|
||||
- [ ] sub3
|
||||
- [ ] sub4
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 1, 6, true, ops::toggle_state, true)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[.]".into()), (1, "[X]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_two_of_four_done_marks_parent_half() {
|
||||
// sub1 is already done; toggling sub2 → done leaves rate=50% → [o].
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [X] sub1
|
||||
- [ ] sub2
|
||||
- [ ] sub3
|
||||
- [ ] sub4
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 2, 6, true, ops::toggle_state, true)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[o]".into()), (2, "[X]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_three_of_four_done_marks_parent_three_quarters() {
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [X] sub1
|
||||
- [X] sub2
|
||||
- [ ] sub3
|
||||
- [ ] sub4
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 3, 6, true, ops::toggle_state, true)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[O]".into()), (3, "[X]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_all_done_marks_parent_done() {
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [X] sub1
|
||||
- [X] sub2
|
||||
- [X] sub3
|
||||
- [ ] sub4
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 4, 6, true, ops::toggle_state, true)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[X]".into()), (4, "[X]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_untoggle_drops_parent_back() {
|
||||
// Untoggling the only completed child should put parent back to [ ].
|
||||
let src = "\
|
||||
- [.] root
|
||||
- [X] sub1
|
||||
- [ ] sub2
|
||||
- [ ] sub3
|
||||
- [ ] sub4
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 1, 6, true, ops::toggle_state, true)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[ ]".into()), (1, "[ ]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_disabled_only_emits_leaf_edit() {
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [ ] sub1
|
||||
- [ ] sub2
|
||||
- [ ] sub3
|
||||
- [ ] sub4
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 1, 6, true, ops::toggle_state, false)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(1, "[X]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_skips_parent_without_checkbox() {
|
||||
// Parent has no `[…]`, so propagation has nothing to update.
|
||||
let src = "\
|
||||
- root
|
||||
- [ ] sub1
|
||||
- [ ] sub2
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 1, 6, true, ops::toggle_state, true)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(1, "[X]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_walks_multiple_levels() {
|
||||
// Toggle the deepest leaf: grandparent has 1 child (parent), parent
|
||||
// has 2 children. Sub1 done → parent half → grandparent half.
|
||||
let src = "\
|
||||
- [ ] grand
|
||||
- [ ] parent
|
||||
- [ ] sub1
|
||||
- [ ] sub2
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 2, 8, true, ops::toggle_state, true)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(
|
||||
pairs,
|
||||
vec![(0, "[o]".into()), (1, "[o]".into()), (2, "[X]".into())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_all_rejected_marks_parent_rejected() {
|
||||
// Every counted child is rejected → parent becomes [-].
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [-] sub1
|
||||
- [ ] sub2
|
||||
";
|
||||
let doc = parse(src);
|
||||
// Reject sub2 so both children are rejected.
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 2, 6, true, ops::reject_state, true)
|
||||
.expect("reject edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[-]".into()), (2, "[-]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_rejected_sibling_counts_as_done_for_average() {
|
||||
// sub1 rejected, sub2 toggled to done → both "complete" → parent [X].
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [-] sub1
|
||||
- [ ] sub2
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 2, 6, true, ops::toggle_state, true)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[X]".into()), (2, "[X]".into())]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user