git.lucas.co / cce-list
things-to-remember checklist
git clone https://git.lucas.co/cce-list.git

src/main.rs (39.3K)

   1 //! `cce-list` — small lists of things to remember, kept on the desktop.
   2 //!
   3 //! A plain floating window: the compositor saves and restores it across
   4 //! sessions (position, size, and respawn) like any other app, and in overview
   5 //! mode it takes the normal move/resize ring. The title band is a dropdown
   6 //! naming the current list; it switches between lists and carries two
   7 //! trailing entries, "New list…" and "Delete list…", which turn the input
   8 //! box into a name prompt or a confirmation. One `TextBox` adds items; a
   9 //! click on a row toggles it done; the `x` glyph that appears on hover deletes it.
  10 //! Rows scroll when they outgrow the window.
  11 //!
  12 //! Each list is a plain markdown checklist on disk — with a notes vault
  13 //! configured, a note in the vault's `Tasks/` folder; otherwise
  14 //! `~/.local/share/cce-list/lists/<title>.md` — so it can be read and
  15 //! edited with anything (cce-notes, Obsidian on the phone); the shown list
  16 //! is named in a `current` file. The app re-reads the directory when
  17 //! something else (a vault sync, an editor) changes it.
  18 
  19 mod vault_tasks;
  20 
  21 use cce_list::{
  22     delete_list, lists_dir, load_current, load_lists, save_current, save_list, Item, ListFile,
  23 };
  24 use vault_tasks::{Row, VaultTasks};
  25 use cce_ui::widget::Handle;
  26 use cce_ui::engine::{Application, LogicalPosition, LogicalSize, WindowSettings};
  27 use cce_ui::scene::layout::Rect;
  28 use cce_ui::scene::paint::{Cap, DisplayList, PaintCtx};
  29 use cce_ui::widget::{
  30     Adapted, Bounds, Dropdown, ElementState, Event, Key, KeyEvent, MouseButton, MouseScrollDelta,
  31     NamedKey, ScrollMotion, TextBox, WidgetHost,
  32 };
  33 
  34 /// Initial size only — the window is freely resizable and the compositor
  35 /// restores the last geometry across sessions.
  36 const INIT_W: u32 = 300;
  37 const INIT_H: u32 = 320;
  38 /// Small enough that the title band, the input box, and one row stay usable.
  39 const MIN_SIZE: (u32, u32) = (220, 160);
  40 const ROW_H: f32 = 26.0;
  41 const TITLE_FONT_SIZE: f32 = 14.0;
  42 /// The share of the title band's width the list switcher takes — the rest
  43 /// stays a drag handle for the window.
  44 const SWITCHER_SHARE: f32 = 0.62;
  45 /// Half the side of a row's check box; its hit target is the whole row, this
  46 /// is only drawn. The box is cce-ui's own (`Checkbox::paint_inline`), so it
  47 /// matches a `Checkbox` widget's: a well, with a plate in it when done.
  48 const CHECK_R: f32 = cce_ui::widget::Checkbox::INLINE_HALF;
  49 /// Side of the `x` delete target at a row's right edge.
  50 const DELETE_S: f32 = 18.0;
  51 /// How often the lists directory is re-read for outside changes (a vault
  52 /// sync, an edit elsewhere). The runner wakes an idle app once a second by itself,
  53 /// so this costs no extra frames; `idle_poll_interval` pins the cadence
  54 /// rather than inheriting it.
  55 const WATCH_EVERY: std::time::Duration = std::time::Duration::from_secs(1);
  56 
  57 /// The switcher's trailing pseudo-entries, after the list titles.
  58 const NEW_LIST: &str = "New list…";
  59 const DELETE_LIST: &str = "Delete list…";
  60 const ITEM_PLACEHOLDER: &str = "Remember to…";
  61 /// The switcher entry (and `current` value) for every open task in the
  62 /// notes vault; listed only when a vault is configured.
  63 const VAULT_TASKS: &str = "Vault tasks";
  64 const VAULT_PLACEHOLDER: &str = "Add to today's note…";
  65 
  66 /// One row as drawn: a list item, a vault task, or a vault note's name.
  67 struct RowView {
  68     text: String,
  69     done: bool,
  70     header: bool,
  71 }
  72 
  73 #[derive(Debug, Clone)]
  74 enum ListMessage {
  75     Exit,
  76     /// The vault watcher queued changes (`VaultTasks::open`'s `wake`).
  77     VaultChanged,
  78 }
  79 
  80 /// What the input box is for right now.
  81 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  82 enum Mode {
  83     /// Typing adds an item to the current list.
  84     Items,
  85     /// Typing names a new list; Enter creates and shows it.
  86     NamingList,
  87     /// Enter deletes the current list, Escape keeps it.
  88     ConfirmDelete,
  89 }
  90 
  91 // ── Layout: hand math over a fixed-width column ───────────────────────────
  92 
  93 /// The frame's fixed vertical anatomy, derived once per use from the shared
  94 /// config paddings so paint, hit-testing, and `desired_size` cannot drift.
  95 struct Metrics {
  96     pad: f32,
  97     band_h: f32,
  98     switcher: Rect,
  99     input: Rect,
 100     list_top: f32,
 101 }
 102 
 103 fn text_leaf_height(font_size: f32) -> f32 {
 104     (font_size * 1.2).ceil()
 105 }
 106 
 107 fn metrics(width: f32) -> Metrics {
 108     // The window-edge inset: the root plate's roll plus one padding.
 109     let pad = cce_ui::layout::root_plate_inset();
 110     let band_h = pad + text_leaf_height(TITLE_FONT_SIZE) + 10.0;
 111     let gap = cce_ui::layout::bevel_width().max(4.0);
 112     let input_y = band_h + gap;
 113     let switcher_h = cce_ui::layout::dropdown_height();
 114     let input_h = cce_ui::layout::textbox_height();
 115     Metrics {
 116         pad,
 117         band_h,
 118         switcher: Rect {
 119             x: pad,
 120             y: ((band_h - switcher_h) / 2.0).max(2.0),
 121             width: ((width - 2.0 * pad) * SWITCHER_SHARE).max(80.0),
 122             height: switcher_h,
 123         },
 124         input: Rect { x: pad, y: input_y, width: width - 2.0 * pad, height: input_h },
 125         list_top: input_y + input_h + gap,
 126     }
 127 }
 128 
 129 fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
 130     let srgb = cce_ui::colors::to_srgb(linear);
 131     [
 132         (srgb[0] * 255.0) as u8,
 133         (srgb[1] * 255.0) as u8,
 134         (srgb[2] * 255.0) as u8,
 135     ]
 136 }
 137 
 138 /// A cheap fingerprint of the lists directory and the `current` pointer:
 139 /// names and mtimes. Compared each second; a change means something else
 140 /// wrote there and the app re-reads.
 141 fn disk_signature() -> Vec<(String, Option<std::time::SystemTime>)> {
 142     let mut sig = Vec::new();
 143     if let Ok(entries) = std::fs::read_dir(lists_dir()) {
 144         for e in entries.flatten() {
 145             let mtime = e.metadata().and_then(|m| m.modified()).ok();
 146             sig.push((e.file_name().to_string_lossy().into_owned(), mtime));
 147         }
 148     }
 149     sig.push((
 150         "current".to_string(),
 151         std::fs::metadata(cce_list::current_path()).and_then(|m| m.modified()).ok(),
 152     ));
 153     sig.sort();
 154     sig
 155 }
 156 
 157 // ── Application ───────────────────────────────────────────────────────────
 158 
 159 struct ListApp {
 160     /// Every list on disk, sorted by title; `cur` indexes the shown one.
 161     /// Every mutation saves before the frame that shows it.
 162     lists: Vec<ListFile>,
 163     cur: usize,
 164     mode: Mode,
 165     switcher: Handle<Adapted<Dropdown>>,
 166     input_box: Handle<Adapted<TextBox>>,
 167     ui_context: cce_ui::context::UiContext,
 168     width: u32,
 169     height: u32,
 170     scale_factor: f64,
 171     needs_rebuild: bool,
 172     /// How far the list is scrolled down, in logical px; non-zero only once
 173     /// the rows overflow the window. The DRAWN offset — `scroll_motion`
 174     /// glides it (wheel) or coasts it (trackpad flick); direct writes (End
 175     /// key, clamp) are adopted by the motion on its next step.
 176     scroll: f32,
 177     scroll_motion: ScrollMotion,
 178     pointer: Option<(f32, f32)>,
 179     hovered_row: Option<usize>,
 180     /// Outside-change detection: what the directory looked like when the
 181     /// lists were last read, and the countdown to the next look.
 182     disk_sig: Vec<(String, Option<std::time::SystemTime>)>,
 183     /// The notes vault's tasks, when a vault is configured; `in_vault`
 184     /// says the window shows them instead of a list.
 185     vault: Option<VaultTasks>,
 186     in_vault: bool,
 187     /// When the directory may be re-read again. A wall clock, not an
 188     /// accumulation of `tick`'s `dt`: `dt` is animation time, clamped to one
 189     /// frame after an idle sleep, and a list nobody is typing into is idle —
 190     /// so the once-a-second look actually happened about once a minute.
 191     watch_at: std::time::Instant,
 192     /// Something outside the app may have changed the lists: the vault
 193     /// watcher said so, or (unwatched) the cadence came round. Checked in
 194     /// `tick` once the input box is not taking a name.
 195     outside_change: bool,
 196 }
 197 
 198 impl ListApp {
 199     fn items(&self) -> &[Item] {
 200         self.lists.get(self.cur).map(|l| l.items.as_slice()).unwrap_or(&[])
 201     }
 202 
 203     fn vault_rows(&self) -> &[Row] {
 204         match (&self.vault, self.in_vault) {
 205             (Some(v), true) => &v.rows,
 206             _ => &[],
 207         }
 208     }
 209 
 210     fn row_count(&self) -> usize {
 211         if self.in_vault { self.vault_rows().len() } else { self.items().len() }
 212     }
 213 
 214     fn rows(&self) -> Vec<RowView> {
 215         if self.in_vault {
 216             self.vault_rows()
 217                 .iter()
 218                 .map(|r| match r {
 219                     Row::Note { name, .. } => RowView { text: name.clone(), done: false, header: true },
 220                     Row::Task { text, done, .. } => RowView { text: text.clone(), done: *done, header: false },
 221                 })
 222                 .collect()
 223         } else {
 224             self.items().iter().map(|i| RowView { text: i.text.clone(), done: i.done, header: false }).collect()
 225         }
 226     }
 227 
 228     fn switcher_options(&self) -> Vec<String> {
 229         self.lists
 230             .iter()
 231             .map(|l| l.title.clone())
 232             .chain(self.vault.as_ref().map(|_| VAULT_TASKS.to_string()))
 233             .chain([NEW_LIST.to_string(), DELETE_LIST.to_string()])
 234             .collect()
 235     }
 236 
 237     /// Show the vault's tasks.
 238     fn enter_vault(&mut self) {
 239         let Some(v) = self.vault.as_mut() else { return };
 240         v.enter();
 241         self.in_vault = true;
 242         self.ui_context[self.switcher].selected = self.lists.len();
 243         if let Err(e) = save_current(VAULT_TASKS) {
 244             log::error!("cce-list: saving current list: {e}");
 245         }
 246         self.disk_sig = disk_signature();
 247         self.scroll = 0.0;
 248         self.hovered_row = None;
 249         self.set_mode(Mode::Items);
 250         self.needs_rebuild = true;
 251     }
 252 
 253     /// (Re)read every list from disk. Keeps the shown list by title where it
 254     /// still exists (an edit elsewhere may have renamed or removed it), guarantees at
 255     /// least one list, and refreshes the switcher.
 256     fn load_from_disk(&mut self) {
 257         let mut lists = match load_lists() {
 258             Ok(l) => l,
 259             Err(e) => {
 260                 log::error!("cce-list: reading lists: {e}");
 261                 Vec::new()
 262             }
 263         };
 264         if lists.is_empty() {
 265             let first = cce_list::new_list("To Do");
 266             if let Err(e) = save_list(&first) {
 267                 log::error!("cce-list: creating the first list: {e}");
 268             }
 269             lists.push(first);
 270         }
 271         let wanted = load_current().or_else(|| self.lists.get(self.cur).map(|l| l.title.clone()));
 272         let vault_wanted = wanted.as_deref() == Some(VAULT_TASKS) && self.vault.is_some();
 273         let cur = wanted
 274             .as_deref()
 275             .and_then(|t| lists.iter().position(|l| l.title == t))
 276             .unwrap_or(self.cur.min(lists.len() - 1));
 277         if !vault_wanted && wanted.as_deref() != Some(lists[cur].title.as_str()) {
 278             let _ = save_current(&lists[cur].title);
 279         }
 280         self.lists = lists;
 281         self.cur = cur;
 282         self.in_vault = vault_wanted;
 283         self.ui_context[self.switcher].options = self.switcher_options();
 284         self.ui_context[self.switcher].selected = if self.in_vault { self.lists.len() } else { cur };
 285         self.disk_sig = disk_signature();
 286         self.clamp_scroll();
 287         if let Some((px, py)) = self.pointer {
 288             self.hovered_row = self.row_at(px, py);
 289         }
 290         self.needs_rebuild = true;
 291     }
 292 
 293     fn save_current_list(&mut self) {
 294         if let Some(list) = self.lists.get(self.cur) {
 295             if let Err(e) = save_list(list) {
 296                 log::error!("cce-list: failed to save {}: {e}", list.title);
 297             }
 298         }
 299         // Our own write must not read as an outside change next tick.
 300         self.disk_sig = disk_signature();
 301     }
 302 
 303     fn select_list(&mut self, idx: usize) {
 304         if idx >= self.lists.len() {
 305             return;
 306         }
 307         self.cur = idx;
 308         self.in_vault = false;
 309         self.ui_context[self.switcher].selected = idx;
 310         if let Err(e) = save_current(&self.lists[idx].title) {
 311             log::error!("cce-list: saving current list: {e}");
 312         }
 313         self.disk_sig = disk_signature();
 314         self.scroll = 0.0;
 315         self.hovered_row = None;
 316         self.set_mode(Mode::Items);
 317         self.needs_rebuild = true;
 318     }
 319 
 320     fn set_mode(&mut self, mode: Mode) {
 321         self.mode = mode;
 322         let placeholder = match mode {
 323             Mode::Items if self.in_vault => VAULT_PLACEHOLDER.to_string(),
 324             Mode::Items => ITEM_PLACEHOLDER.to_string(),
 325             Mode::NamingList => "Name the new list, then Enter".to_string(),
 326             Mode::ConfirmDelete => format!(
 327                 "Enter deletes “{}” · Esc keeps it",
 328                 self.lists.get(self.cur).map(|l| l.title.as_str()).unwrap_or("")
 329             ),
 330         };
 331         self.ui_context[self.input_box].set_placeholder(&placeholder);
 332         self.clear_input();
 333     }
 334 
 335     fn clear_input(&mut self) {
 336         self.ui_context[self.input_box].text.clear();
 337         self.ui_context[self.input_box].edit_buffer.clear();
 338         self.ui_context[self.input_box].cursor_idx = 0;
 339     }
 340 
 341     /// The live value: while the box is in edit mode the typed text sits in
 342     /// `edit_buffer`; `text` is only the last committed value.
 343     fn input_value(&self) -> String {
 344         let raw = if self.ui_context[self.input_box].editing {
 345             &self.ui_context[self.input_box].edit_buffer
 346         } else {
 347             &self.ui_context[self.input_box].text
 348         };
 349         raw.trim().to_string()
 350     }
 351 
 352     fn begin_new_list(&mut self) {
 353         self.set_mode(Mode::NamingList);
 354         self.ui_context.focus_id(self.input_box.id());
 355         self.needs_rebuild = true;
 356     }
 357 
 358     fn create_list(&mut self, title: &str) {
 359         let title = cce_list::safe_title(title);
 360         if let Some(idx) = self.lists.iter().position(|l| l.title == title) {
 361             // Already there: just show it.
 362             self.select_list(idx);
 363             return;
 364         }
 365         let list = cce_list::new_list(&title);
 366         if let Err(e) = save_list(&list) {
 367             log::error!("cce-list: creating {title}: {e}");
 368             return;
 369         }
 370         self.lists.push(list);
 371         self.lists.sort_by_key(|l| l.title.to_lowercase());
 372         self.ui_context[self.switcher].options = self.switcher_options();
 373         let idx = self.lists.iter().position(|l| l.title == title).unwrap_or(0);
 374         self.select_list(idx);
 375     }
 376 
 377     fn begin_delete(&mut self) {
 378         if self.in_vault {
 379             // The vault view is not a list; there is nothing to delete.
 380             self.set_mode(Mode::Items);
 381             self.ui_context[self.input_box].set_placeholder("Pick a list to delete it");
 382             self.needs_rebuild = true;
 383             return;
 384         }
 385         if self.lists.len() <= 1 {
 386             // The switcher needs something to show; one list is the floor.
 387             self.set_mode(Mode::Items);
 388             self.ui_context[self.input_box].set_placeholder("Keep at least one list");
 389             self.needs_rebuild = true;
 390             return;
 391         }
 392         self.set_mode(Mode::ConfirmDelete);
 393         self.ui_context.unfocus_id(self.input_box.id());
 394         self.needs_rebuild = true;
 395     }
 396 
 397     fn confirm_delete(&mut self) {
 398         let Some(list) = self.lists.get(self.cur) else { return };
 399         let title = list.title.clone();
 400         if let Err(e) = delete_list(&title) {
 401             log::error!("cce-list: deleting {title}: {e}");
 402             self.set_mode(Mode::Items);
 403             return;
 404         }
 405         self.lists.remove(self.cur);
 406         self.ui_context[self.switcher].options = self.switcher_options();
 407         let idx = self.cur.min(self.lists.len().saturating_sub(1));
 408         self.select_list(idx);
 409     }
 410 
 411     /// The switcher reported a pick: a list, or one of the two actions.
 412     fn switcher_picked(&mut self) {
 413         let idx = self.ui_context[self.switcher].selected;
 414         let n = self.lists.len();
 415         let has_vault = usize::from(self.vault.is_some());
 416         if idx < n {
 417             if idx != self.cur || self.in_vault {
 418                 self.select_list(idx);
 419             }
 420         } else if has_vault == 1 && idx == n {
 421             if !self.in_vault {
 422                 self.enter_vault();
 423             }
 424         } else {
 425             // A pseudo-entry: restore the trigger to what is shown.
 426             self.ui_context[self.switcher].selected = if self.in_vault { n } else { self.cur };
 427             if idx == n + has_vault {
 428                 self.begin_new_list();
 429             } else {
 430                 self.begin_delete();
 431             }
 432         }
 433         self.needs_rebuild = true;
 434     }
 435 
 436     /// Where rows show: from under the input down to the plate's bottom edge —
 437     /// the window's, across the bottom roll — so a scrolled list runs off the
 438     /// plate rather than stopping short of it. The rounded corners need no clip
 439     /// here: the compositor cuts every window to its silhouette.
 440     fn list_viewport(&self, m: &Metrics) -> Rect {
 441         Rect {
 442             x: 0.0,
 443             y: m.list_top,
 444             width: self.width as f32,
 445             height: (self.height as f32 - m.list_top).max(0.0),
 446         }
 447     }
 448 
 449     /// Row `i`'s rect in window coordinates, scroll applied.
 450     fn row_rect(&self, m: &Metrics, i: usize) -> Rect {
 451         Rect {
 452             x: m.pad,
 453             y: m.list_top + i as f32 * ROW_H - self.scroll,
 454             width: self.width as f32 - 2.0 * m.pad,
 455             height: ROW_H,
 456         }
 457     }
 458 
 459     fn delete_rect(row: Rect) -> Rect {
 460         Rect {
 461             x: row.x + row.width - DELETE_S,
 462             y: row.y + (row.height - DELETE_S) / 2.0,
 463             width: DELETE_S,
 464             height: DELETE_S,
 465         }
 466     }
 467 
 468     /// Scrolled to the end, the last row rests the plate inset clear of the
 469     /// edge — a padding of flat face past the roll, as at every other edge.
 470     fn max_scroll(&self, m: &Metrics) -> f32 {
 471         let content = self.row_count() as f32 * ROW_H + m.pad;
 472         (content - self.list_viewport(m).height).max(0.0)
 473     }
 474 
 475     fn clamp_scroll(&mut self) {
 476         let m = metrics(self.width as f32);
 477         self.scroll = self.scroll.clamp(0.0, self.max_scroll(&m));
 478     }
 479 
 480     /// Re-derive what depends on the drawn offset after it moved.
 481     fn after_scroll_moved(&mut self) {
 482         if let Some((px, py)) = self.pointer {
 483             self.hovered_row = self.row_at(px, py);
 484         }
 485     }
 486 
 487     /// Advance the wheel glide / flick coast; true while the offset moved.
 488     fn tick_scroll(&mut self, dt: f32) -> bool {
 489         self.scroll_motion.reconcile(0.0, self.scroll);
 490         if !self.scroll_motion.is_animating() {
 491             return false;
 492         }
 493         let m = metrics(self.width as f32);
 494         let max = self.max_scroll(&m);
 495         let moved = self.scroll_motion.tick(dt, Bounds::max(0.0), Bounds::max(max));
 496         self.scroll = self.scroll_motion.y.pos();
 497         if moved {
 498             self.after_scroll_moved();
 499         }
 500         moved || self.scroll_motion.is_animating()
 501     }
 502 
 503     fn row_at(&self, x: f32, y: f32) -> Option<usize> {
 504         let m = metrics(self.width as f32);
 505         let vp = self.list_viewport(&m);
 506         if y < vp.y || y > vp.y + vp.height {
 507             return None;
 508         }
 509         let i = ((y - m.list_top + self.scroll) / ROW_H).floor();
 510         let row = (i >= 0.0).then_some(i as usize).filter(|&i| i < self.row_count())?;
 511         let r = self.row_rect(&m, row);
 512         (x >= r.x && x <= r.x + r.width).then_some(row)
 513     }
 514 
 515     fn submit_input(&mut self) {
 516         let text = self.input_value();
 517         match self.mode {
 518             Mode::ConfirmDelete => self.confirm_delete(),
 519             Mode::NamingList => {
 520                 if !text.is_empty() {
 521                     self.create_list(&text);
 522                 }
 523             }
 524             Mode::Items if self.in_vault => {
 525                 if text.is_empty() {
 526                     return;
 527                 }
 528                 if let Some(v) = self.vault.as_mut() {
 529                     match v.add(&text) {
 530                         Ok(path) => log::info!("cce-list: added a task to {path}"),
 531                         Err(e) => {
 532                             log::error!("cce-list: adding to today's note: {e}");
 533                             self.ui_context[self.input_box].set_placeholder("Could not write today's note");
 534                         }
 535                     }
 536                 }
 537                 self.clear_input();
 538                 self.clamp_scroll();
 539                 self.needs_rebuild = true;
 540             }
 541             Mode::Items => {
 542                 if text.is_empty() {
 543                     return;
 544                 }
 545                 if let Some(list) = self.lists.get_mut(self.cur) {
 546                     cce_list::push_item(list, text);
 547                 }
 548                 self.clear_input();
 549                 self.save_current_list();
 550                 // Keep the fresh item in view once the window is at its height cap.
 551                 let m = metrics(self.width as f32);
 552                 self.scroll = self.max_scroll(&m);
 553                 self.needs_rebuild = true;
 554             }
 555         }
 556     }
 557 }
 558 
 559 impl ListApp {
 560     /// Whether outside changes to the lists arrive as messages: lists live
 561     /// in the vault and its watcher is running.
 562     fn watched(&self) -> bool {
 563         cce_list::in_vault() && self.vault.as_ref().is_some_and(|v| v.watching())
 564     }
 565 }
 566 
 567 impl Application for ListApp {
 568     type Message = ListMessage;
 569 
 570     fn create(sender: cce_ui::engine::AppSender<Self::Message>) -> Self {
 571         // The app keeps calloop's sender; `AppSender` converts into it.
 572         let sender: calloop::channel::Sender<Self::Message> = sender.into();
 573         cce_ui::scale::set_scale_factor(1.0);
 574         // The context owns the widgets; the app keeps their handles.
 575         let mut ui_context = cce_ui::context::UiContext::new();
 576         let mut app = Self {
 577             lists: Vec::new(),
 578             cur: 0,
 579             mode: Mode::Items,
 580             switcher: ui_context.insert(Dropdown::new(Vec::new(), 0)),
 581             input_box: ui_context.insert(TextBox::new(String::new()).with_placeholder(ITEM_PLACEHOLDER)),
 582             ui_context,
 583             width: INIT_W,
 584             height: INIT_H,
 585             scale_factor: 1.0,
 586             needs_rebuild: true,
 587             scroll: 0.0,
 588             scroll_motion: ScrollMotion::new(),
 589             pointer: None,
 590             hovered_row: None,
 591             disk_sig: Vec::new(),
 592             vault: VaultTasks::open(move || {
 593                 let _ = sender.send(ListMessage::VaultChanged);
 594             }),
 595             in_vault: false,
 596             watch_at: std::time::Instant::now(),
 597             outside_change: false,
 598         };
 599         app.load_from_disk();
 600         app
 601     }
 602 
 603     fn settings(&self) -> WindowSettings {
 604         WindowSettings {
 605             title: "List".to_string(),
 606             app_id: "cce-list".to_string(),
 607             width: INIT_W,
 608             height: INIT_H,
 609             fullscreen: false,
 610             min_size: Some(MIN_SIZE),
 611         }
 612     }
 613 
 614     fn update(&mut self, msg: Self::Message, _needs_rebuild: &mut bool, exit: &mut bool) {
 615         match msg {
 616             ListMessage::Exit => *exit = true,
 617             // Applied by `tick`, which the message's turn runs next.
 618             ListMessage::VaultChanged => self.outside_change = true,
 619         }
 620     }
 621 
 622     /// With a vault, the lists are its `Tasks/` notes and the vault watcher
 623     /// wakes the loop when they change (`ListMessage::VaultChanged`), so
 624     /// nothing needs polling. Without one, the app's own lists folder is
 625     /// re-read every [`WATCH_EVERY`] as before — work the runner cannot see,
 626     /// so name the cadence. Until 2026-10-06 the folder was re-read every
 627     /// second either way, and the watcher's batches waited for that tick.
 628     fn idle_poll_interval(&self) -> Option<std::time::Duration> {
 629         (!self.watched()).then_some(WATCH_EVERY)
 630     }
 631 
 632     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
 633         if self.ui_context.tick(dt) {
 634             *needs_rebuild = true;
 635             self.needs_rebuild = true;
 636         }
 637         if self.tick_scroll(dt) {
 638             *needs_rebuild = true;
 639             self.needs_rebuild = true;
 640         }
 641         // Vault changes arrive from the watcher thread; applying them is
 642         // cheap, so do it every tick (at least once a second when idle).
 643         if let Some(v) = self.vault.as_mut() {
 644             if v.poll() && self.in_vault {
 645                 self.clamp_scroll();
 646                 *needs_rebuild = true;
 647                 self.needs_rebuild = true;
 648             }
 649         }
 650         // Outside changes (a vault sync, a hand edit) show up without a
 651         // relaunch — but never while typing a name, which a reload would
 652         // interrupt; that waits until the name is done. Watched, a change
 653         // is announced; unwatched, the folder is looked at on the cadence.
 654         let now = std::time::Instant::now();
 655         if !self.watched() && now >= self.watch_at {
 656             self.watch_at = now + WATCH_EVERY;
 657             self.outside_change = true;
 658         }
 659         if self.outside_change && self.mode == Mode::Items {
 660             self.outside_change = false;
 661             if disk_signature() != self.disk_sig {
 662                 self.load_from_disk();
 663                 *needs_rebuild = true;
 664             }
 665         }
 666     }
 667 
 668     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
 669         let size_changed = self.width != size.width as u32
 670             || self.height != size.height as u32
 671             || self.scale_factor != scale;
 672         if size_changed {
 673             self.width = size.width as u32;
 674             self.height = size.height as u32;
 675             self.scale_factor = scale;
 676             cce_ui::scale::set_scale_factor(scale as f32);
 677             self.clamp_scroll();
 678         }
 679         let m = metrics(self.width as f32);
 680         if self.needs_rebuild || size_changed {
 681             self.ui_context[self.input_box]
 682                 .set_rect(m.input.x, m.input.y, m.input.width, m.input.height);
 683             self.ui_context[self.switcher]
 684                 .set_rect(m.switcher.x, m.switcher.y, m.switcher.width, m.switcher.height);
 685             self.needs_rebuild = false;
 686             self.ui_context.rebuild_spatial_grid();
 687         }
 688         // An open menu overlays the rows: registered as a popover so it is
 689         // hit-tested above them and clips the row text beneath; the popover
 690         // pass at the end of this function draws it. Re-registered every
 691         // frame from a clean slate, since the rect animates and closes.
 692         self.ui_context.clear_popovers();
 693         if self.ui_context[self.switcher].popover_rect().is_some() {
 694             self.ui_context.register_popover_id(self.switcher.id());
 695         }
 696 
 697         let (w, h) = (self.width as f32, self.height as f32);
 698         let mut pc = PaintCtx::new();
 699 
 700         // The standard root plate, then the title strip carved one step down
 701         // into it (the recessed header idiom — its only wall faces the content).
 702         pc.root_plate(w, h);
 703         pc.recess_edges(
 704             Rect { x: 0.0, y: 0.0, width: w, height: m.band_h },
 705             (0.0, 0.0, 0.0, 0.0),
 706             cce_ui::layout::bar_wall_width(),
 707             (false, false, true, false),
 708         );
 709 
 710         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.input_box], &mut pc);
 711 
 712         // The rows, clipped to the viewport so a scrolled list never bleeds
 713         // into the input.
 714         let (family, font_size) = cce_ui::layout::list_font_parsed();
 715         let vp = self.list_viewport(&m);
 716         let items = self.rows();
 717         let empty = if self.in_vault { "no open tasks in the vault" } else { "nothing to remember" };
 718         pc.clip(vp, |pc| {
 719             if items.is_empty() {
 720                 let r = self.row_rect(&m, 0);
 721                 pc.text_with(
 722                     empty.to_string(),
 723                     r.x,
 724                     cce_ui::layout::align_text_y(r.y, r.height, font_size, 0.0),
 725                     font_size,
 726                     srgb_u8(cce_ui::colors::TEXT_DIM),
 727                     Some(family.clone()),
 728                     None,
 729                 );
 730             }
 731             for (i, item) in items.iter().enumerate() {
 732                 let r = self.row_rect(&m, i);
 733                 if r.y + r.height < vp.y || r.y > vp.y + vp.height {
 734                     continue;
 735                 }
 736                 let hovered = self.hovered_row == Some(i);
 737                 if item.header {
 738                     // A vault note's name: a click opens it in cce-notes.
 739                     let color = if hovered { cce_ui::colors::TEXT_FG } else { cce_ui::colors::TEXT_DIM };
 740                     let size = (font_size * 0.9).round();
 741                     pc.text_with(
 742                         item.text.clone(),
 743                         r.x,
 744                         cce_ui::layout::align_text_y(r.y, r.height, size, 0.0) + 2.0,
 745                         size,
 746                         srgb_u8(color),
 747                         Some(family.clone()),
 748                         Some([r.x, r.y, r.x + r.width, r.y + r.height]),
 749                     );
 750                     continue;
 751                 }
 752                 let (cx, cy) = (r.x + CHECK_R, r.y + r.height / 2.0);
 753                 cce_ui::widget::Checkbox::paint_inline(pc, cx, cy, CHECK_R, item.done);
 754                 let color = if item.done {
 755                     cce_ui::colors::TEXT_DIM
 756                 } else {
 757                     cce_ui::colors::TEXT_FG
 758                 };
 759                 let text_x = cx + CHECK_R + 8.0;
 760                 // The delete zone bounds the label whether or not its `x` is drawn,
 761                 // so hovering never truncates the text it just revealed the `x` over.
 762                 let text_end = r.x + r.width - DELETE_S - 4.0;
 763                 let text_y = cce_ui::layout::align_text_y(r.y, r.height, font_size, 0.0);
 764                 pc.text_with(
 765                     item.text.clone(),
 766                     text_x,
 767                     text_y,
 768                     font_size,
 769                     srgb_u8(color),
 770                     Some(family.clone()),
 771                     Some([text_x, r.y, text_end, r.y + r.height]),
 772                 );
 773                 if item.done {
 774                     // Struck through rather than restyled: the row stays
 775                     // readable, it just reads as handled.
 776                     let strike_w = ((item.text.chars().count() as f32) * font_size * 0.55)
 777                         .min(text_end - text_x);
 778                     pc.vector(
 779                         text_x,
 780                         cy,
 781                         text_x + strike_w,
 782                         cy,
 783                         1.0,
 784                         cce_ui::colors::TEXT_DIM,
 785                         Cap::Flat,
 786                     );
 787                 }
 788                 if hovered && !self.in_vault {
 789                     // The `x` glyph, centred in the delete zone.
 790                     let d = Self::delete_rect(r);
 791                     let side = 12.0;
 792                     let glyph = Rect {
 793                         x: d.x + (d.width - side) / 2.0,
 794                         y: d.y + (d.height - side) / 2.0,
 795                         width: side,
 796                         height: side,
 797                     };
 798                     pc.icon("x", glyph, cce_ui::colors::to_srgb(cce_ui::colors::TEXT_DIM));
 799                 }
 800             }
 801         });
 802 
 803         // The switcher's trigger, then its open menu on top of everything.
 804         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.switcher], &mut pc);
 805         self.ui_context[self.switcher].render_popover(&mut pc);
 806 
 807         Some(pc.finish())
 808     }
 809 
 810     fn display_list_text(&self) -> bool {
 811         true
 812     }
 813 
 814     fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
 815         Some(&self.ui_context)
 816     }
 817 
 818     fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
 819         Some(&mut self.ui_context)
 820     }
 821 
 822     /// Drag the window by the title band, right of the switcher; everywhere
 823     /// else is content.
 824     fn is_movable_root_plate_at(&self, px: f32, py: f32) -> bool {
 825         let m = metrics(self.width as f32);
 826         py <= m.band_h && px > m.switcher.x + m.switcher.width
 827     }
 828 
 829     fn clear_color(&self) -> [f32; 4] {
 830         [0.0, 0.0, 0.0, 0.0]
 831     }
 832 
 833     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
 834         self.pointer = Some((pos.x, pos.y));
 835         // The shared context menu (the switcher's, the input box's) gets the
 836         // pointer to itself while open: its row highlight.
 837         if cce_ui::widget::context_menu::is_visible() {
 838             if cce_ui::widget::context_menu::cursor_moved(pos.x, pos.y) {
 839                 *needs_rebuild = true;
 840             }
 841             return;
 842         }
 843         let ev = Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
 844         if self.ui_context.propagate_event(&ev, self.switcher.id()) {
 845             *needs_rebuild = true;
 846         }
 847         // Rows under an open menu are not hoverable.
 848         let hovered = if self.ui_context[self.switcher].open { None } else { self.row_at(pos.x, pos.y) };
 849         if hovered != self.hovered_row {
 850             self.hovered_row = hovered;
 851             *needs_rebuild = true;
 852         }
 853         if self.ui_context.propagate_event(&ev, self.input_box.id()) {
 854             *needs_rebuild = true;
 855         }
 856     }
 857 
 858     fn handle_mouse_input(
 859         &mut self,
 860         button: MouseButton,
 861         state: ElementState,
 862         pos: LogicalPosition,
 863         needs_rebuild: &mut bool,
 864     ) -> Option<Self::Message> {
 865         let (px, py) = (pos.x, pos.y);
 866 
 867         // The shared context menu a right-click on the switcher or the input box
 868         // opens takes every click while open, ahead even of the switcher: a row
 869         // runs, a press anywhere else dismisses it. The toolkit leaves this
 870         // routing to the app; without it the menu could not be closed by
 871         // clicking outside it, and its rows did nothing.
 872         if cce_ui::widget::context_menu::is_visible() {
 873             if cce_ui::widget::context_menu::mouse_input(button, state, px, py, Some(&mut self.ui_context)) {
 874                 *needs_rebuild = true;
 875                 self.needs_rebuild = true;
 876             }
 877             return None;
 878         }
 879 
 880         let ev = Event::MouseButton { button, state, x: px, y: py, local_x: px, local_y: py };
 881 
 882         // The switcher routes first: its open menu overlays the rows, so a
 883         // press it handles must not fall through to what is beneath.
 884         if self.ui_context.propagate_event(&ev, self.switcher.id()) {
 885             if self.ui_context[self.switcher].take_change() {
 886                 self.switcher_picked();
 887             }
 888             *needs_rebuild = true;
 889             self.needs_rebuild = true;
 890             return None;
 891         }
 892 
 893         if button == MouseButton::Left && state == ElementState::Pressed {
 894             if let Some(i) = self.row_at(px, py) {
 895                 if self.in_vault {
 896                     if let Some(v) = self.vault.as_mut() {
 897                         match v.rows.get(i).cloned() {
 898                             Some(Row::Note { path, .. }) => v.open_note(&path),
 899                             Some(Row::Task { .. }) => {
 900                                 if let Err(e) = v.toggle(i) {
 901                                     log::error!("cce-list: ticking a vault task: {e}");
 902                                 }
 903                             }
 904                             None => {}
 905                         }
 906                     }
 907                     self.needs_rebuild = true;
 908                     *needs_rebuild = true;
 909                     return None;
 910                 }
 911                 let m = metrics(self.width as f32);
 912                 let d = Self::delete_rect(self.row_rect(&m, i));
 913                 if let Some(list) = self.lists.get_mut(self.cur) {
 914                     if px >= d.x && px <= d.x + d.width && py >= d.y && py <= d.y + d.height {
 915                         cce_list::remove_item(list, i);
 916                         self.clamp_scroll();
 917                         self.hovered_row = self.row_at(px, py);
 918                     } else {
 919                         let item = &mut list.items[i];
 920                         item.done = !item.done;
 921                         // A custom status (`[/]`, `[-]`) does not survive a
 922                         // tick by hand: it reads `x` or ` ` from now on.
 923                         item.mark = None;
 924                     }
 925                 }
 926                 self.save_current_list();
 927                 self.needs_rebuild = true;
 928                 *needs_rebuild = true;
 929                 return None;
 930             }
 931         }
 932         if state == ElementState::Pressed && !self.ui_context[self.input_box].hit_test(px, py, &self.ui_context) {
 933             self.ui_context.unfocus_id(self.input_box.id());
 934             *needs_rebuild = true;
 935         }
 936         if self.ui_context.propagate_event(&ev, self.input_box.id()) {
 937             *needs_rebuild = true;
 938         }
 939         None
 940     }
 941 
 942     fn handle_mouse_wheel(
 943         &mut self,
 944         delta: &MouseScrollDelta,
 945         _pos: LogicalPosition,
 946         needs_rebuild: &mut bool,
 947     ) {
 948         let m = metrics(self.width as f32);
 949         let max = self.max_scroll(&m);
 950         if max <= 0.0 || self.ui_context[self.switcher].open {
 951             return;
 952         }
 953         self.scroll_motion.reconcile(0.0, self.scroll);
 954         let moved = self.scroll_motion.apply(delta, (ROW_H, ROW_H), Bounds::max(0.0), Bounds::max(max));
 955         self.scroll = self.scroll_motion.y.pos();
 956         if moved {
 957             self.after_scroll_moved();
 958             self.needs_rebuild = true;
 959             *needs_rebuild = true;
 960         }
 961     }
 962 
 963     fn handle_key_input(
 964         &mut self,
 965         event: &KeyEvent,
 966         needs_rebuild: &mut bool,
 967     ) -> Option<Self::Message> {
 968         let ev = Event::KeyInput(event.clone());
 969         // An open menu takes the keyboard: arrows move, Enter picks.
 970         if self.ui_context[self.switcher].open {
 971             if self.ui_context.propagate_event(&ev, self.switcher.id()) {
 972                 if self.ui_context[self.switcher].take_change() {
 973                     self.switcher_picked();
 974                 }
 975                 *needs_rebuild = true;
 976                 self.needs_rebuild = true;
 977                 return None;
 978             }
 979         }
 980         if event.state == ElementState::Pressed && !event.repeat {
 981             if event.ctrl {
 982                 if let Key::Character(ref c) = event.logical_key {
 983                     if c == "q" {
 984                         return Some(ListMessage::Exit);
 985                     }
 986                 }
 987             }
 988             if let Key::Named(NamedKey::Escape) = event.logical_key {
 989                 self.ui_context.unfocus_id(self.input_box.id());
 990                 if self.mode != Mode::Items {
 991                     self.set_mode(Mode::Items);
 992                 }
 993                 *needs_rebuild = true;
 994                 self.needs_rebuild = true;
 995                 return None;
 996             }
 997             if let Key::Named(NamedKey::Enter) = event.logical_key {
 998                 // The box's own edit mode, not `focused(&ctx)`: a click focuses
 999                 // through the thread-local focus registry, so the UiContext's
1000                 // focused_widget — which that checks — never learns of it.
1001                 // A pending deletion takes Enter from anywhere.
1002                 if self.ui_context[self.input_box].editing || self.mode == Mode::ConfirmDelete {
1003                     self.submit_input();
1004                     *needs_rebuild = true;
1005                     self.needs_rebuild = true;
1006                     return None;
1007                 }
1008             }
1009         }
1010         if self.ui_context.propagate_event(&ev, self.input_box.id()) {
1011             *needs_rebuild = true;
1012         }
1013         None
1014     }
1015 }
1016 
1017 fn main() {
1018     env_logger::init();
1019     cce_ui::engine::run::<ListApp>();
1020 }