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 }