git.lucas.co / cce-calendar
calendar
git clone https://git.lucas.co/cce-calendar.git

src/main.rs (35K)

  1 //! cce-calendar — month-view calendar with per-day events.
  2 //!
  3 //! A 6×7 month grid on the left, a day pane on the right. Events live in
  4 //! `$XDG_DATA_HOME/cce/calendar/events.json` (one flat list of
  5 //! date/time/title records) and are saved on every mutation. Records with a
  6 //! `source` are mirrored from a remote calendar by `cce-calendar-sync`
  7 //! (blue dot). Events typed here are pushed to the default calendar on the
  8 //! next sync tick and come back carrying their identity; deleting a synced
  9 //! event here deletes it on the server — except instances of a recurring
 10 //! event, which are read-only mirrors (the app refuses). The file is
 11 //! re-read once a second when the sync rewrites it.
 12 //!
 13 //! Keys: arrows move the selected day · PageUp/PageDown month · [/] year ·
 14 //! t/Home today · n/Enter new event (a leading `HH:MM` token sets the
 15 //! time) · d/Delete remove the clicked event · q quit. The wheel flips
 16 //! months over the grid and scrolls the event list over the pane.
 17 //!
 18 //! Config (`~/.config/cce/cce-calendar/config.kdl`): `week-start "sunday"`
 19 //! (default monday); `push-to "icloud"` / `"google"` / `"none"`, optionally
 20 //! `calendar="Name"`, picks where typed events are created (the sync's
 21 //! default is iCloud when such an account exists).
 22 
 23 use std::collections::BTreeMap;
 24 
 25 use cce_calendar::{load_records, save_records, EventRecord};
 26 use chrono::{Datelike, Days, Local, NaiveDate, Weekday};
 27 use wayland_client::QueueHandle;
 28 
 29 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
 30 use cce_ui::layout::{bevel_width, plate_gap, plate_padding, root_plate_gap, root_plate_inset};
 31 use cce_ui::scene::layout::Rect;
 32 use cce_ui::scene::paint::{AlignH, AlignV, DisplayList, PaintCtx, PlateSpec, TextAttrs, TextLayout};
 33 use cce_ui::scene::Material;
 34 use cce_ui::widget::scroll_motion::{current_scroll_phase, Bounds, ScrollMotion, ScrollPhase};
 35 use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey};
 36 
 37 /// How often events.json is re-stat'd. The runner wakes an idle app once a
 38 /// second anyway; `idle_poll_interval` pins that rather than inheriting it.
 39 const WATCH_EVERY: std::time::Duration = std::time::Duration::from_secs(1);
 40 
 41 const HEADER_H: f32 = 46.0;
 42 const WEEKDAY_H: f32 = 24.0;
 43 const SIDEBAR_W: f32 = 300.0;
 44 const ROW_H: f32 = 36.0;
 45 const INPUT_H: f32 = 40.0;
 46 /// The sidebar's heading block: the day's name on one line, "today" under
 47 /// it. Two text lines, so a size, not a spacing.
 48 const SIDEBAR_HEAD_H: f32 = 40.0;
 49 /// Trackpad travel per month step over the grid (a wheel notch is one step).
 50 const MONTH_STEP_PX: f32 = 48.0;
 51 
 52 const BG: [f32; 4] = [0.075, 0.08, 0.09, 1.0];
 53 const BG_OTHER_MONTH: [f32; 4] = [0.06, 0.064, 0.072, 1.0];
 54 const SIDEBAR_BG: [f32; 4] = [0.10, 0.105, 0.12, 1.0];
 55 const GRID_LINE: [f32; 4] = [1.0, 1.0, 1.0, 0.06];
 56 const ACCENT: [f32; 4] = [0.22, 0.42, 0.85, 1.0];
 57 const EVENT_DOT: [f32; 4] = [0.95, 0.72, 0.30, 1.0];
 58 /// Dot for events mirrored from a remote calendar (`source` set).
 59 const SYNC_DOT: [f32; 4] = [0.42, 0.68, 0.95, 1.0];
 60 const ROW_SEL: [f32; 4] = [1.0, 1.0, 1.0, 0.08];
 61 const TEXT: [u8; 3] = [225, 228, 232];
 62 const TEXT_DIM: [u8; 3] = [140, 145, 152];
 63 const TEXT_FAINT: [u8; 3] = [95, 100, 108];
 64 const TEXT_ACCENT: [u8; 3] = [120, 165, 255];
 65 
 66 #[derive(Debug, Clone)]
 67 enum Message {
 68     Quit,
 69 }
 70 
 71 /// One event on a day. `time` is (hour, minute); untimed events sort after
 72 /// timed ones. `uid`/`source` ride along from synced records so a save from
 73 /// this app never strips what `cce-calendar-sync` wrote.
 74 #[derive(Clone, Debug)]
 75 struct Event {
 76     time: Option<(u32, u32)>,
 77     title: String,
 78     uid: Option<String>,
 79     source: Option<String>,
 80     recurring: bool,
 81 }
 82 
 83 fn load_events() -> BTreeMap<NaiveDate, Vec<Event>> {
 84     let mut map: BTreeMap<NaiveDate, Vec<Event>> = BTreeMap::new();
 85     let records = match load_records() {
 86         Ok(r) => r,
 87         Err(e) => {
 88             log::error!("events.json unreadable, starting empty: {e}");
 89             return map;
 90         }
 91     };
 92     for rec in records {
 93         let Ok(date) = rec.date.parse::<NaiveDate>() else {
 94             continue;
 95         };
 96         let time = rec.time.as_deref().and_then(parse_time);
 97         map.entry(date).or_default().push(Event {
 98             time,
 99             title: rec.title,
100             uid: rec.uid,
101             source: rec.source,
102             recurring: rec.recurring,
103         });
104     }
105     for events in map.values_mut() {
106         sort_events(events);
107     }
108     map
109 }
110 
111 fn sort_events(events: &mut [Event]) {
112     events.sort_by_key(|e| e.time.map_or((1, 0, 0), |(h, m)| (0, h, m)));
113 }
114 
115 /// `"H:MM"` / `"HH:MM"` → (hour, minute).
116 fn parse_time(tok: &str) -> Option<(u32, u32)> {
117     let (h, m) = tok.split_once(':')?;
118     let (h, m) = (h.parse::<u32>().ok()?, m.parse::<u32>().ok()?);
119     (h < 24 && m < 60 && !tok.starts_with('+')).then_some((h, m))
120 }
121 
122 /// A leading `HH:MM` token becomes the event time; the rest is the title.
123 fn parse_event(raw: &str) -> Option<Event> {
124     let raw = raw.trim();
125     if raw.is_empty() {
126         return None;
127     }
128     let (time, title) = match raw.split_once(char::is_whitespace) {
129         Some((tok, rest)) if parse_time(tok).is_some() => (parse_time(tok), rest.trim().to_string()),
130         _ => match parse_time(raw) {
131             Some(t) => (Some(t), String::new()),
132             None => (None, raw.to_string()),
133         },
134     };
135     let title = if title.is_empty() { "(untitled)".to_string() } else { title };
136     Some(Event { time, title, uid: None, source: None, recurring: false })
137 }
138 
139 fn week_start_config() -> Weekday {
140     let path = cce_ui::config::get_app_config_path("cce-calendar");
141     if let Ok(text) = std::fs::read_to_string(path) {
142         if let Ok(doc) = text.parse::<kdl::KdlDocument>() {
143             if let Some(v) = doc
144                 .get("week-start")
145                 .and_then(|n| n.entries().first())
146                 .and_then(|e| e.value().as_string())
147             {
148                 return match v.to_ascii_lowercase().as_str() {
149                     "sunday" | "sun" => Weekday::Sun,
150                     "saturday" | "sat" => Weekday::Sat,
151                     _ => Weekday::Mon,
152                 };
153             }
154         }
155     }
156     Weekday::Mon
157 }
158 
159 fn add_months(year: i32, month: u32, delta: i32) -> (i32, u32) {
160     let idx = year * 12 + month as i32 - 1 + delta;
161     (idx.div_euclid(12), (idx.rem_euclid(12) + 1) as u32)
162 }
163 
164 fn days_in_month(year: i32, month: u32) -> u32 {
165     let (ny, nm) = add_months(year, month, 1);
166     NaiveDate::from_ymd_opt(ny, nm, 1)
167         .and_then(|d| d.pred_opt())
168         .map_or(28, |d| d.day())
169 }
170 
171 const MONTHS: [&str; 12] = [
172     "January", "February", "March", "April", "May", "June", "July", "August", "September",
173     "October", "November", "December",
174 ];
175 const WEEKDAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
176 
177 fn weekday_offset(day: Weekday, week_start: Weekday) -> u32 {
178     (day.num_days_from_monday() + 7 - week_start.num_days_from_monday()) % 7
179 }
180 
181 /// The window geometry every frame and every hit-test derive from.
182 struct Geom {
183     /// The month header band across the top of the grid column.
184     header: Rect,
185     prev_btn: Rect,
186     next_btn: Rect,
187     today_btn: Rect,
188     grid: Rect,
189     cell_w: f32,
190     cell_h: f32,
191     sidebar: Rect,
192     /// The event list's clip inside the sidebar plate: full plate width,
193     /// between the heading block and the bottom strip.
194     list: Rect,
195     /// The bottom strip inside the sidebar plate — the input field while
196     /// typing, else the key hints.
197     strip: Rect,
198     /// Date of the grid's top-left cell (always a 42-day window).
199     first_day: NaiveDate,
200 }
201 
202 fn hit(r: &Rect, x: f32, y: f32) -> bool {
203     x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
204 }
205 
206 struct CalendarApp {
207     events: BTreeMap<NaiveDate, Vec<Event>>,
208     /// Displayed (year, month).
209     view: (i32, u32),
210     selected: NaiveDate,
211     /// Index into the selected day's (sorted) events, for deletion.
212     sel_event: Option<usize>,
213     /// `Some(buffer)` while typing a new event.
214     input: Option<String>,
215     week_start: Weekday,
216     today: NaiveDate,
217     win: (f32, f32),
218     sidebar_scroll: f32,
219     /// Drives `sidebar_scroll` (the drawn value) from the wheel: notches
220     /// glide, fingers track 1:1 and fling on the lift. `select` resets the
221     /// offset directly; the motion adopts that through `reconcile`.
222     sidebar_motion: ScrollMotion,
223     /// Trackpad pixels accumulated toward the next month step over the grid,
224     /// so a gesture flips one month per MONTH_STEP_PX rather than one per
225     /// pixel event. Cleared by a wheel notch and by the finger lift.
226     month_wheel_px: f32,
227     status: Option<String>,
228     /// events.json as last read: mtime, polled once a second so the sync's
229     /// rewrites (pushed identities, phone-side changes) show up live.
230     file_mtime: Option<std::time::SystemTime>,
231     /// When the mtime check below may run again. A wall clock, not an
232     /// accumulation of `tick`'s `dt`: `dt` is animation time, clamped to one
233     /// frame after an idle sleep, and a calendar nobody is touching is idle —
234     /// so the "once a second" watch actually ran about once a minute.
235     watch_at: std::time::Instant,
236 }
237 
238 fn file_mtime() -> Option<std::time::SystemTime> {
239     std::fs::metadata(cce_calendar::data_path()).and_then(|m| m.modified()).ok()
240 }
241 
242 impl CalendarApp {
243     fn geom(&self) -> Geom {
244         let (w, h) = self.win;
245         // Everything stands on the root plate `root_plate_inset` in from
246         // the window edge; the grid column and the sidebar plate are
247         // siblings on it, `root_plate_gap` apart:
248         // [inset][header+grid][gap][sidebar][inset].
249         let inset = root_plate_inset();
250         let gap = root_plate_gap();
251         let sidebar_w = SIDEBAR_W.min(w * 0.4);
252         let grid_w = (w - 2.0 * inset - gap - sidebar_w).max(0.0);
253         let header = Rect { x: inset, y: inset, width: grid_w, height: HEADER_H };
254         let grid = Rect {
255             x: inset,
256             y: header.y + HEADER_H + WEEKDAY_H,
257             width: grid_w,
258             height: (h - inset - (header.y + HEADER_H + WEEKDAY_H)).max(0.0),
259         };
260         let btn = |x: f32| Rect { x, y: header.y + (HEADER_H - 28.0) / 2.0, width: 28.0, height: 28.0 };
261         let sidebar = Rect {
262             x: header.x + grid_w + gap,
263             y: inset,
264             width: sidebar_w,
265             height: (h - 2.0 * inset).max(0.0),
266         };
267         // Inside the sidebar plate: `plate_padding` off its rim, the
268         // heading block, the list and the bottom strip `plate_gap` apart.
269         let pad = plate_padding();
270         let strip_h = INPUT_H - 8.0;
271         let strip = Rect {
272             x: sidebar.x + pad,
273             y: sidebar.y + sidebar.height - pad - strip_h,
274             width: (sidebar.width - 2.0 * pad).max(0.0),
275             height: strip_h,
276         };
277         let list_y = sidebar.y + pad + SIDEBAR_HEAD_H + plate_gap();
278         let list = Rect {
279             x: sidebar.x,
280             y: list_y,
281             width: sidebar.width,
282             height: (strip.y - plate_gap() - list_y).max(0.0),
283         };
284         let first_of_month = NaiveDate::from_ymd_opt(self.view.0, self.view.1, 1)
285             .unwrap_or(self.today);
286         let back = weekday_offset(first_of_month.weekday(), self.week_start);
287         let first_day = first_of_month
288             .checked_sub_days(Days::new(back as u64))
289             .unwrap_or(first_of_month);
290         Geom {
291             header,
292             prev_btn: btn(header.x),
293             // 190 is the month title's width between the two arrows.
294             next_btn: btn(header.x + 28.0 + 190.0),
295             today_btn: Rect {
296                 x: header.x + header.width - 64.0,
297                 y: header.y + (HEADER_H - 24.0) / 2.0,
298                 width: 64.0,
299                 height: 24.0,
300             },
301             grid,
302             cell_w: grid.width / 7.0,
303             cell_h: grid.height / 6.0,
304             sidebar,
305             list,
306             strip,
307             first_day,
308         }
309     }
310 
311     fn day_at(&self, g: &Geom, x: f32, y: f32) -> Option<NaiveDate> {
312         if !hit(&g.grid, x, y) {
313             return None;
314         }
315         let col = ((x - g.grid.x) / g.cell_w) as u64;
316         let row = ((y - g.grid.y) / g.cell_h) as u64;
317         g.first_day.checked_add_days(Days::new(row.min(5) * 7 + col.min(6)))
318     }
319 
320     fn select(&mut self, date: NaiveDate) {
321         self.selected = date;
322         self.sel_event = None;
323         self.sidebar_scroll = 0.0;
324         self.status = None;
325         if (date.year(), date.month()) != self.view {
326             self.view = (date.year(), date.month());
327         }
328     }
329 
330     /// How far the selected day's event rows overflow the sidebar's list area.
331     fn sidebar_overflow(&self, g: &Geom) -> f32 {
332         let events = self.events.get(&self.selected).map_or(0, Vec::len);
333         (events as f32 * ROW_H - g.list.height).max(0.0)
334     }
335 
336     /// Per-frame sidebar glide/coast; true while `sidebar_scroll` is still
337     /// moving, so the frame loop keeps drawing.
338     fn tick_sidebar_scroll(&mut self, dt: f32) -> bool {
339         self.sidebar_motion.reconcile(0.0, self.sidebar_scroll);
340         if !self.sidebar_motion.is_animating() {
341             return false;
342         }
343         let g = self.geom();
344         let overflow = self.sidebar_overflow(&g);
345         let moved = self.sidebar_motion.tick(dt, Bounds::max(0.0), Bounds::max(overflow));
346         self.sidebar_scroll = self.sidebar_motion.y.pos();
347         moved || self.sidebar_motion.is_animating()
348     }
349 
350     fn shift_months(&mut self, delta: i32) {
351         let (y, m) = add_months(self.view.0, self.view.1, delta);
352         self.view = (y, m);
353         let day = self.selected.day().min(days_in_month(y, m));
354         if let Some(d) = NaiveDate::from_ymd_opt(y, m, day) {
355             self.selected = d;
356             self.sel_event = None;
357         }
358     }
359 
360     fn shift_selected_days(&mut self, delta: i64) {
361         let moved = if delta >= 0 {
362             self.selected.checked_add_days(Days::new(delta as u64))
363         } else {
364             self.selected.checked_sub_days(Days::new((-delta) as u64))
365         };
366         if let Some(d) = moved {
367             self.select(d);
368         }
369     }
370 
371     fn save(&mut self) {
372         let records: Vec<EventRecord> = self
373             .events
374             .iter()
375             .flat_map(|(date, events)| {
376                 events.iter().map(move |e| EventRecord {
377                     date: date.to_string(),
378                     time: e.time.map(|(h, m)| format!("{h:02}:{m:02}")),
379                     title: e.title.clone(),
380                     uid: e.uid.clone(),
381                     source: e.source.clone(),
382                     recurring: e.recurring,
383                 })
384             })
385             .collect();
386         self.status = save_records(&records).err().map(|e| format!("save failed: {e}"));
387         // Our own write must not read as an outside change next tick.
388         self.file_mtime = file_mtime();
389     }
390 
391     /// Re-read events.json after something else wrote it, keeping the
392     /// selection where it still makes sense.
393     fn reload(&mut self) {
394         self.events = load_events();
395         self.file_mtime = file_mtime();
396         let n = self.events.get(&self.selected).map_or(0, Vec::len);
397         if self.sel_event.is_some_and(|i| i >= n) {
398             self.sel_event = None;
399         }
400     }
401 
402     fn commit_input(&mut self) {
403         let Some(buffer) = self.input.take() else {
404             return;
405         };
406         if let Some(event) = parse_event(&buffer) {
407             let day = self.events.entry(self.selected).or_default();
408             day.push(event);
409             sort_events(day);
410             self.save();
411         }
412     }
413 
414     fn delete_selected_event(&mut self) {
415         let Some(idx) = self.sel_event.take() else {
416             return;
417         };
418         if let Some(day) = self.events.get_mut(&self.selected) {
419             if day.get(idx).is_some_and(|e| e.recurring) {
420                 // One instance of a repeating event: the server has no
421                 // "just this one" the mirror could express, so it stays.
422                 self.sel_event = Some(idx);
423                 self.status = Some("repeating event — change it on the phone".to_string());
424                 return;
425             }
426             if idx < day.len() {
427                 day.remove(idx);
428                 if day.is_empty() {
429                     self.events.remove(&self.selected);
430                 }
431                 self.save();
432             }
433         }
434     }
435 
436     // ── keyboard ──────────────────────────────────────────────────────────
437 
438     fn key_input_mode(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) {
439         let buffer = self.input.as_mut().expect("input mode");
440         match &event.logical_key {
441             Key::Named(NamedKey::Escape) => self.input = None,
442             Key::Named(NamedKey::Enter) => self.commit_input(),
443             Key::Named(NamedKey::Backspace) => {
444                 buffer.pop();
445             }
446             _ => {
447                 if let Some(text) = &event.text {
448                     buffer.extend(text.chars().filter(|c| !c.is_control()));
449                 }
450             }
451         }
452         *needs_rebuild = true;
453     }
454 
455     fn key_browse_mode(&mut self, event: &KeyEvent) -> (bool, bool) {
456         let mut quit = false;
457         let mut handled = true;
458         match &event.logical_key {
459             Key::Named(NamedKey::ArrowLeft) => self.shift_selected_days(-1),
460             Key::Named(NamedKey::ArrowRight) => self.shift_selected_days(1),
461             Key::Named(NamedKey::ArrowUp) => self.shift_selected_days(-7),
462             Key::Named(NamedKey::ArrowDown) => self.shift_selected_days(7),
463             Key::Named(NamedKey::PageUp) => self.shift_months(-1),
464             Key::Named(NamedKey::PageDown) => self.shift_months(1),
465             Key::Named(NamedKey::Home) => self.select(self.today),
466             Key::Named(NamedKey::Enter) => self.input = Some(String::new()),
467             Key::Named(NamedKey::Delete) => self.delete_selected_event(),
468             Key::Character(c) => match c.as_str() {
469                 "t" => self.select(self.today),
470                 "n" => self.input = Some(String::new()),
471                 "d" | "x" => self.delete_selected_event(),
472                 "[" => self.shift_months(-12),
473                 "]" => self.shift_months(12),
474                 "q" => quit = true,
475                 _ => handled = false,
476             },
477             _ => handled = false,
478         }
479         (handled, quit)
480     }
481 
482     // ── painting ──────────────────────────────────────────────────────────
483 
484     fn boxed(rect: Rect, align_h: AlignH) -> TextLayout {
485         TextLayout {
486             wrap_width: Some(rect.width),
487             box_height: rect.height,
488             align_h,
489             align_v: AlignV::Middle,
490         }
491     }
492 
493     fn paint_header(&self, pc: &mut PaintCtx, g: &Geom) {
494         let bold = TextAttrs { italic: false, weight: Some(700) };
495         for (rect, glyph) in [(&g.prev_btn, "‹"), (&g.next_btn, "›")] {
496             pc.rounded_rect(*rect, 6.0, (true, true, true, true), [1.0, 1.0, 1.0, 0.07]);
497             pc.text_boxed(glyph, rect.x, rect.y - 1.0, 18.0, TEXT, None, None, bold,
498                 Self::boxed(*rect, AlignH::Center));
499         }
500         let title = Rect {
501             x: g.prev_btn.x + g.prev_btn.width,
502             y: g.header.y,
503             width: g.next_btn.x - (g.prev_btn.x + g.prev_btn.width),
504             height: HEADER_H,
505         };
506         let label = format!("{} {}", MONTHS[self.view.1 as usize - 1], self.view.0);
507         pc.text_boxed(label, title.x, title.y, 16.0, TEXT, None, None, bold,
508             Self::boxed(title, AlignH::Center));
509         pc.rounded_rect(g.today_btn, 6.0, (true, true, true, true), [1.0, 1.0, 1.0, 0.07]);
510         pc.text_boxed("Today", g.today_btn.x, g.today_btn.y, 12.0, TEXT_DIM, None, None,
511             TextAttrs::default(), Self::boxed(g.today_btn, AlignH::Center));
512     }
513 
514     fn paint_grid(&self, pc: &mut PaintCtx, g: &Geom) {
515         for (i, name) in WEEKDAYS.iter().cycle()
516             .skip(self.week_start.num_days_from_monday() as usize)
517             .take(7)
518             .enumerate()
519         {
520             let rect = Rect {
521                 x: g.grid.x + i as f32 * g.cell_w,
522                 y: g.header.y + g.header.height,
523                 width: g.cell_w,
524                 height: WEEKDAY_H,
525             };
526             pc.text_boxed(*name, rect.x, rect.y, 11.0, TEXT_FAINT, None, None,
527                 TextAttrs::default(), Self::boxed(rect, AlignH::Center));
528         }
529 
530         for i in 0..42u64 {
531             let Some(date) = g.first_day.checked_add_days(Days::new(i)) else { continue };
532             let (row, col) = (i / 7, i % 7);
533             let cell = Rect {
534                 x: g.grid.x + col as f32 * g.cell_w,
535                 y: g.grid.y + row as f32 * g.cell_h,
536                 width: g.cell_w,
537                 height: g.cell_h,
538             };
539             let in_month = (date.year(), date.month()) == self.view;
540             if !in_month {
541                 pc.quad(cell, BG_OTHER_MONTH);
542             }
543             // style: deliberate — the cells are hairline-separated grid
544             // cells, not plates: the selection ring sits 1px inside the
545             // cell with a 2px band, and the day number, dots and chips are
546             // tight typographic offsets, not ladder spacings.
547             if date == self.selected {
548                 let r = Rect {
549                     x: cell.x + 1.0,
550                     y: cell.y + 1.0,
551                     width: cell.width - 2.0,
552                     height: cell.height - 2.0,
553                 };
554                 pc.rounded_rect(r, 5.0, (true, true, true, true), [ACCENT[0], ACCENT[1], ACCENT[2], 0.55]);
555                 let inner = Rect { x: r.x + 2.0, y: r.y + 2.0, width: r.width - 4.0, height: r.height - 4.0 };
556                 pc.rounded_rect(inner, 4.0, (true, true, true, true), if in_month { BG } else { BG_OTHER_MONTH });
557             }
558 
559             // Day number, top-left; today gets an accent pill.
560             let num = Rect { x: cell.x + 6.0, y: cell.y + 4.0, width: 24.0, height: 17.0 };
561             if date == self.today {
562                 pc.rounded_rect(num, 8.0, (true, true, true, true), ACCENT);
563             }
564             let num_color = if date == self.today {
565                 [255, 255, 255]
566             } else if in_month {
567                 TEXT
568             } else {
569                 TEXT_FAINT
570             };
571             pc.text_boxed(date.day().to_string(), num.x, num.y, 11.5, num_color, None, None,
572                 TextAttrs { italic: false, weight: Some(600) }, Self::boxed(num, AlignH::Center));
573 
574             // Event chips: dot + clipped title, then a "+N" overflow line.
575             if let Some(events) = self.events.get(&date) {
576                 let line_h = 15.0;
577                 let avail = ((cell.height - 26.0) / line_h).max(0.0) as usize;
578                 let shown = if avail >= events.len() { events.len() } else { avail.saturating_sub(1) };
579                 pc.clip(cell, |pc| {
580                     let mut y = cell.y + 24.0;
581                     for e in events.iter().take(shown) {
582                         let dot = if e.source.is_some() { SYNC_DOT } else { EVENT_DOT };
583                         pc.circle(cell.x + 9.0, y + 6.0, 2.5, dot);
584                         let alpha = if in_month { TEXT } else { TEXT_DIM };
585                         pc.text_with(e.title.clone(), cell.x + 15.0, y, 10.0, alpha, None,
586                             Some([cell.x, cell.y, cell.x + cell.width - 4.0, cell.y + cell.height]));
587                         y += line_h;
588                     }
589                     if events.len() > shown {
590                         pc.text_with(format!("+{} more", events.len() - shown), cell.x + 15.0, y,
591                             10.0, TEXT_FAINT, None, None);
592                     }
593                 });
594             }
595         }
596 
597         for col in 1..7 {
598             let x = g.grid.x + col as f32 * g.cell_w;
599             pc.quad(Rect { x, y: g.grid.y, width: 1.0, height: g.grid.height }, GRID_LINE);
600         }
601         for row in 0..6 {
602             let y = g.grid.y + row as f32 * g.cell_h;
603             pc.quad(Rect { x: g.grid.x, y, width: g.grid.width, height: 1.0 }, GRID_LINE);
604         }
605     }
606 
607     /// Sidebar event-row rects, matching `paint_sidebar` (shared with hit-testing).
608     fn sidebar_row(&self, g: &Geom, idx: usize) -> Rect {
609         let pad = plate_padding();
610         Rect {
611             x: g.sidebar.x + pad,
612             y: g.list.y + idx as f32 * ROW_H - self.sidebar_scroll,
613             width: (g.sidebar.width - 2.0 * pad).max(0.0),
614             // style: deliberate — a 4px hairline between rows in a list,
615             // not a rung gap.
616             height: ROW_H - 4.0,
617         }
618     }
619 
620     fn paint_sidebar(&self, pc: &mut PaintCtx, g: &Geom) {
621         // The sidebar is a pane plate standing on the root plate, in the
622         // app's own colour (the DE pane material would swallow this app's
623         // faint text). Inset from every window edge, so no corner is on
624         // the silhouette; the flags are derived anyway so a future
625         // edge-to-edge layout rounds correctly.
626         let (w, h) = self.win;
627         pc.plate_spec(&PlateSpec {
628             rect: g.sidebar,
629             material: Material::opaque(SIDEBAR_BG),
630             window_corners: PlateSpec::window_corner_flags(g.sidebar, w, h),
631             depth: bevel_width(),
632         });
633 
634         let pad = plate_padding();
635         let heading = format!(
636             "{}, {} {}",
637             WEEKDAYS[self.selected.weekday().num_days_from_monday() as usize],
638             MONTHS[self.selected.month() as usize - 1],
639             self.selected.day()
640         );
641         let color = if self.selected == self.today { TEXT_ACCENT } else { TEXT };
642         let head_y = g.sidebar.y + pad;
643         pc.text(heading, g.sidebar.x + pad, head_y, 14.0, color);
644         if self.selected == self.today {
645             // The heading block's second line (a line advance, not a gap).
646             pc.text("today", g.sidebar.x + pad, head_y + 20.0, 10.5, TEXT_FAINT);
647         }
648 
649         let events = self.events.get(&self.selected).map(Vec::as_slice).unwrap_or(&[]);
650         // style: deliberate — the text offsets inside a row and inside the
651         // strip (+8/+9/+12/+54, the -4/-8 clip margins) are a control's own
652         // text insets, not ladder spacings.
653         pc.clip(g.list, |pc| {
654             if events.is_empty() {
655                 pc.text("No events", g.sidebar.x + pad, g.list.y + 9.0, 12.0, TEXT_FAINT);
656             }
657             for (i, e) in events.iter().enumerate() {
658                 let row = self.sidebar_row(g, i);
659                 if self.sel_event == Some(i) {
660                     pc.rounded_rect(row, 5.0, (true, true, true, true), ROW_SEL);
661                 }
662                 let time = e.time.map_or("——".to_string(), |(h, m)| format!("{h:02}:{m:02}"));
663                 pc.text(time, row.x + 8.0, row.y + 9.0, 11.0,
664                     if e.time.is_some() { TEXT_ACCENT } else { TEXT_FAINT });
665                 pc.text_with(e.title.clone(), row.x + 54.0, row.y + 8.0, 12.5, TEXT, None,
666                     Some([row.x, row.y, row.x + row.width - 4.0, row.y + row.height]));
667             }
668         });
669 
670         // Bottom strip: the input field while typing, else the key hints.
671         let strip = g.strip;
672         if let Some(buffer) = &self.input {
673             pc.rounded_rect(strip, 6.0, (true, true, true, true), [0.0, 0.0, 0.0, 0.35]);
674             pc.rounded_rect(
675                 Rect { x: strip.x, y: strip.y + strip.height - 2.0, width: strip.width, height: 2.0 },
676                 1.0, (true, true, true, true), ACCENT);
677             let shown = if buffer.is_empty() { "HH:MM title".to_string() } else { format!("{buffer}▏") };
678             let color = if buffer.is_empty() { TEXT_FAINT } else { TEXT };
679             pc.text_with(shown, strip.x + 8.0, strip.y + 9.0, 12.5, color, None,
680                 Some([strip.x, strip.y, strip.x + strip.width - 8.0, strip.y + strip.height]));
681         } else {
682             let hint = if let Some(err) = &self.status {
683                 err.clone()
684             } else if self.sel_event.is_some() {
685                 "d delete · n new · t today".to_string()
686             } else {
687                 "n new · t today · pgup/pgdn month".to_string()
688             };
689             let color = if self.status.is_some() { [230, 130, 120] } else { TEXT_FAINT };
690             pc.text(hint, strip.x, strip.y + 12.0, 10.5, color);
691         }
692     }
693 }
694 
695 impl Application for CalendarApp {
696     type Message = Message;
697 
698     fn new(_qh: &QueueHandle<EngineState<Self>>, _sender: calloop::channel::Sender<Self::Message>) -> Self {
699         let today = Local::now().date_naive();
700         Self {
701             events: load_events(),
702             view: (today.year(), today.month()),
703             selected: today,
704             sel_event: None,
705             input: None,
706             week_start: week_start_config(),
707             today,
708             win: (1060.0, 720.0),
709             sidebar_scroll: 0.0,
710             sidebar_motion: ScrollMotion::new(),
711             month_wheel_px: 0.0,
712             status: None,
713             file_mtime: file_mtime(),
714             watch_at: std::time::Instant::now(),
715         }
716     }
717 
718     fn settings(&self) -> WindowSettings {
719         WindowSettings {
720             title: "Calendar".to_string(),
721             app_id: "cce-calendar".to_string(),
722             width: 1060,
723             height: 720,
724             fullscreen: false,
725             min_size: Some((700, 480)),
726         }
727     }
728 
729     fn update(&mut self, msg: Self::Message, _needs_rebuild: &mut bool, exit: &mut bool) {
730         match msg {
731             Message::Quit => *exit = true,
732         }
733     }
734 
735     /// The mtime watch in `tick` is work the runner cannot see — nothing
736     /// redraws until the file changes underneath us — so name the cadence the
737     /// loop has to come back at.
738     fn idle_poll_interval(&self) -> Option<std::time::Duration> {
739         Some(WATCH_EVERY)
740     }
741 
742     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
743         let now = Local::now().date_naive();
744         if now != self.today {
745             self.today = now;
746             *needs_rebuild = true;
747         }
748         if self.tick_sidebar_scroll(dt) {
749             *needs_rebuild = true;
750         }
751         // The sync timer rewrites events.json; pick that up without a
752         // relaunch — but not mid-typing, which a reload would clobber.
753         let now_i = std::time::Instant::now();
754         if now_i >= self.watch_at {
755             self.watch_at = now_i + WATCH_EVERY;
756             if self.input.is_none() && file_mtime() != self.file_mtime {
757                 self.reload();
758                 *needs_rebuild = true;
759             }
760         }
761     }
762 
763     fn handle_resize(&mut self, width: f32, height: f32, _scale: f64) {
764         self.win = (width, height);
765     }
766 
767     fn handle_pointer_move(&mut self, _pos: LogicalPosition, _needs_rebuild: &mut bool) {}
768 
769     fn handle_mouse_input(
770         &mut self,
771         button: MouseButton,
772         state: ElementState,
773         pos: LogicalPosition,
774         needs_rebuild: &mut bool,
775     ) -> Option<Self::Message> {
776         if button != MouseButton::Left || state != ElementState::Pressed {
777             return None;
778         }
779         let g = self.geom();
780         let (x, y) = (pos.x, pos.y);
781         if hit(&g.prev_btn, x, y) {
782             self.shift_months(-1);
783         } else if hit(&g.next_btn, x, y) {
784             self.shift_months(1);
785         } else if hit(&g.today_btn, x, y) {
786             self.select(self.today);
787         } else if let Some(date) = self.day_at(&g, x, y) {
788             self.select(date);
789         } else if hit(&g.sidebar, x, y) {
790             let events = self.events.get(&self.selected).map_or(0, Vec::len);
791             self.sel_event = (0..events).find(|&i| hit(&self.sidebar_row(&g, i), x, y));
792         } else {
793             return None;
794         }
795         *needs_rebuild = true;
796         None
797     }
798 
799     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
800         let g = self.geom();
801         if hit(&g.sidebar, pos.x, pos.y) {
802             // A notch is one row, pixel deltas are 1:1. The motion glides
803             // notches and coasts a flick; `tick_sidebar_scroll` carries the
804             // drawn offset after it. A true return is the repaint signal.
805             let overflow = self.sidebar_overflow(&g);
806             self.sidebar_motion.reconcile(0.0, self.sidebar_scroll);
807             if self.sidebar_motion.apply(delta, (ROW_H, ROW_H), Bounds::max(0.0), Bounds::max(overflow)) {
808                 self.sidebar_scroll = self.sidebar_motion.y.pos();
809                 *needs_rebuild = true;
810             }
811         } else {
812             // Month stepping stays discrete: one month per wheel notch. A
813             // trackpad gesture accumulates pixels and steps once per
814             // MONTH_STEP_PX instead of once per pixel event, carrying the
815             // remainder until the finger lifts.
816             let step = match delta {
817                 MouseScrollDelta::LineDelta(_, y) => {
818                     self.month_wheel_px = 0.0;
819                     if *y < 0.0 {
820                         1
821                     } else if *y > 0.0 {
822                         -1
823                     } else {
824                         0
825                     }
826                 }
827                 MouseScrollDelta::PixelDelta(p) => {
828                     if current_scroll_phase() == ScrollPhase::FingerEnd {
829                         self.month_wheel_px = 0.0;
830                         0
831                     } else {
832                         self.month_wheel_px += p.y as f32;
833                         if self.month_wheel_px <= -MONTH_STEP_PX {
834                             self.month_wheel_px += MONTH_STEP_PX;
835                             1
836                         } else if self.month_wheel_px >= MONTH_STEP_PX {
837                             self.month_wheel_px -= MONTH_STEP_PX;
838                             -1
839                         } else {
840                             0
841                         }
842                     }
843                 }
844             };
845             if step != 0 {
846                 self.shift_months(step);
847                 *needs_rebuild = true;
848             }
849         }
850     }
851 
852     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
853         if event.state != ElementState::Pressed {
854             return None;
855         }
856         if self.input.is_some() {
857             self.key_input_mode(event, needs_rebuild);
858             return None;
859         }
860         let (handled, quit) = self.key_browse_mode(event);
861         if handled {
862             *needs_rebuild = true;
863         }
864         quit.then_some(Message::Quit)
865     }
866 
867     fn display_list(&mut self, size: LogicalSize, _scale: f64) -> Option<DisplayList> {
868         self.win = (size.width, size.height);
869         let g = self.geom();
870         let mut pc = PaintCtx::new();
871         // The standard root plate (cce-ui PlateSpec::window).
872         pc.root_plate(size.width, size.height);
873         self.paint_header(&mut pc, &g);
874         self.paint_grid(&mut pc, &g);
875         self.paint_sidebar(&mut pc, &g);
876         Some(pc.finish())
877     }
878 
879     fn display_list_text(&self) -> bool {
880         true
881     }
882 
883     fn clear_color(&self) -> [f32; 4] {
884         BG
885     }
886 }
887 
888 fn main() {
889     env_logger::init();
890     cce_ui::engine::run::<CalendarApp>();
891 }