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 }