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;
|
||||
|
||||
Reference in New Issue
Block a user