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

src/main.rs (30.6K)

  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 ✕ 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
 13 //! (`~/.local/share/cce-list/lists/<title>.md`), so it can be read and
 14 //! edited with anything; the shown list is named in a `current` file next
 15 //! to them. Lists and items mirrored from Google Tasks by `cce-list-sync`
 16 //! carry `<!-- list:… -->` / `<!-- uid:… -->` comments; toggling, adding,
 17 //! deleting — items or whole lists — here is pushed to the server on the
 18 //! next sync tick, and the app re-reads the directory when the sync (or a
 19 //! hand edit) changes it.
 20 
 21 use cce_list::{
 22     delete_list, lists_dir, load_current, load_lists, save_current, save_list, Item, ListFile,
 23 };
 24 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
 25 use cce_ui::scene::layout::Rect;
 26 use cce_ui::scene::paint::{Cap, DisplayList, PaintCtx};
 27 use cce_ui::widget::{
 28     Adapted, Bounds, Dropdown, ElementState, Event, Key, KeyEvent, MouseButton, MouseScrollDelta,
 29     NamedKey, ScrollMotion, TextBox, WidgetHost,
 30 };
 31 use wayland_client::QueueHandle;
 32 
 33 /// Initial size only — the window is freely resizable and the compositor
 34 /// restores the last geometry across sessions.
 35 const INIT_W: u32 = 300;
 36 const INIT_H: u32 = 320;
 37 /// Small enough that the title band, the input box, and one row stay usable.
 38 const MIN_SIZE: (u32, u32) = (220, 160);
 39 const ROW_H: f32 = 26.0;
 40 const INPUT_H: f32 = 30.0;
 41 const TITLE_FONT_SIZE: f32 = 14.0;
 42 /// The list switcher in the title band: its height, and the share of the
 43 /// band's width it takes — the rest stays a drag handle for the window.
 44 const SWITCHER_H: f32 = 24.0;
 45 const SWITCHER_SHARE: f32 = 0.62;
 46 /// Checkbox disc radius; its hit target is the whole row, this is only drawn.
 47 /// The mark itself is cce-ui's round `Checkbox` style, so it matches one.
 48 const CHECK_R: f32 = cce_ui::widget::Checkbox::ROUND_RADIUS;
 49 /// Side of the ✕ 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 (the sync
 52 /// timer, a hand edit). 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 
 62 #[derive(Debug, Clone)]
 63 enum ListMessage {
 64     Exit,
 65 }
 66 
 67 /// What the input box is for right now.
 68 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 69 enum Mode {
 70     /// Typing adds an item to the current list.
 71     Items,
 72     /// Typing names a new list; Enter creates and shows it.
 73     NamingList,
 74     /// Enter deletes the current list, Escape keeps it.
 75     ConfirmDelete,
 76 }
 77 
 78 // ── Layout: hand math over a fixed-width column ───────────────────────────
 79 
 80 /// The frame's fixed vertical anatomy, derived once per use from the shared
 81 /// config paddings so paint, hit-testing, and `desired_size` cannot drift.
 82 struct Metrics {
 83     pad: f32,
 84     band_h: f32,
 85     switcher: Rect,
 86     input: Rect,
 87     list_top: f32,
 88 }
 89 
 90 fn text_leaf_height(font_size: f32) -> f32 {
 91     (font_size * 1.2).ceil()
 92 }
 93 
 94 fn metrics(width: f32) -> Metrics {
 95     // The window-edge inset: the root plate's roll plus one padding.
 96     let pad = cce_ui::layout::root_plate_inset();
 97     let band_h = pad + text_leaf_height(TITLE_FONT_SIZE) + 10.0;
 98     let gap = cce_ui::layout::bevel_width().max(4.0);
 99     let input_y = band_h + gap;
100     Metrics {
101         pad,
102         band_h,
103         switcher: Rect {
104             x: pad,
105             y: ((band_h - SWITCHER_H) / 2.0).max(2.0),
106             width: ((width - 2.0 * pad) * SWITCHER_SHARE).max(80.0),
107             height: SWITCHER_H,
108         },
109         input: Rect { x: pad, y: input_y, width: width - 2.0 * pad, height: INPUT_H },
110         list_top: input_y + INPUT_H + gap,
111     }
112 }
113 
114 fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
115     let srgb = cce_ui::colors::to_srgb(linear);
116     [
117         (srgb[0] * 255.0) as u8,
118         (srgb[1] * 255.0) as u8,
119         (srgb[2] * 255.0) as u8,
120     ]
121 }
122 
123 /// A cheap fingerprint of the lists directory and the `current` pointer:
124 /// names and mtimes. Compared each second; a change means something else
125 /// wrote there and the app re-reads.
126 fn disk_signature() -> Vec<(String, Option<std::time::SystemTime>)> {
127     let mut sig = Vec::new();
128     if let Ok(entries) = std::fs::read_dir(lists_dir()) {
129         for e in entries.flatten() {
130             let mtime = e.metadata().and_then(|m| m.modified()).ok();
131             sig.push((e.file_name().to_string_lossy().into_owned(), mtime));
132         }
133     }
134     sig.push((
135         "current".to_string(),
136         std::fs::metadata(cce_list::current_path()).and_then(|m| m.modified()).ok(),
137     ));
138     sig.sort();
139     sig
140 }
141 
142 // ── Application ───────────────────────────────────────────────────────────
143 
144 struct ListApp {
145     /// Every list on disk, sorted by title; `cur` indexes the shown one.
146     /// Every mutation saves before the frame that shows it.
147     lists: Vec<ListFile>,
148     cur: usize,
149     mode: Mode,
150     switcher: Adapted<Dropdown>,
151     input_box: Adapted<TextBox>,
152     ui_context: cce_ui::context::UiContext,
153     width: u32,
154     height: u32,
155     scale_factor: f64,
156     needs_rebuild: bool,
157     widgets_registered: bool,
158     /// How far the list is scrolled down, in logical px; non-zero only once
159     /// the rows overflow the window. The DRAWN offset — `scroll_motion`
160     /// glides it (wheel) or coasts it (trackpad flick); direct writes (End
161     /// key, clamp) are adopted by the motion on its next step.
162     scroll: f32,
163     scroll_motion: ScrollMotion,
164     pointer: Option<(f32, f32)>,
165     hovered_row: Option<usize>,
166     /// Outside-change detection: what the directory looked like when the
167     /// lists were last read, and the countdown to the next look.
168     disk_sig: Vec<(String, Option<std::time::SystemTime>)>,
169     /// When the directory may be re-read again. A wall clock, not an
170     /// accumulation of `tick`'s `dt`: `dt` is animation time, clamped to one
171     /// frame after an idle sleep, and a list nobody is typing into is idle —
172     /// so the once-a-second look actually happened about once a minute.
173     watch_at: std::time::Instant,
174 }
175 
176 impl ListApp {
177     fn items(&self) -> &[Item] {
178         self.lists.get(self.cur).map(|l| l.items.as_slice()).unwrap_or(&[])
179     }
180 
181     fn switcher_options(lists: &[ListFile]) -> Vec<String> {
182         lists
183             .iter()
184             .map(|l| l.title.clone())
185             .chain([NEW_LIST.to_string(), DELETE_LIST.to_string()])
186             .collect()
187     }
188 
189     /// (Re)read every list from disk. Keeps the shown list by title where it
190     /// still exists (the sync may have renamed or removed it), guarantees at
191     /// least one list, and refreshes the switcher.
192     fn load_from_disk(&mut self) {
193         let mut lists = match load_lists() {
194             Ok(l) => l,
195             Err(e) => {
196                 log::error!("cce-list: reading lists: {e}");
197                 Vec::new()
198             }
199         };
200         if lists.is_empty() {
201             let first = ListFile { title: "Tasks".to_string(), id: None, items: Vec::new() };
202             if let Err(e) = save_list(&first) {
203                 log::error!("cce-list: creating the first list: {e}");
204             }
205             lists.push(first);
206         }
207         let wanted = load_current().or_else(|| self.lists.get(self.cur).map(|l| l.title.clone()));
208         let cur = wanted
209             .as_deref()
210             .and_then(|t| lists.iter().position(|l| l.title == t))
211             .unwrap_or(0);
212         if wanted.as_deref() != Some(lists[cur].title.as_str()) {
213             let _ = save_current(&lists[cur].title);
214         }
215         self.lists = lists;
216         self.cur = cur;
217         self.switcher.options = Self::switcher_options(&self.lists);
218         self.switcher.selected = cur;
219         self.disk_sig = disk_signature();
220         self.clamp_scroll();
221         if let Some((px, py)) = self.pointer {
222             self.hovered_row = self.row_at(px, py);
223         }
224         self.needs_rebuild = true;
225     }
226 
227     fn save_current_list(&mut self) {
228         if let Some(list) = self.lists.get(self.cur) {
229             if let Err(e) = save_list(list) {
230                 log::error!("cce-list: failed to save {}: {e}", list.title);
231             }
232         }
233         // Our own write must not read as an outside change next tick.
234         self.disk_sig = disk_signature();
235     }
236 
237     fn select_list(&mut self, idx: usize) {
238         if idx >= self.lists.len() {
239             return;
240         }
241         self.cur = idx;
242         self.switcher.selected = idx;
243         if let Err(e) = save_current(&self.lists[idx].title) {
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     fn set_mode(&mut self, mode: Mode) {
254         self.mode = mode;
255         let placeholder = match mode {
256             Mode::Items => ITEM_PLACEHOLDER.to_string(),
257             Mode::NamingList => "Name the new list, then Enter".to_string(),
258             Mode::ConfirmDelete => format!(
259                 "Enter deletes “{}” · Esc keeps it",
260                 self.lists.get(self.cur).map(|l| l.title.as_str()).unwrap_or("")
261             ),
262         };
263         self.input_box.set_placeholder(&placeholder);
264         self.clear_input();
265     }
266 
267     fn clear_input(&mut self) {
268         self.input_box.text.clear();
269         self.input_box.edit_buffer.clear();
270         self.input_box.cursor_idx = 0;
271     }
272 
273     /// The live value: while the box is in edit mode the typed text sits in
274     /// `edit_buffer`; `text` is only the last committed value.
275     fn input_value(&self) -> String {
276         let raw = if self.input_box.editing {
277             &self.input_box.edit_buffer
278         } else {
279             &self.input_box.text
280         };
281         raw.trim().to_string()
282     }
283 
284     fn begin_new_list(&mut self) {
285         self.set_mode(Mode::NamingList);
286         self.input_box.focus();
287         self.needs_rebuild = true;
288     }
289 
290     fn create_list(&mut self, title: &str) {
291         let title = cce_list::safe_title(title);
292         if let Some(idx) = self.lists.iter().position(|l| l.title == title) {
293             // Already there: just show it.
294             self.select_list(idx);
295             return;
296         }
297         let list = ListFile { title: title.clone(), id: None, items: Vec::new() };
298         if let Err(e) = save_list(&list) {
299             log::error!("cce-list: creating {title}: {e}");
300             return;
301         }
302         self.lists.push(list);
303         self.lists.sort_by_key(|l| l.title.to_lowercase());
304         self.switcher.options = Self::switcher_options(&self.lists);
305         let idx = self.lists.iter().position(|l| l.title == title).unwrap_or(0);
306         self.select_list(idx);
307     }
308 
309     fn begin_delete(&mut self) {
310         if self.lists.len() <= 1 {
311             // The server keeps a default list too; one is the floor.
312             self.set_mode(Mode::Items);
313             self.input_box.set_placeholder("Keep at least one list");
314             self.needs_rebuild = true;
315             return;
316         }
317         self.set_mode(Mode::ConfirmDelete);
318         self.input_box.unfocus();
319         self.needs_rebuild = true;
320     }
321 
322     fn confirm_delete(&mut self) {
323         let Some(list) = self.lists.get(self.cur) else { return };
324         let title = list.title.clone();
325         if let Err(e) = delete_list(&title) {
326             log::error!("cce-list: deleting {title}: {e}");
327             self.set_mode(Mode::Items);
328             return;
329         }
330         self.lists.remove(self.cur);
331         self.switcher.options = Self::switcher_options(&self.lists);
332         let idx = self.cur.min(self.lists.len().saturating_sub(1));
333         self.select_list(idx);
334     }
335 
336     /// The switcher reported a pick: a list, or one of the two actions.
337     fn switcher_picked(&mut self) {
338         let idx = self.switcher.selected;
339         if idx < self.lists.len() {
340             if idx != self.cur {
341                 self.select_list(idx);
342             }
343         } else {
344             // A pseudo-entry: restore the trigger to the shown list.
345             self.switcher.selected = self.cur;
346             if idx == self.lists.len() {
347                 self.begin_new_list();
348             } else {
349                 self.begin_delete();
350             }
351         }
352         self.needs_rebuild = true;
353     }
354 
355     fn list_viewport(&self, m: &Metrics) -> Rect {
356         Rect {
357             x: 0.0,
358             y: m.list_top,
359             width: self.width as f32,
360             height: (self.height as f32 - m.list_top - m.pad).max(0.0),
361         }
362     }
363 
364     /// Row `i`'s rect in window coordinates, scroll applied.
365     fn row_rect(&self, m: &Metrics, i: usize) -> Rect {
366         Rect {
367             x: m.pad,
368             y: m.list_top + i as f32 * ROW_H - self.scroll,
369             width: self.width as f32 - 2.0 * m.pad,
370             height: ROW_H,
371         }
372     }
373 
374     fn delete_rect(row: Rect) -> Rect {
375         Rect {
376             x: row.x + row.width - DELETE_S,
377             y: row.y + (row.height - DELETE_S) / 2.0,
378             width: DELETE_S,
379             height: DELETE_S,
380         }
381     }
382 
383     fn max_scroll(&self, m: &Metrics) -> f32 {
384         (self.items().len() as f32 * ROW_H - self.list_viewport(m).height).max(0.0)
385     }
386 
387     fn clamp_scroll(&mut self) {
388         let m = metrics(self.width as f32);
389         self.scroll = self.scroll.clamp(0.0, self.max_scroll(&m));
390     }
391 
392     /// Re-derive what depends on the drawn offset after it moved.
393     fn after_scroll_moved(&mut self) {
394         if let Some((px, py)) = self.pointer {
395             self.hovered_row = self.row_at(px, py);
396         }
397     }
398 
399     /// Advance the wheel glide / flick coast; true while the offset moved.
400     fn tick_scroll(&mut self, dt: f32) -> bool {
401         self.scroll_motion.reconcile(0.0, self.scroll);
402         if !self.scroll_motion.is_animating() {
403             return false;
404         }
405         let m = metrics(self.width as f32);
406         let max = self.max_scroll(&m);
407         let moved = self.scroll_motion.tick(dt, Bounds::max(0.0), Bounds::max(max));
408         self.scroll = self.scroll_motion.y.pos();
409         if moved {
410             self.after_scroll_moved();
411         }
412         moved || self.scroll_motion.is_animating()
413     }
414 
415     fn row_at(&self, x: f32, y: f32) -> Option<usize> {
416         let m = metrics(self.width as f32);
417         let vp = self.list_viewport(&m);
418         if y < vp.y || y > vp.y + vp.height {
419             return None;
420         }
421         let i = ((y - m.list_top + self.scroll) / ROW_H).floor();
422         let row = (i >= 0.0).then_some(i as usize).filter(|&i| i < self.items().len())?;
423         let r = self.row_rect(&m, row);
424         (x >= r.x && x <= r.x + r.width).then_some(row)
425     }
426 
427     fn submit_input(&mut self) {
428         let text = self.input_value();
429         match self.mode {
430             Mode::ConfirmDelete => self.confirm_delete(),
431             Mode::NamingList => {
432                 if !text.is_empty() {
433                     self.create_list(&text);
434                 }
435             }
436             Mode::Items => {
437                 if text.is_empty() {
438                     return;
439                 }
440                 if let Some(list) = self.lists.get_mut(self.cur) {
441                     list.items.push(Item { text, done: false, uid: None });
442                 }
443                 self.clear_input();
444                 self.save_current_list();
445                 // Keep the fresh item in view once the window is at its height cap.
446                 let m = metrics(self.width as f32);
447                 self.scroll = self.max_scroll(&m);
448                 self.needs_rebuild = true;
449             }
450         }
451     }
452 }
453 
454 impl Application for ListApp {
455     type Message = ListMessage;
456 
457     fn new(
458         _qh: &QueueHandle<EngineState<Self>>,
459         _sender: calloop::channel::Sender<Self::Message>,
460     ) -> Self {
461         cce_ui::scale::set_scale_factor(1.0);
462         let mut app = Self {
463             lists: Vec::new(),
464             cur: 0,
465             mode: Mode::Items,
466             switcher: Dropdown::new(Vec::new(), 0),
467             input_box: TextBox::new(String::new()).with_placeholder(ITEM_PLACEHOLDER),
468             ui_context: cce_ui::context::UiContext::new(),
469             width: INIT_W,
470             height: INIT_H,
471             scale_factor: 1.0,
472             needs_rebuild: true,
473             widgets_registered: false,
474             scroll: 0.0,
475             scroll_motion: ScrollMotion::new(),
476             pointer: None,
477             hovered_row: None,
478             disk_sig: Vec::new(),
479             watch_at: std::time::Instant::now(),
480         };
481         app.load_from_disk();
482         app
483     }
484 
485     fn settings(&self) -> WindowSettings {
486         WindowSettings {
487             title: "cce-list".to_string(),
488             app_id: "cce-list".to_string(),
489             width: INIT_W,
490             height: INIT_H,
491             fullscreen: false,
492             min_size: Some(MIN_SIZE),
493         }
494     }
495 
496     fn update(&mut self, msg: Self::Message, _needs_rebuild: &mut bool, exit: &mut bool) {
497         match msg {
498             ListMessage::Exit => *exit = true,
499         }
500     }
501 
502     /// The directory watch in `tick` is work the runner cannot see — nothing
503     /// redraws until the files change underneath us — so name the cadence the
504     /// loop has to come back at.
505     fn idle_poll_interval(&self) -> Option<std::time::Duration> {
506         Some(WATCH_EVERY)
507     }
508 
509     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
510         if self.ui_context.tick(dt) {
511             *needs_rebuild = true;
512             self.needs_rebuild = true;
513         }
514         if self.tick_scroll(dt) {
515             *needs_rebuild = true;
516             self.needs_rebuild = true;
517         }
518         // Outside changes (the sync tick, a hand edit) show up without a
519         // relaunch — but never while typing a name, which a reload would
520         // interrupt; that waits a second.
521         let now = std::time::Instant::now();
522         if now >= self.watch_at {
523             self.watch_at = now + WATCH_EVERY;
524             if self.mode == Mode::Items && disk_signature() != self.disk_sig {
525                 self.load_from_disk();
526                 *needs_rebuild = true;
527             }
528         }
529     }
530 
531     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
532         // Register once, at self's final address (the registry stores pointers).
533         if !self.widgets_registered {
534             self.widgets_registered = true;
535             let (id, ptr) = (self.input_box.id(), self.input_box.as_ptr_mut());
536             self.ui_context.register_widget(id, ptr);
537             let (id, ptr) = (self.switcher.id(), self.switcher.as_ptr_mut());
538             self.ui_context.register_widget(id, ptr);
539         }
540 
541         let size_changed = self.width != size.width as u32
542             || self.height != size.height as u32
543             || self.scale_factor != scale;
544         if size_changed {
545             self.width = size.width as u32;
546             self.height = size.height as u32;
547             self.scale_factor = scale;
548             cce_ui::scale::set_scale_factor(scale as f32);
549             self.clamp_scroll();
550         }
551         let m = metrics(self.width as f32);
552         if self.needs_rebuild || size_changed {
553             self.input_box
554                 .set_rect(m.input.x, m.input.y, m.input.width, m.input.height);
555             self.switcher
556                 .set_rect(m.switcher.x, m.switcher.y, m.switcher.width, m.switcher.height);
557             self.needs_rebuild = false;
558             self.ui_context.rebuild_spatial_grid();
559         }
560         // An open menu overlays the rows: registered as a popover so it is
561         // hit-tested above them and clips the row text beneath; the popover
562         // pass at the end of this function draws it. Re-registered every
563         // frame from a clean slate, since the rect animates and closes.
564         self.ui_context.clear_popovers();
565         if self.switcher.popover_rect().is_some() {
566             self.ui_context.register_popover(&mut self.switcher);
567         }
568 
569         let (w, h) = (self.width as f32, self.height as f32);
570         let mut pc = PaintCtx::new();
571 
572         // The standard root plate, then the title strip carved one step down
573         // into it (the recessed header idiom — its only wall faces the content).
574         pc.root_plate(w, h);
575         pc.recess_edges(
576             Rect { x: 0.0, y: 0.0, width: w, height: m.band_h },
577             (0.0, 0.0, 0.0, 0.0),
578             cce_ui::layout::bar_wall_width(),
579             (false, false, true, false),
580         );
581 
582         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.input_box, &mut pc);
583 
584         // The rows, clipped to the viewport so a scrolled list never bleeds
585         // into the input or the plate's bottom roll.
586         let (family, font_size) = cce_ui::layout::list_font_parsed();
587         let vp = self.list_viewport(&m);
588         let items = self.items();
589         pc.clip(vp, |pc| {
590             if items.is_empty() {
591                 let r = self.row_rect(&m, 0);
592                 pc.text_with(
593                     "nothing to remember".to_string(),
594                     r.x,
595                     cce_ui::layout::align_text_y(r.y, r.height, font_size, 0.0),
596                     font_size,
597                     srgb_u8(cce_ui::colors::TEXT_DIM),
598                     Some(family.clone()),
599                     None,
600                 );
601             }
602             for (i, item) in items.iter().enumerate() {
603                 let r = self.row_rect(&m, i);
604                 if r.y + r.height < vp.y || r.y > vp.y + vp.height {
605                     continue;
606                 }
607                 let hovered = self.hovered_row == Some(i);
608                 let (cx, cy) = (r.x + CHECK_R, r.y + r.height / 2.0);
609                 cce_ui::widget::Checkbox::paint_round_mark(pc, cx, cy, CHECK_R, item.done);
610                 let color = if item.done {
611                     cce_ui::colors::TEXT_DIM
612                 } else {
613                     cce_ui::colors::TEXT_FG
614                 };
615                 let text_x = cx + CHECK_R + 8.0;
616                 // The ✕ zone bounds the label whether or not it is drawn, so
617                 // hovering never truncates the text it just revealed the ✕ over.
618                 let text_end = r.x + r.width - DELETE_S - 4.0;
619                 let text_y = cce_ui::layout::align_text_y(r.y, r.height, font_size, 0.0);
620                 pc.text_with(
621                     item.text.clone(),
622                     text_x,
623                     text_y,
624                     font_size,
625                     srgb_u8(color),
626                     Some(family.clone()),
627                     Some([text_x, r.y, text_end, r.y + r.height]),
628                 );
629                 if item.done {
630                     // Struck through rather than restyled: the row stays
631                     // readable, it just reads as handled.
632                     let strike_w = ((item.text.chars().count() as f32) * font_size * 0.55)
633                         .min(text_end - text_x);
634                     pc.vector(
635                         text_x,
636                         cy,
637                         text_x + strike_w,
638                         cy,
639                         1.0,
640                         cce_ui::colors::TEXT_DIM,
641                         Cap::Flat,
642                     );
643                 }
644                 if hovered {
645                     let d = Self::delete_rect(r);
646                     let (dcx, dcy) = (d.x + d.width / 2.0, d.y + d.height / 2.0);
647                     let arm = 4.0;
648                     let dim = cce_ui::colors::TEXT_DIM;
649                     pc.vector(dcx - arm, dcy - arm, dcx + arm, dcy + arm, 1.5, dim, Cap::Round);
650                     pc.vector(dcx - arm, dcy + arm, dcx + arm, dcy - arm, 1.5, dim, Cap::Round);
651                 }
652             }
653         });
654 
655         // The switcher's trigger, then its open menu on top of everything.
656         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.switcher, &mut pc);
657         self.switcher.render_popover(&mut pc);
658 
659         Some(pc.finish())
660     }
661 
662     fn display_list_text(&self) -> bool {
663         true
664     }
665 
666     fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
667         Some(&self.ui_context)
668     }
669 
670     fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
671         Some(&mut self.ui_context)
672     }
673 
674     /// Drag the window by the title band, right of the switcher; everywhere
675     /// else is content.
676     fn is_movable_root_plate_at(&self, px: f32, py: f32) -> bool {
677         let m = metrics(self.width as f32);
678         py <= m.band_h && px > m.switcher.x + m.switcher.width
679     }
680 
681     fn clear_color(&self) -> [f32; 4] {
682         [0.0, 0.0, 0.0, 0.0]
683     }
684 
685     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
686         self.pointer = Some((pos.x, pos.y));
687         let ev = Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
688         if self.ui_context.propagate_event(&ev, self.switcher.id()) {
689             *needs_rebuild = true;
690         }
691         // Rows under an open menu are not hoverable.
692         let hovered = if self.switcher.open { None } else { self.row_at(pos.x, pos.y) };
693         if hovered != self.hovered_row {
694             self.hovered_row = hovered;
695             *needs_rebuild = true;
696         }
697         if self.ui_context.propagate_event(&ev, self.input_box.id()) {
698             *needs_rebuild = true;
699         }
700     }
701 
702     fn handle_mouse_input(
703         &mut self,
704         button: MouseButton,
705         state: ElementState,
706         pos: LogicalPosition,
707         needs_rebuild: &mut bool,
708     ) -> Option<Self::Message> {
709         let (px, py) = (pos.x, pos.y);
710         let ev = Event::MouseButton { button, state, x: px, y: py, local_x: px, local_y: py };
711 
712         // The switcher routes first: its open menu overlays the rows, so a
713         // press it handles must not fall through to what is beneath.
714         if self.ui_context.propagate_event(&ev, self.switcher.id()) {
715             if self.switcher.take_change() {
716                 self.switcher_picked();
717             }
718             *needs_rebuild = true;
719             self.needs_rebuild = true;
720             return None;
721         }
722 
723         if button == MouseButton::Left && state == ElementState::Pressed {
724             if let Some(i) = self.row_at(px, py) {
725                 let m = metrics(self.width as f32);
726                 let d = Self::delete_rect(self.row_rect(&m, i));
727                 if let Some(list) = self.lists.get_mut(self.cur) {
728                     if px >= d.x && px <= d.x + d.width && py >= d.y && py <= d.y + d.height {
729                         list.items.remove(i);
730                         self.clamp_scroll();
731                         self.hovered_row = self.row_at(px, py);
732                     } else {
733                         list.items[i].done = !list.items[i].done;
734                     }
735                 }
736                 self.save_current_list();
737                 self.needs_rebuild = true;
738                 *needs_rebuild = true;
739                 return None;
740             }
741         }
742         if state == ElementState::Pressed && !self.input_box.hit_test(px, py, &self.ui_context) {
743             self.input_box.unfocus();
744             *needs_rebuild = true;
745         }
746         if self.ui_context.propagate_event(&ev, self.input_box.id()) {
747             *needs_rebuild = true;
748         }
749         None
750     }
751 
752     fn handle_mouse_wheel(
753         &mut self,
754         delta: &MouseScrollDelta,
755         _pos: LogicalPosition,
756         needs_rebuild: &mut bool,
757     ) {
758         let m = metrics(self.width as f32);
759         let max = self.max_scroll(&m);
760         if max <= 0.0 || self.switcher.open {
761             return;
762         }
763         self.scroll_motion.reconcile(0.0, self.scroll);
764         let moved = self.scroll_motion.apply(delta, (ROW_H, ROW_H), Bounds::max(0.0), Bounds::max(max));
765         self.scroll = self.scroll_motion.y.pos();
766         if moved {
767             self.after_scroll_moved();
768             self.needs_rebuild = true;
769             *needs_rebuild = true;
770         }
771     }
772 
773     fn handle_key_input(
774         &mut self,
775         event: &KeyEvent,
776         needs_rebuild: &mut bool,
777     ) -> Option<Self::Message> {
778         let ev = Event::KeyInput(event.clone());
779         // An open menu takes the keyboard: arrows move, Enter picks.
780         if self.switcher.open {
781             if self.ui_context.propagate_event(&ev, self.switcher.id()) {
782                 if self.switcher.take_change() {
783                     self.switcher_picked();
784                 }
785                 *needs_rebuild = true;
786                 self.needs_rebuild = true;
787                 return None;
788             }
789         }
790         if event.state == ElementState::Pressed && !event.repeat {
791             if event.ctrl {
792                 if let Key::Character(ref c) = event.logical_key {
793                     if c == "q" {
794                         return Some(ListMessage::Exit);
795                     }
796                 }
797             }
798             if let Key::Named(NamedKey::Escape) = event.logical_key {
799                 self.input_box.unfocus();
800                 if self.mode != Mode::Items {
801                     self.set_mode(Mode::Items);
802                 }
803                 *needs_rebuild = true;
804                 self.needs_rebuild = true;
805                 return None;
806             }
807             if let Key::Named(NamedKey::Enter) = event.logical_key {
808                 // The box's own edit mode, not `focused(&ctx)`: a click focuses
809                 // through the thread-local focus registry, so the UiContext's
810                 // focused_widget — which that checks — never learns of it.
811                 // A pending deletion takes Enter from anywhere.
812                 if self.input_box.editing || self.mode == Mode::ConfirmDelete {
813                     self.submit_input();
814                     *needs_rebuild = true;
815                     self.needs_rebuild = true;
816                     return None;
817                 }
818             }
819         }
820         if self.ui_context.propagate_event(&ev, self.input_box.id()) {
821             *needs_rebuild = true;
822         }
823         None
824     }
825 }
826 
827 fn main() {
828     env_logger::init();
829     cce_ui::engine::run::<ListApp>();
830 }