things-to-remember checklist
git clone https://git.lucas.co/cce-list.git
feat: lossless list files, and a Vault tasks view (Obsidian-on-cce M5)
Lossless: a list file is any Markdown note. Task lines keep how they were
written (indent, */+/1. bullets, custom statuses like [/]), and every
other line -- headings, prose, blanks, fenced code -- is kept verbatim
with the task below it (Item::before) or after the last (trailer). An
untouched file saves back byte for byte. Plain lines are no longer
adopted as items, which turned a heading into a checkbox on the next
save. Deleting a task (remove_item, and the sync's retain_items) hands
its kept lines to the next one. An #[ignore]d test round-trips the lists
on this machine (`cargo test -p cce-list -- --ignored`).
Vault tasks: with a vault configured, the switcher lists every open task
in it, grouped by note. A tick writes the checkbox back through
cce-vault (which re-reads the file first); a task ticked in the view
stays, struck through, until it is left. A note's name opens it in
cce-notes; typing adds a task to today's daily note.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cargo.toml | 1 +
src/bin/sync.rs | 20 ++--
src/lib.rs | 323 +++++++++++++++++++++++++++++++++++++++++++----------
src/main.rs | 187 +++++++++++++++++++++++++++----
src/vault_tasks.rs | 165 +++++++++++++++++++++++++++
5 files changed, 604 insertions(+), 92 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index f6a47c3..9239824 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,6 +16,7 @@ serde_json = "1"
reqwest = { version = "0.12", features = ["blocking", "json"] }
roxmltree = "0.20"
chrono = "0.4"
+cce-vault = { git = "https://github.com/lsgalante/cce-vault.git", rev = "11a26a5f8e1ff1ac55a951d88afc751ab68a5aba" }
keyring = { version = "3", features = ["sync-secret-service"] }
# The desktop checklist app; src/bin/sync.rs adds the cce-list-sync helper,
diff --git a/src/bin/sync.rs b/src/bin/sync.rs
index 444e5c3..9fad50b 100644
--- a/src/bin/sync.rs
+++ b/src/bin/sync.rs
@@ -338,10 +338,11 @@ fn run_sync(backend: &Backend, dry_run: bool, force_deletes: bool) -> Result<(),
let mut fresh = cce_list::load_list(&file.title).unwrap_or_else(|_| ListFile {
title: file.title.clone(),
id: Some(list_id.clone()),
- items: Vec::new(),
+ ..Default::default()
});
fresh.id = Some(list_id.clone());
- apply_local(&mut fresh.items, &plan, &todos, &created, &moved_from);
+ let orphans = apply_local(&mut fresh.items, &plan, &todos, &created, &moved_from);
+ fresh.trailer.splice(0..0, orphans);
save_list(&fresh).map_err(|e| e.to_string())?;
}
@@ -621,7 +622,7 @@ fn apply_list_plan(
for id in &plan.pull_new {
let r = &remote.lists[id];
let title = unique_local_title(&r.title, local);
- let file = ListFile { title, id: Some(id.clone()), items: Vec::new() };
+ let file = ListFile { title, id: Some(id.clone()), ..Default::default() };
save_list(&file).map_err(|e| e.to_string())?;
local.push(file);
state.lists.insert(
@@ -818,8 +819,10 @@ fn apply_local(
remote: &BTreeMap<String, &RemoteTodo>,
created: &[(String, Option<String>, String)],
moved_from: &[(String, String)],
-) {
- items.retain(|i| {
+) -> Vec<String> {
+ // Lines kept above a deleted row move to the next row; any left with
+ // no row after them go back to the caller for the trailer.
+ let orphans = cce_list::retain_items(items, |i| {
i.uid.as_deref().is_none_or(|u| !plan.pull_deletes.iter().any(|d| d == u))
});
for uid in &plan.pull_updates {
@@ -843,8 +846,9 @@ fn apply_local(
}
for uid in &plan.pull_new {
let todo = remote[uid];
- items.push(Item { text: todo.summary.clone(), done: todo.done, uid: Some(uid.clone()) });
+ items.push(Item { text: todo.summary.clone(), done: todo.done, uid: Some(uid.clone()), ..Default::default() });
}
+ orphans
}
// ── Accounts ──────────────────────────────────────────────────────────────
@@ -1609,7 +1613,7 @@ mod tests {
use super::*;
fn item(text: &str, done: bool, uid: Option<&str>) -> Item {
- Item { text: text.into(), done, uid: uid.map(String::from) }
+ Item { text: text.into(), done, uid: uid.map(String::from), ..Default::default() }
}
fn todo(summary: &str, done: bool, etag: &str) -> RemoteTodo {
@@ -1647,7 +1651,7 @@ mod tests {
}
fn lfile(title: &str, id: Option<&str>) -> ListFile {
- ListFile { title: title.into(), id: id.map(String::from), items: Vec::new() }
+ ListFile { title: title.into(), id: id.map(String::from), ..Default::default() }
}
fn refs<'a>(m: &'a BTreeMap<String, RemoteTodo>) -> BTreeMap<String, &'a RemoteTodo> {
diff --git a/src/lib.rs b/src/lib.rs
index 65ef573..849ca9b 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -14,24 +14,46 @@
//!
//! Before lists existed there was a single `list.md`; `load_lists` migrates
//! it on first sight (see [`migrate_legacy`]).
+//!
+//! **Reading is lossless.** A file is any Markdown note: task lines become
+//! items, keeping how they were written (indentation, `*`/`+`/`1.` bullets,
+//! a custom status such as Obsidian's `[/]`), and every other line —
+//! headings, prose, blank lines, fenced code — is kept verbatim with the
+//! task below it ([`Item::before`]) or after the last one
+//! ([`ListFile::trailer`]). A load and save of an untouched file writes it
+//! back byte for byte (bar a missing final newline), so cce-list can open
+//! an ordinary note without eating it. (It used to adopt every non-task
+//! line as an item, turning a heading into a checkbox on the next save.)
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Item {
pub text: String,
pub done: bool,
/// Server identity for synced items; None for purely local ones.
pub uid: Option<String>,
-}
-
-/// One checklist: its file stem, its server identity (if mirrored), items.
-#[derive(Debug, Clone, PartialEq, Eq)]
+ /// What came before the `[` as written — ` - `, `* `, `1. ` — when it
+ /// is not the plain `- ` a new item gets.
+ pub prefix: Option<String>,
+ /// The status between the brackets when it is neither ` ` nor `x`
+ /// (`/`, `-`, `>` …). Such an item reads as done, and keeps its mark
+ /// until it is unticked.
+ pub mark: Option<char>,
+ /// Non-task lines just above this item, verbatim, written back before
+ /// it. Deleting the item hands them to the next one ([`remove_item`]).
+ pub before: Vec<String>,
+}
+
+/// One checklist: its file stem, its server identity (if mirrored), items,
+/// and whatever non-task lines follow the last item.
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ListFile {
pub title: String,
pub id: Option<String>,
pub items: Vec<Item>,
+ pub trailer: Vec<String>,
}
pub fn data_dir() -> PathBuf {
@@ -75,29 +97,83 @@ pub fn list_path(title: &str) -> PathBuf {
// ── Markdown ──────────────────────────────────────────────────────────────
-/// Checklist lines become items; any other non-empty line is adopted as a
-/// not-done item rather than parsed around — the next save rewrites the file,
-/// so a line this reader skipped would be a line silently deleted.
-pub fn parse_items(text: &str) -> Vec<Item> {
- text.lines()
- .filter_map(|line| {
- let trimmed = line.trim();
- if trimmed.is_empty() {
- return None;
+/// A task line's parts: (prefix before `[`, status char, text after `] `).
+/// `- [ ] a`, ` * [x] b`, `1. [/] c`; the bracket must be followed by a
+/// space or end the line.
+fn task_line(line: &str) -> Option<(&str, char, &str)> {
+ let body = line.trim_start();
+ let indent = line.len() - body.len();
+ let bullet = if let Some(r) = body.strip_prefix(['-', '*', '+']) {
+ body.len() - r.len()
+ } else {
+ let digits = body.chars().take_while(char::is_ascii_digit).count();
+ let after = &body[digits..];
+ if digits == 0 || !(after.starts_with(". ") || after.starts_with(") ")) {
+ return None;
+ }
+ digits + 1
+ };
+ let rest = body[bullet..].strip_prefix(' ')?;
+ let mut chars = rest.chars();
+ if chars.next()? != '[' {
+ return None;
+ }
+ let status = chars.next()?;
+ if chars.next()? != ']' {
+ return None;
+ }
+ let after = chars.as_str();
+ let text = match after.strip_prefix(' ') {
+ Some(t) => t,
+ None if after.is_empty() => "",
+ None => return None,
+ };
+ let prefix_len = indent + bullet + 1;
+ Some((&line[..prefix_len], status, text))
+}
+
+/// Split text into items and the non-task lines after the last one. Lines
+/// inside fenced code blocks are never tasks.
+pub fn parse_body(text: &str) -> (Vec<Item>, Vec<String>) {
+ let mut items = Vec::new();
+ let mut pending: Vec<String> = Vec::new();
+ let mut fence: Option<&str> = None;
+ for line in text.lines() {
+ let t = line.trim_start();
+ if let Some(f) = fence {
+ if t.starts_with(f) {
+ fence = None;
}
- let (done, rest) = if let Some(r) = trimmed.strip_prefix("- [ ] ") {
- (false, r)
- } else if let Some(r) =
- trimmed.strip_prefix("- [x] ").or_else(|| trimmed.strip_prefix("- [X] "))
- {
- (true, r)
- } else {
- (false, trimmed)
- };
- let (text, uid) = split_uid_comment(rest);
- Some(Item { text: text.to_string(), done, uid })
- })
- .collect()
+ pending.push(line.to_string());
+ continue;
+ }
+ if t.starts_with("```") || t.starts_with("~~~") {
+ fence = Some(&t[..3]);
+ pending.push(line.to_string());
+ continue;
+ }
+ match task_line(line) {
+ Some((prefix, status, rest)) => {
+ let (text, uid) = split_uid_comment(rest);
+ let done = status != ' ';
+ items.push(Item {
+ text: text.to_string(),
+ done,
+ uid,
+ prefix: (prefix != "- ").then(|| prefix.to_string()),
+ mark: (done && status != 'x').then_some(status),
+ before: std::mem::take(&mut pending),
+ });
+ }
+ None => pending.push(line.to_string()),
+ }
+ }
+ (items, pending)
+}
+
+/// The items of a body, its trailing lines dropped (tests, the migration).
+pub fn parse_items(text: &str) -> Vec<Item> {
+ parse_body(text).0
}
/// Peel a trailing `<!-- uid:… -->` off an item's text, if present.
@@ -115,16 +191,85 @@ fn split_uid_comment(rest: &str) -> (&str, Option<String>) {
}
pub fn serialize_items(items: &[Item]) -> String {
- items
- .iter()
- .map(|i| {
- let mark = if i.done { 'x' } else { ' ' };
- match &i.uid {
- Some(uid) => format!("- [{mark}] {} <!-- uid:{uid} -->\n", i.text),
- None => format!("- [{mark}] {}\n", i.text),
+ let mut out = String::new();
+ for i in items {
+ for line in &i.before {
+ out.push_str(line);
+ out.push('\n');
+ }
+ let mark = if i.done { i.mark.unwrap_or('x') } else { ' ' };
+ let prefix = i.prefix.as_deref().unwrap_or("- ");
+ let sep = if i.text.is_empty() && i.uid.is_none() { "" } else { " " };
+ match &i.uid {
+ Some(uid) => out.push_str(&format!("{prefix}[{mark}]{sep}{} <!-- uid:{uid} -->\n", i.text)),
+ None => out.push_str(&format!("{prefix}[{mark}]{sep}{}\n", i.text)),
+ }
+ }
+ out
+}
+
+/// Remove item `i`, handing the lines kept above it to the item that
+/// follows (or the trailer), so deleting a task never deletes a heading.
+pub fn remove_item(list: &mut ListFile, i: usize) -> Item {
+ let mut item = list.items.remove(i);
+ let before = std::mem::take(&mut item.before);
+ match list.items.get_mut(i) {
+ Some(next) => {
+ let mut lines = before;
+ lines.append(&mut next.before);
+ next.before = lines;
+ }
+ None => {
+ let mut lines = before;
+ lines.append(&mut list.trailer);
+ list.trailer = lines;
+ }
+ }
+ item
+}
+
+/// `Vec::retain` for items, keeping the lines above a dropped item with
+/// the next kept one. Lines that no kept item follows are returned, for
+/// the caller to put at the front of the trailer.
+pub fn retain_items(items: &mut Vec<Item>, mut keep: impl FnMut(&Item) -> bool) -> Vec<String> {
+ let mut carried: Vec<String> = Vec::new();
+ let mut out = Vec::with_capacity(items.len());
+ for mut item in items.drain(..) {
+ if keep(&item) {
+ if !carried.is_empty() {
+ carried.append(&mut item.before);
+ item.before = std::mem::take(&mut carried);
}
- })
- .collect()
+ out.push(item);
+ } else {
+ carried.append(&mut item.before);
+ }
+ }
+ *items = out;
+ carried
+}
+
+/// A whole list file: the optional `<!-- list:ID -->` header, then the
+/// body's items and trailing lines.
+pub fn parse_file(text: &str) -> (Option<String>, Vec<Item>, Vec<String>) {
+ let mut lines = text.lines();
+ let mut first = lines.next();
+ while matches!(first, Some(l) if l.trim().is_empty()) {
+ first = lines.next();
+ }
+ if let Some(id) = first
+ .map(str::trim)
+ .and_then(|l| l.strip_prefix("<!-- list:"))
+ .and_then(|l| l.strip_suffix("-->"))
+ .map(str::trim)
+ .filter(|id| !id.is_empty())
+ {
+ let rest: Vec<&str> = lines.collect();
+ let (items, trailer) = parse_body(&rest.join("\n"));
+ return (Some(id.to_string()), items, trailer);
+ }
+ let (items, trailer) = parse_body(text);
+ (None, items, trailer)
}
/// A whole list file: the optional `<!-- list:ID -->` header, then items.
@@ -156,6 +301,15 @@ pub fn serialize_list(id: Option<&str>, items: &[Item]) -> String {
out
}
+pub fn serialize_file(list: &ListFile) -> String {
+ let mut out = serialize_list(list.id.as_deref(), &list.items);
+ for line in &list.trailer {
+ out.push_str(line);
+ out.push('\n');
+ }
+ out
+}
+
// ── Files ─────────────────────────────────────────────────────────────────
/// Every list on disk, titles sorted case-insensitively. Runs the legacy
@@ -179,8 +333,8 @@ pub fn load_lists() -> std::io::Result<Vec<ListFile>> {
continue;
};
let text = std::fs::read_to_string(&path)?;
- let (id, items) = parse_list(&text);
- out.push(ListFile { title, id, items });
+ let (id, items, trailer) = parse_file(&text);
+ out.push(ListFile { title, id, items, trailer });
}
out.sort_by_key(|l| l.title.to_lowercase());
Ok(out)
@@ -188,14 +342,14 @@ pub fn load_lists() -> std::io::Result<Vec<ListFile>> {
pub fn load_list(title: &str) -> std::io::Result<ListFile> {
let text = std::fs::read_to_string(list_path(title))?;
- let (id, items) = parse_list(&text);
- Ok(ListFile { title: safe_title(title), id, items })
+ let (id, items, trailer) = parse_file(&text);
+ Ok(ListFile { title: safe_title(title), id, items, trailer })
}
/// Write-temp-then-rename in the same directory, so a crash mid-write never
/// leaves a truncated list behind.
pub fn save_list(list: &ListFile) -> std::io::Result<()> {
- atomic_write(&list_path(&list.title), &serialize_list(list.id.as_deref(), &list.items))
+ atomic_write(&list_path(&list.title), &serialize_file(list))
}
pub fn delete_list(title: &str) -> std::io::Result<()> {
@@ -269,7 +423,7 @@ pub fn migrate_legacy() -> std::io::Result<()> {
(Some(i), Some(m)) if i != m => safe_title(i),
_ => "Tasks".to_string(),
};
- save_list(&ListFile { title, id, items })?;
+ save_list(&ListFile { title, id, items, trailer: Vec::new() })?;
}
save_current("Tasks")?;
std::fs::rename(&legacy, legacy.with_extension("md.migrated"))
@@ -377,31 +531,77 @@ mod tests {
#[test]
fn checklist_round_trips() {
let items = vec![
- Item { text: "water the plants".into(), done: false, uid: None },
- Item { text: "renew passport".into(), done: true, uid: Some("AB-12".into()) },
+ Item { text: "water the plants".into(), done: false, uid: None, ..Default::default() },
+ Item { text: "renew passport".into(), done: true, uid: Some("AB-12".into()), ..Default::default() },
];
assert_eq!(parse_items(&serialize_items(&items)), items);
}
- /// A hand-edited file must survive a load/save cycle: plain lines are
- /// adopted as items, not dropped, and `[X]` reads the same as `[x]`.
+ /// A hand-edited file survives a load/save cycle untouched: plain lines
+ /// stay lines (they are no longer adopted as items), `[X]` reads as done.
#[test]
- fn foreign_lines_are_adopted_not_dropped() {
- let parsed = parse_items("buy stamps\n- [X] call mom\n\n - [ ] indented\n");
- assert_eq!(
- parsed,
- vec![
- Item { text: "buy stamps".into(), done: false, uid: None },
- Item { text: "call mom".into(), done: true, uid: None },
- Item { text: "indented".into(), done: false, uid: None },
- ]
- );
+ fn foreign_lines_are_kept_not_adopted() {
+ let (items, trailer) = parse_body("buy stamps\n- [X] call mom\n\n - [ ] indented\n");
+ assert_eq!(items.len(), 2);
+ assert_eq!(items[0].before, ["buy stamps"]);
+ assert!(items[0].done && items[0].mark == Some('X'));
+ assert_eq!(items[1].prefix.as_deref(), Some(" - "));
+ assert_eq!(items[1].before, [""]);
+ assert!(trailer.is_empty());
+ }
+
+ #[test]
+ fn a_note_round_trips_byte_for_byte() {
+ let note = "---\ntags: [x]\n---\n# Heading\n\nSome prose.\n- [ ] open\n - [x] done nested\n* [/] in progress\n1. [ ] numbered\n- plain bullet\n- [ ]\n\n```\n- [ ] in code\n```\n## Tail\n";
+ let (id, items, trailer) = parse_file(note);
+ assert_eq!(id, None);
+ assert_eq!(items.len(), 5, "{items:?}");
+ assert_eq!(items[2].mark, Some('/'));
+ let list = ListFile { title: "n".into(), id, items, trailer };
+ assert_eq!(serialize_file(&list), note);
+ }
+
+ #[test]
+ fn deleting_a_task_keeps_the_lines_above_it() {
+ let (id, items, trailer) = parse_file("# A\n- [ ] one\n## B\n- [ ] two\nend\n");
+ let mut list = ListFile { title: "t".into(), id, items, trailer };
+ remove_item(&mut list, 1);
+ assert_eq!(serialize_file(&list), "# A\n- [ ] one\n## B\nend\n");
+ remove_item(&mut list, 0);
+ assert_eq!(serialize_file(&list), "# A\n## B\nend\n");
+ let (_, mut items, _) = parse_file("x\n- [ ] a\ny\n- [ ] b\nz\n- [ ] c\n");
+ let orphans = retain_items(&mut items, |i| i.text == "b");
+ assert_eq!(items[0].before, ["x", "y"]);
+ assert_eq!(orphans, ["z"]);
+ }
+
+ /// Every list on this machine reads and writes back unchanged. Reads
+ /// only; run by hand: `cargo test -p cce-list -- --ignored`.
+ #[test]
+ #[ignore]
+ fn real_lists_round_trip() {
+ let Ok(entries) = std::fs::read_dir(lists_dir()) else { return };
+ for e in entries.flatten() {
+ let text = std::fs::read_to_string(e.path()).unwrap();
+ let (id, items, trailer) = parse_file(&text);
+ let list = ListFile { title: String::new(), id, items, trailer };
+ let back = serialize_file(&list);
+ let want = if text.ends_with('\n') || text.is_empty() { text.clone() } else { format!("{text}\n") };
+ assert_eq!(back, want, "{}", e.path().display());
+ }
+ }
+
+ #[test]
+ fn unticking_drops_a_custom_mark() {
+ let mut items = parse_items("- [/] half\n");
+ items[0].done = false;
+ assert_eq!(serialize_items(&items), "- [ ] half\n");
}
#[test]
fn uid_comment_is_identity_not_text() {
let parsed = parse_items("- [ ] call mom <!-- uid:X-1 -->\n- [ ] literal <!-- not a uid -->\n");
- assert_eq!(parsed[0], Item { text: "call mom".into(), done: false, uid: Some("X-1".into()) });
+ assert_eq!(parsed[0], Item { text: "call mom".into(), done: false, uid: Some("X-1".into()), ..Default::default() });
// A comment that is not `uid:` stays part of the text.
assert_eq!(parsed[1].uid, None);
assert_eq!(parsed[1].text, "literal <!-- not a uid -->");
@@ -409,15 +609,16 @@ mod tests {
#[test]
fn list_header_round_trips_and_is_optional() {
- let items = vec![Item { text: "a".into(), done: false, uid: None }];
+ let items = vec![Item { text: "a".into(), done: false, uid: None, ..Default::default() }];
let text = serialize_list(Some("MDM5"), &items);
assert_eq!(parse_list(&text), (Some("MDM5".into()), items.clone()));
// No header: a hand-made file is a local-only list, first line and all.
assert_eq!(parse_list("- [ ] a\n"), (None, items));
- // A header that is not `list:` is just an adopted line.
- let (id, adopted) = parse_list("<!-- note -->\n- [ ] a\n");
+ // A header that is not `list:` is just a kept line.
+ let (id, kept) = parse_list("<!-- note -->\n- [ ] a\n");
assert_eq!(id, None);
- assert_eq!(adopted.len(), 2);
+ assert_eq!(kept.len(), 1);
+ assert_eq!(kept[0].before, ["<!-- note -->"]);
}
#[test]
diff --git a/src/main.rs b/src/main.rs
index 2763d0f..aea792d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -18,9 +18,12 @@
//! next sync tick, and the app re-reads the directory when the sync (or a
//! hand edit) changes it.
+mod vault_tasks;
+
use cce_list::{
delete_list, lists_dir, load_current, load_lists, save_current, save_list, Item, ListFile,
};
+use vault_tasks::{Row, VaultTasks};
use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
use cce_ui::scene::layout::Rect;
use cce_ui::scene::paint::{Cap, DisplayList, PaintCtx};
@@ -58,6 +61,17 @@ const WATCH_EVERY: std::time::Duration = std::time::Duration::from_secs(1);
const NEW_LIST: &str = "New list…";
const DELETE_LIST: &str = "Delete list…";
const ITEM_PLACEHOLDER: &str = "Remember to…";
+/// The switcher entry (and `current` value) for every open task in the
+/// notes vault; listed only when a vault is configured.
+const VAULT_TASKS: &str = "Vault tasks";
+const VAULT_PLACEHOLDER: &str = "Add to today's note…";
+
+/// One row as drawn: a list item, a vault task, or a vault note's name.
+struct RowView {
+ text: String,
+ done: bool,
+ header: bool,
+}
#[derive(Debug, Clone)]
enum ListMessage {
@@ -166,6 +180,10 @@ struct ListApp {
/// Outside-change detection: what the directory looked like when the
/// lists were last read, and the countdown to the next look.
disk_sig: Vec<(String, Option<std::time::SystemTime>)>,
+ /// The notes vault's tasks, when a vault is configured; `in_vault`
+ /// says the window shows them instead of a list.
+ vault: Option<VaultTasks>,
+ in_vault: bool,
/// When the directory may be re-read again. A wall clock, not an
/// accumulation of `tick`'s `dt`: `dt` is animation time, clamped to one
/// frame after an idle sleep, and a list nobody is typing into is idle —
@@ -178,14 +196,56 @@ impl ListApp {
self.lists.get(self.cur).map(|l| l.items.as_slice()).unwrap_or(&[])
}
- fn switcher_options(lists: &[ListFile]) -> Vec<String> {
- lists
+ fn vault_rows(&self) -> &[Row] {
+ match (&self.vault, self.in_vault) {
+ (Some(v), true) => &v.rows,
+ _ => &[],
+ }
+ }
+
+ fn row_count(&self) -> usize {
+ if self.in_vault { self.vault_rows().len() } else { self.items().len() }
+ }
+
+ fn rows(&self) -> Vec<RowView> {
+ if self.in_vault {
+ self.vault_rows()
+ .iter()
+ .map(|r| match r {
+ Row::Note { name, .. } => RowView { text: name.clone(), done: false, header: true },
+ Row::Task { text, done, .. } => RowView { text: text.clone(), done: *done, header: false },
+ })
+ .collect()
+ } else {
+ self.items().iter().map(|i| RowView { text: i.text.clone(), done: i.done, header: false }).collect()
+ }
+ }
+
+ fn switcher_options(&self) -> Vec<String> {
+ self.lists
.iter()
.map(|l| l.title.clone())
+ .chain(self.vault.as_ref().map(|_| VAULT_TASKS.to_string()))
.chain([NEW_LIST.to_string(), DELETE_LIST.to_string()])
.collect()
}
+ /// Show the vault's tasks.
+ fn enter_vault(&mut self) {
+ let Some(v) = self.vault.as_mut() else { return };
+ v.enter();
+ self.in_vault = true;
+ self.switcher.selected = self.lists.len();
+ if let Err(e) = save_current(VAULT_TASKS) {
+ log::error!("cce-list: saving current list: {e}");
+ }
+ self.disk_sig = disk_signature();
+ self.scroll = 0.0;
+ self.hovered_row = None;
+ self.set_mode(Mode::Items);
+ self.needs_rebuild = true;
+ }
+
/// (Re)read every list from disk. Keeps the shown list by title where it
/// still exists (the sync may have renamed or removed it), guarantees at
/// least one list, and refreshes the switcher.
@@ -198,24 +258,26 @@ impl ListApp {
}
};
if lists.is_empty() {
- let first = ListFile { title: "Tasks".to_string(), id: None, items: Vec::new() };
+ let first = ListFile { title: "Tasks".to_string(), ..Default::default() };
if let Err(e) = save_list(&first) {
log::error!("cce-list: creating the first list: {e}");
}
lists.push(first);
}
let wanted = load_current().or_else(|| self.lists.get(self.cur).map(|l| l.title.clone()));
+ let vault_wanted = wanted.as_deref() == Some(VAULT_TASKS) && self.vault.is_some();
let cur = wanted
.as_deref()
.and_then(|t| lists.iter().position(|l| l.title == t))
- .unwrap_or(0);
- if wanted.as_deref() != Some(lists[cur].title.as_str()) {
+ .unwrap_or(self.cur.min(lists.len() - 1));
+ if !vault_wanted && wanted.as_deref() != Some(lists[cur].title.as_str()) {
let _ = save_current(&lists[cur].title);
}
self.lists = lists;
self.cur = cur;
- self.switcher.options = Self::switcher_options(&self.lists);
- self.switcher.selected = cur;
+ self.in_vault = vault_wanted;
+ self.switcher.options = self.switcher_options();
+ self.switcher.selected = if self.in_vault { self.lists.len() } else { cur };
self.disk_sig = disk_signature();
self.clamp_scroll();
if let Some((px, py)) = self.pointer {
@@ -239,6 +301,7 @@ impl ListApp {
return;
}
self.cur = idx;
+ self.in_vault = false;
self.switcher.selected = idx;
if let Err(e) = save_current(&self.lists[idx].title) {
log::error!("cce-list: saving current list: {e}");
@@ -253,6 +316,7 @@ impl ListApp {
fn set_mode(&mut self, mode: Mode) {
self.mode = mode;
let placeholder = match mode {
+ Mode::Items if self.in_vault => VAULT_PLACEHOLDER.to_string(),
Mode::Items => ITEM_PLACEHOLDER.to_string(),
Mode::NamingList => "Name the new list, then Enter".to_string(),
Mode::ConfirmDelete => format!(
@@ -294,19 +358,26 @@ impl ListApp {
self.select_list(idx);
return;
}
- let list = ListFile { title: title.clone(), id: None, items: Vec::new() };
+ let list = ListFile { title: title.clone(), ..Default::default() };
if let Err(e) = save_list(&list) {
log::error!("cce-list: creating {title}: {e}");
return;
}
self.lists.push(list);
self.lists.sort_by_key(|l| l.title.to_lowercase());
- self.switcher.options = Self::switcher_options(&self.lists);
+ self.switcher.options = self.switcher_options();
let idx = self.lists.iter().position(|l| l.title == title).unwrap_or(0);
self.select_list(idx);
}
fn begin_delete(&mut self) {
+ if self.in_vault {
+ // The vault view is not a list; there is nothing to delete.
+ self.set_mode(Mode::Items);
+ self.input_box.set_placeholder("Pick a list to delete it");
+ self.needs_rebuild = true;
+ return;
+ }
if self.lists.len() <= 1 {
// The server keeps a default list too; one is the floor.
self.set_mode(Mode::Items);
@@ -328,7 +399,7 @@ impl ListApp {
return;
}
self.lists.remove(self.cur);
- self.switcher.options = Self::switcher_options(&self.lists);
+ self.switcher.options = self.switcher_options();
let idx = self.cur.min(self.lists.len().saturating_sub(1));
self.select_list(idx);
}
@@ -336,14 +407,20 @@ impl ListApp {
/// The switcher reported a pick: a list, or one of the two actions.
fn switcher_picked(&mut self) {
let idx = self.switcher.selected;
- if idx < self.lists.len() {
- if idx != self.cur {
+ let n = self.lists.len();
+ let has_vault = usize::from(self.vault.is_some());
+ if idx < n {
+ if idx != self.cur || self.in_vault {
self.select_list(idx);
}
+ } else if has_vault == 1 && idx == n {
+ if !self.in_vault {
+ self.enter_vault();
+ }
} else {
- // A pseudo-entry: restore the trigger to the shown list.
- self.switcher.selected = self.cur;
- if idx == self.lists.len() {
+ // A pseudo-entry: restore the trigger to what is shown.
+ self.switcher.selected = if self.in_vault { n } else { self.cur };
+ if idx == n + has_vault {
self.begin_new_list();
} else {
self.begin_delete();
@@ -381,7 +458,7 @@ impl ListApp {
}
fn max_scroll(&self, m: &Metrics) -> f32 {
- (self.items().len() as f32 * ROW_H - self.list_viewport(m).height).max(0.0)
+ (self.row_count() as f32 * ROW_H - self.list_viewport(m).height).max(0.0)
}
fn clamp_scroll(&mut self) {
@@ -419,7 +496,7 @@ impl ListApp {
return None;
}
let i = ((y - m.list_top + self.scroll) / ROW_H).floor();
- let row = (i >= 0.0).then_some(i as usize).filter(|&i| i < self.items().len())?;
+ let row = (i >= 0.0).then_some(i as usize).filter(|&i| i < self.row_count())?;
let r = self.row_rect(&m, row);
(x >= r.x && x <= r.x + r.width).then_some(row)
}
@@ -433,12 +510,29 @@ impl ListApp {
self.create_list(&text);
}
}
+ Mode::Items if self.in_vault => {
+ if text.is_empty() {
+ return;
+ }
+ if let Some(v) = self.vault.as_mut() {
+ match v.add(&text) {
+ Ok(path) => log::info!("cce-list: added a task to {path}"),
+ Err(e) => {
+ log::error!("cce-list: adding to today's note: {e}");
+ self.input_box.set_placeholder("Could not write today's note");
+ }
+ }
+ }
+ self.clear_input();
+ self.clamp_scroll();
+ self.needs_rebuild = true;
+ }
Mode::Items => {
if text.is_empty() {
return;
}
if let Some(list) = self.lists.get_mut(self.cur) {
- list.items.push(Item { text, done: false, uid: None });
+ list.items.push(Item { text, ..Default::default() });
}
self.clear_input();
self.save_current_list();
@@ -476,6 +570,8 @@ impl Application for ListApp {
pointer: None,
hovered_row: None,
disk_sig: Vec::new(),
+ vault: VaultTasks::open(),
+ in_vault: false,
watch_at: std::time::Instant::now(),
};
app.load_from_disk();
@@ -515,6 +611,15 @@ impl Application for ListApp {
*needs_rebuild = true;
self.needs_rebuild = true;
}
+ // Vault changes arrive from the watcher thread; applying them is
+ // cheap, so do it every tick (at least once a second when idle).
+ if let Some(v) = self.vault.as_mut() {
+ if v.poll() && self.in_vault {
+ self.clamp_scroll();
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+ }
// Outside changes (the sync tick, a hand edit) show up without a
// relaunch — but never while typing a name, which a reload would
// interrupt; that waits a second.
@@ -585,12 +690,13 @@ impl Application for ListApp {
// into the input or the plate's bottom roll.
let (family, font_size) = cce_ui::layout::list_font_parsed();
let vp = self.list_viewport(&m);
- let items = self.items();
+ let items = self.rows();
+ let empty = if self.in_vault { "no open tasks in the vault" } else { "nothing to remember" };
pc.clip(vp, |pc| {
if items.is_empty() {
let r = self.row_rect(&m, 0);
pc.text_with(
- "nothing to remember".to_string(),
+ empty.to_string(),
r.x,
cce_ui::layout::align_text_y(r.y, r.height, font_size, 0.0),
font_size,
@@ -605,6 +711,21 @@ impl Application for ListApp {
continue;
}
let hovered = self.hovered_row == Some(i);
+ if item.header {
+ // A vault note's name: a click opens it in cce-notes.
+ let color = if hovered { cce_ui::colors::TEXT_FG } else { cce_ui::colors::TEXT_DIM };
+ let size = (font_size * 0.9).round();
+ pc.text_with(
+ item.text.clone(),
+ r.x,
+ cce_ui::layout::align_text_y(r.y, r.height, size, 0.0) + 2.0,
+ size,
+ srgb_u8(color),
+ Some(family.clone()),
+ Some([r.x, r.y, r.x + r.width, r.y + r.height]),
+ );
+ continue;
+ }
let (cx, cy) = (r.x + CHECK_R, r.y + r.height / 2.0);
cce_ui::widget::Checkbox::paint_round_mark(pc, cx, cy, CHECK_R, item.done);
let color = if item.done {
@@ -641,7 +762,7 @@ impl Application for ListApp {
Cap::Flat,
);
}
- if hovered {
+ if hovered && !self.in_vault {
let d = Self::delete_rect(r);
let (dcx, dcy) = (d.x + d.width / 2.0, d.y + d.height / 2.0);
let arm = 4.0;
@@ -722,15 +843,35 @@ impl Application for ListApp {
if button == MouseButton::Left && state == ElementState::Pressed {
if let Some(i) = self.row_at(px, py) {
+ if self.in_vault {
+ if let Some(v) = self.vault.as_mut() {
+ match v.rows.get(i).cloned() {
+ Some(Row::Note { path, .. }) => v.open_note(&path),
+ Some(Row::Task { .. }) => {
+ if let Err(e) = v.toggle(i) {
+ log::error!("cce-list: ticking a vault task: {e}");
+ }
+ }
+ None => {}
+ }
+ }
+ self.needs_rebuild = true;
+ *needs_rebuild = true;
+ return None;
+ }
let m = metrics(self.width as f32);
let d = Self::delete_rect(self.row_rect(&m, i));
if let Some(list) = self.lists.get_mut(self.cur) {
if px >= d.x && px <= d.x + d.width && py >= d.y && py <= d.y + d.height {
- list.items.remove(i);
+ cce_list::remove_item(list, i);
self.clamp_scroll();
self.hovered_row = self.row_at(px, py);
} else {
- list.items[i].done = !list.items[i].done;
+ let item = &mut list.items[i];
+ item.done = !item.done;
+ // A custom status (`[/]`, `[-]`) does not survive a
+ // tick by hand: it reads `x` or ` ` from now on.
+ item.mark = None;
}
}
self.save_current_list();
diff --git a/src/vault_tasks.rs b/src/vault_tasks.rs
new file mode 100644
index 0000000..883d328
--- /dev/null
+++ b/src/vault_tasks.rs
@@ -0,0 +1,165 @@
+//! The "Vault tasks" view: every open checkbox in the notes vault (the one
+//! cce-notes uses, from `vault { path }` in config.kdl), grouped by note.
+//! Ticking writes the checkbox back into its note through `cce-vault`,
+//! which re-reads the file first, so an edit made a second ago elsewhere is
+//! not overwritten. Typing adds a task to today's daily note.
+//!
+//! A task ticked here stays on screen, struck through, until the view is
+//! left — so a mis-tick can be undone — although the view otherwise lists
+//! only open tasks. Clicking a note's name opens it in cce-notes.
+//!
+//! The vault is watched like any cce-vault client: a watcher thread queues
+//! changed paths, and `poll` applies them on the app's loop.
+
+use std::collections::HashSet;
+use std::io::{BufRead, BufReader, Write};
+use std::path::PathBuf;
+use std::sync::{Arc, Mutex};
+
+use cce_vault::index::stem;
+use cce_vault::{Index, Task, VaultWatcher};
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum Row {
+ Note { path: String, name: String },
+ Task { path: String, line: usize, text: String, done: bool },
+}
+
+pub struct VaultTasks {
+ index: Index,
+ _watcher: Option<VaultWatcher>,
+ pending: Arc<Mutex<Vec<PathBuf>>>,
+ /// (path, line) of tasks ticked in this view since it was entered.
+ ticked: HashSet<(String, usize)>,
+ pub rows: Vec<Row>,
+}
+
+/// The view's rows: each note with open (or just-ticked) tasks, then them.
+pub fn build_rows<'a>(tasks: impl Iterator<Item = (&'a str, &'a Task)>, ticked: &HashSet<(String, usize)>) -> Vec<Row> {
+ let mut rows = Vec::new();
+ let mut last: Option<&str> = None;
+ for (path, t) in tasks {
+ let shown = t.is_open() || ticked.contains(&(path.to_string(), t.line));
+ if !shown {
+ continue;
+ }
+ if last != Some(path) {
+ rows.push(Row::Note { path: path.to_string(), name: stem(path).to_string() });
+ last = Some(path);
+ }
+ rows.push(Row::Task { path: path.to_string(), line: t.line, text: t.text.trim().to_string(), done: !t.is_open() });
+ }
+ rows
+}
+
+impl VaultTasks {
+ /// The configured vault's tasks, or `None` when no vault is set up.
+ pub fn open() -> Option<VaultTasks> {
+ let root = cce_vault::config::vault_root(None).ok()?;
+ let index = Index::open(&root, true).map_err(|e| log::warn!("vault tasks: {e}")).ok()?;
+ let pending = Arc::new(Mutex::new(Vec::new()));
+ let queue = pending.clone();
+ let watcher = VaultWatcher::spawn(&root, move |paths| {
+ queue.lock().unwrap_or_else(|e| e.into_inner()).extend(paths);
+ })
+ .map_err(|e| log::warn!("vault watcher: {e}"))
+ .ok();
+ let mut v = VaultTasks { index, _watcher: watcher, pending, ticked: HashSet::new(), rows: Vec::new() };
+ v.rebuild();
+ Some(v)
+ }
+
+ fn rebuild(&mut self) {
+ self.rows = build_rows(self.index.tasks(), &self.ticked);
+ }
+
+ /// Apply queued vault changes; true when the rows may have changed.
+ pub fn poll(&mut self) -> bool {
+ let paths: Vec<PathBuf> = std::mem::take(&mut *self.pending.lock().unwrap_or_else(|e| e.into_inner()));
+ if paths.is_empty() {
+ return false;
+ }
+ self.index.apply_changes(&paths);
+ self.rebuild();
+ true
+ }
+
+ /// Entering the view starts a fresh session: earlier ticks drop out.
+ pub fn enter(&mut self) {
+ self.ticked.clear();
+ self.poll();
+ self.rebuild();
+ }
+
+ /// Tick or untick the task in row `i`.
+ pub fn toggle(&mut self, i: usize) -> Result<(), String> {
+ let Some(Row::Task { path, line, done, .. }) = self.rows.get(i).cloned() else { return Ok(()) };
+ let status = if done { ' ' } else { 'x' };
+ self.index.set_task(&path, line, status).map_err(|e| e.to_string())?;
+ if done {
+ self.ticked.remove(&(path, line));
+ } else {
+ self.ticked.insert((path, line));
+ }
+ self.rebuild();
+ Ok(())
+ }
+
+ /// Add `- [ ] text` to today's daily note (created from the vault's
+ /// template if missing); returns its path.
+ pub fn add(&mut self, text: &str) -> Result<String, String> {
+ let today = chrono::Local::now().date_naive();
+ let (path, _) = self.index.daily(today, true).map_err(|e| e.to_string())?;
+ self.index.append(&path, &format!("- [ ] {text}")).map_err(|e| e.to_string())?;
+ self.rebuild();
+ Ok(path)
+ }
+
+ /// Show a note in cce-notes: hand it to the running instance over its
+ /// socket, or start one.
+ pub fn open_note(&self, path: &str) {
+ let abs = self.index.abs(path);
+ let sock = cce_ui::ipc::socket_path("cce-notes");
+ if let Ok(mut s) = std::os::unix::net::UnixStream::connect(&sock) {
+ if s.write_all(format!("open {}\n", abs.display()).as_bytes()).is_ok() {
+ let mut reply = String::new();
+ let _ = BufReader::new(s).read_line(&mut reply);
+ return;
+ }
+ }
+ let _ = std::process::Command::new("cce-notes").arg("open").arg(&abs).spawn();
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn task(status: char, text: &str, line: usize) -> Task {
+ Task { status, text: text.into(), line, status_at: 0 }
+ }
+
+ #[test]
+ fn rows_group_open_tasks_and_keep_this_sessions_ticks() {
+ let a = [task(' ', "one", 1), task('x', "old done", 2)];
+ let b = [task('x', "ticked now", 4), task('x', "long done", 5)];
+ let c = [task('x', "all done", 0)];
+ let all: Vec<(&str, &Task)> = a
+ .iter()
+ .map(|t| ("A.md", t))
+ .chain(b.iter().map(|t| ("dir/B.md", t)))
+ .chain(c.iter().map(|t| ("C.md", t)))
+ .collect();
+ let ticked: HashSet<(String, usize)> = [("dir/B.md".to_string(), 4)].into_iter().collect();
+ let rows = build_rows(all.into_iter(), &ticked);
+ assert_eq!(
+ rows,
+ [
+ Row::Note { path: "A.md".into(), name: "A".into() },
+ Row::Task { path: "A.md".into(), line: 1, text: "one".into(), done: false },
+ Row::Note { path: "dir/B.md".into(), name: "B".into() },
+ Row::Task { path: "dir/B.md".into(), line: 4, text: "ticked now".into(), done: true },
+ ]
+ );
+ }
+}