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

src/main.rs (46.1K)

   1 //! cce-calendar — month-view calendar with per-day events.
   2 //!
   3 //! A 6×7 month grid on the left, a day pane (a well in the root plate) on
   4 //! the right. Events live in
   5 //! `$XDG_DATA_HOME/cce/calendar/events.json` (one flat list of
   6 //! date/time/title records) and are saved on every mutation. Records with a
   7 //! `source` are mirrored from a remote calendar by `cce-calendar-sync`
   8 //! (blue dot). Events typed here are pushed to the default calendar on the
   9 //! next sync tick and come back carrying their identity; deleting a synced
  10 //! event here deletes it on the server — except instances of a recurring
  11 //! event, which are read-only mirrors (the app refuses). The file is
  12 //! re-read once a second when the sync rewrites it.
  13 //!
  14 //! Keys: arrows move the selected day · PageUp/PageDown month · [/] year ·
  15 //! t/Home today · n/Enter new event (a leading `HH:MM` token sets the
  16 //! time) · d/Delete remove the clicked event · q quit. The wheel flips
  17 //! months over the grid and scrolls the event list over the pane.
  18 //!
  19 //! Config (`~/.config/cce/cce-calendar/config.kdl`): `week-start "sunday"`
  20 //! (default monday); `push-to "icloud"` / `"google"` / `"none"`, optionally
  21 //! `calendar="Name"`, picks where typed events are created (the sync's
  22 //! default is iCloud when such an account exists).
  23 
  24 use std::collections::BTreeMap;
  25 
  26 use cce_calendar::{load_records, save_records, EventRecord};
  27 use chrono::{Datelike, Days, Local, NaiveDate, Weekday};
  28 
  29 use cce_ui::engine::{Application, LogicalPosition, LogicalSize, WindowSettings};
  30 use cce_ui::colors::{
  31     button_background_color, button_hover_color, button_press_color, control_label_color_u8,
  32     highlight_primary_color, list_font_color, textbox_background_color, textbox_placeholder_text_color,
  33     well_frame_color,
  34 };
  35 use cce_ui::layout::{
  36     align_text_y, bevel_width, button_corner_radius, button_font, button_height, carve_inside,
  37     control_label_font_detached, control_label_font_detached_parsed, control_relief, list_font,
  38     list_font_parsed, parse_font_string, plate_corner_radius, statusbar_font, statusbar_font_parsed,
  39     textbox_corner_radius, textbox_height, CONTROL_TEXT_INSET, plate_gap, plate_padding, root_plate_gap, root_plate_inset,
  40 };
  41 use cce_ui::scene::layout::Rect;
  42 use cce_ui::scene::paint::{AlignH, AlignV, ControlPlate, DisplayList, PaintCtx, PlateStance, TextAttrs, TextLayout};
  43 use cce_ui::scene::Material;
  44 use cce_ui::widget::scroll_motion::{current_scroll_phase, Bounds, ScrollMotion, ScrollPhase};
  45 use cce_ui::widget::{Button, ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, Paint};
  46 
  47 /// How often events.json is re-stat'd. The runner wakes an idle app once a
  48 /// second anyway; `idle_poll_interval` pins that rather than inheriting it.
  49 const WATCH_EVERY: std::time::Duration = std::time::Duration::from_secs(1);
  50 
  51 const HEADER_H: f32 = 46.0;
  52 const WEEKDAY_H: f32 = 24.0;
  53 const SIDEBAR_W: f32 = 300.0;
  54 const ROW_H: f32 = 36.0;
  55 const INPUT_H: f32 = 40.0;
  56 /// The sidebar's heading block: the day's name on one line, "today" under
  57 /// it. Two text lines, so a size, not a spacing.
  58 const SIDEBAR_HEAD_H: f32 = 40.0;
  59 /// Trackpad travel per month step over the grid (a wheel notch is one step).
  60 const MONTH_STEP_PX: f32 = 48.0;
  61 
  62 const BG: [f32; 4] = [0.075, 0.08, 0.09, 1.0];
  63 const BG_OTHER_MONTH: [f32; 4] = [0.06, 0.064, 0.072, 1.0];
  64 const GRID_LINE: [f32; 4] = [1.0, 1.0, 1.0, 0.06];
  65 /// The grid lines' drawn width. The lines are the grout between rounded
  66 /// cells, so every crossing carries the cells' corners as concave fillets.
  67 const GRID_LINE_W: f32 = 2.0;
  68 const ACCENT: [f32; 4] = [0.22, 0.42, 0.85, 1.0];
  69 const EVENT_DOT: [f32; 4] = [0.95, 0.72, 0.30, 1.0];
  70 /// Dot for events mirrored from a remote calendar (`source` set).
  71 const SYNC_DOT: [f32; 4] = [0.42, 0.68, 0.95, 1.0];
  72 const TEXT: [u8; 3] = [225, 228, 232];
  73 const TEXT_DIM: [u8; 3] = [140, 145, 152];
  74 const TEXT_FAINT: [u8; 3] = [95, 100, 108];
  75 const TEXT_ACCENT: [u8; 3] = [120, 165, 255];
  76 
  77 /// The month header's three buttons, for hover and press tracking.
  78 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
  79 enum HeaderBtn {
  80     Prev,
  81     Next,
  82     Today,
  83 }
  84 
  85 /// The "Today" button's width; the arrows are square at the button height.
  86 const TODAY_BTN_W: f32 = 64.0;
  87 /// The month title's width between the two arrows.
  88 const TITLE_W: f32 = 190.0;
  89 
  90 #[derive(Debug, Clone)]
  91 enum Message {
  92     Quit,
  93 }
  94 
  95 /// One event on a day. `time` is (hour, minute); untimed events sort after
  96 /// timed ones. `uid`/`source` ride along from synced records so a save from
  97 /// this app never strips what `cce-calendar-sync` wrote.
  98 #[derive(Clone, Debug)]
  99 struct Event {
 100     time: Option<(u32, u32)>,
 101     title: String,
 102     uid: Option<String>,
 103     source: Option<String>,
 104     recurring: bool,
 105 }
 106 
 107 fn load_events() -> BTreeMap<NaiveDate, Vec<Event>> {
 108     let mut map: BTreeMap<NaiveDate, Vec<Event>> = BTreeMap::new();
 109     let records = match load_records() {
 110         Ok(r) => r,
 111         Err(e) => {
 112             log::error!("events.json unreadable, starting empty: {e}");
 113             return map;
 114         }
 115     };
 116     for rec in records {
 117         let Ok(date) = rec.date.parse::<NaiveDate>() else {
 118             continue;
 119         };
 120         let time = rec.time.as_deref().and_then(parse_time);
 121         map.entry(date).or_default().push(Event {
 122             time,
 123             title: rec.title,
 124             uid: rec.uid,
 125             source: rec.source,
 126             recurring: rec.recurring,
 127         });
 128     }
 129     for events in map.values_mut() {
 130         sort_events(events);
 131     }
 132     map
 133 }
 134 
 135 fn sort_events(events: &mut [Event]) {
 136     events.sort_by_key(|e| e.time.map_or((1, 0, 0), |(h, m)| (0, h, m)));
 137 }
 138 
 139 /// `"H:MM"` / `"HH:MM"` → (hour, minute).
 140 fn parse_time(tok: &str) -> Option<(u32, u32)> {
 141     let (h, m) = tok.split_once(':')?;
 142     let (h, m) = (h.parse::<u32>().ok()?, m.parse::<u32>().ok()?);
 143     (h < 24 && m < 60 && !tok.starts_with('+')).then_some((h, m))
 144 }
 145 
 146 /// A leading `HH:MM` token becomes the event time; the rest is the title.
 147 fn parse_event(raw: &str) -> Option<Event> {
 148     let raw = raw.trim();
 149     if raw.is_empty() {
 150         return None;
 151     }
 152     let (time, title) = match raw.split_once(char::is_whitespace) {
 153         Some((tok, rest)) if parse_time(tok).is_some() => (parse_time(tok), rest.trim().to_string()),
 154         _ => match parse_time(raw) {
 155             Some(t) => (Some(t), String::new()),
 156             None => (None, raw.to_string()),
 157         },
 158     };
 159     let title = if title.is_empty() { "(untitled)".to_string() } else { title };
 160     Some(Event { time, title, uid: None, source: None, recurring: false })
 161 }
 162 
 163 fn week_start_config() -> Weekday {
 164     let path = cce_ui::config::get_app_config_path("cce-calendar");
 165     if let Ok(text) = std::fs::read_to_string(path) {
 166         if let Ok(doc) = text.parse::<kdl::KdlDocument>() {
 167             if let Some(v) = doc
 168                 .get("week-start")
 169                 .and_then(|n| n.entries().first())
 170                 .and_then(|e| e.value().as_string())
 171             {
 172                 return match v.to_ascii_lowercase().as_str() {
 173                     "sunday" | "sun" => Weekday::Sun,
 174                     "saturday" | "sat" => Weekday::Sat,
 175                     _ => Weekday::Mon,
 176                 };
 177             }
 178         }
 179     }
 180     Weekday::Mon
 181 }
 182 
 183 fn add_months(year: i32, month: u32, delta: i32) -> (i32, u32) {
 184     let idx = year * 12 + month as i32 - 1 + delta;
 185     (idx.div_euclid(12), (idx.rem_euclid(12) + 1) as u32)
 186 }
 187 
 188 fn days_in_month(year: i32, month: u32) -> u32 {
 189     let (ny, nm) = add_months(year, month, 1);
 190     NaiveDate::from_ymd_opt(ny, nm, 1)
 191         .and_then(|d| d.pred_opt())
 192         .map_or(28, |d| d.day())
 193 }
 194 
 195 const MONTHS: [&str; 12] = [
 196     "January", "February", "March", "April", "May", "June", "July", "August", "September",
 197     "October", "November", "December",
 198 ];
 199 const WEEKDAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
 200 
 201 fn weekday_offset(day: Weekday, week_start: Weekday) -> u32 {
 202     (day.num_days_from_monday() + 7 - week_start.num_days_from_monday()) % 7
 203 }
 204 
 205 /// The window geometry every frame and every hit-test derive from.
 206 struct Geom {
 207     /// The month header band across the top of the grid column.
 208     header: Rect,
 209     prev_btn: Rect,
 210     next_btn: Rect,
 211     today_btn: Rect,
 212     /// The month grid's frame, outer edge of the outer lines.
 213     grid: Rect,
 214     /// The pitch: one line centre to the next.
 215     cell_w: f32,
 216     cell_h: f32,
 217     /// A cell face's corner radius — the fillet at every line crossing.
 218     cell_radius: f32,
 219     sidebar: Rect,
 220     /// The event list's clip inside the sidebar well: full well width,
 221     /// between the heading block and the bottom strip.
 222     list: Rect,
 223     /// The bottom strip inside the sidebar well — the input field while
 224     /// typing, else the key hints.
 225     strip: Rect,
 226     /// Date of the grid's top-left cell (always a 42-day window).
 227     first_day: NaiveDate,
 228 }
 229 
 230 impl Geom {
 231     /// Column `col`'s left line centre (`col` 7 is the right frame line).
 232     fn col_x(&self, col: u64) -> f32 {
 233         self.grid.x + GRID_LINE_W / 2.0 + col as f32 * self.cell_w
 234     }
 235 
 236     fn row_y(&self, row: u64) -> f32 {
 237         self.grid.y + GRID_LINE_W / 2.0 + row as f32 * self.cell_h
 238     }
 239 
 240     /// A cell's face: the pitch box less half a line on every side — the
 241     /// rounded pocket the grout leaves.
 242     fn cell(&self, row: u64, col: u64) -> Rect {
 243         Rect {
 244             x: self.col_x(col) + GRID_LINE_W / 2.0,
 245             y: self.row_y(row) + GRID_LINE_W / 2.0,
 246             width: (self.cell_w - GRID_LINE_W).max(0.0),
 247             height: (self.cell_h - GRID_LINE_W).max(0.0),
 248         }
 249     }
 250 }
 251 
 252 /// `text`'s advance in `font` at `size`, shaped as the renderer draws it —
 253 /// what a caret or a column after the text must be placed by.
 254 fn shaped_width(text: &str, size: f32, font: &str) -> f32 {
 255     let mut fs = cce_ui::geometry_font_system().lock().unwrap_or_else(|e| e.into_inner());
 256     cce_ui::backend::window_runner::shaped_cluster_offsets(&mut fs, text, size, Some(font))
 257         .last()
 258         .map_or(0.0, |&(_, x)| x)
 259 }
 260 
 261 
 262 struct CalendarApp {
 263     events: BTreeMap<NaiveDate, Vec<Event>>,
 264     /// Displayed (year, month).
 265     view: (i32, u32),
 266     selected: NaiveDate,
 267     /// Index into the selected day's (sorted) events, for deletion.
 268     sel_event: Option<usize>,
 269     /// `Some(buffer)` while typing a new event.
 270     input: Option<String>,
 271     week_start: Weekday,
 272     today: NaiveDate,
 273     win: (f32, f32),
 274     sidebar_scroll: f32,
 275     /// Drives `sidebar_scroll` (the drawn value) from the wheel: notches
 276     /// glide, fingers track 1:1 and fling on the lift. `select` resets the
 277     /// offset directly; the motion adopts that through `reconcile`.
 278     sidebar_motion: ScrollMotion,
 279     /// Trackpad pixels accumulated toward the next month step over the grid,
 280     /// so a gesture flips one month per MONTH_STEP_PX rather than one per
 281     /// pixel event. Cleared by a wheel notch and by the finger lift.
 282     month_wheel_px: f32,
 283     status: Option<String>,
 284     /// The header button under the pointer, and the one held down.
 285     hover_btn: Option<HeaderBtn>,
 286     pressed_btn: Option<HeaderBtn>,
 287     /// The day view's event row under the pointer.
 288     hover_row: Option<usize>,
 289     /// events.json as last read: mtime, polled once a second so the sync's
 290     /// rewrites (pushed identities, phone-side changes) show up live.
 291     file_mtime: Option<std::time::SystemTime>,
 292     /// When the mtime check below may run again. A wall clock, not an
 293     /// accumulation of `tick`'s `dt`: `dt` is animation time, clamped to one
 294     /// frame after an idle sleep, and a calendar nobody is touching is idle —
 295     /// so the "once a second" watch actually ran about once a minute.
 296     watch_at: std::time::Instant,
 297 }
 298 
 299 fn file_mtime() -> Option<std::time::SystemTime> {
 300     std::fs::metadata(cce_calendar::data_path()).and_then(|m| m.modified()).ok()
 301 }
 302 
 303 impl CalendarApp {
 304     fn geom(&self) -> Geom {
 305         let (w, h) = self.win;
 306         // Everything stands on the root plate `root_plate_inset` in from
 307         // the window edge; the grid column and the sidebar well are
 308         // siblings on it, `root_plate_gap` apart:
 309         // [inset][header+grid][gap][sidebar][inset].
 310         let inset = root_plate_inset();
 311         let gap = root_plate_gap();
 312         let sidebar_w = SIDEBAR_W.min(w * 0.4);
 313         let grid_w = (w - 2.0 * inset - gap - sidebar_w).max(0.0);
 314         let header = Rect { x: inset, y: inset, width: grid_w, height: HEADER_H };
 315         let grid = Rect {
 316             x: inset,
 317             y: header.y + HEADER_H + WEEKDAY_H,
 318             width: grid_w,
 319             height: (h - inset - (header.y + HEADER_H + WEEKDAY_H)).max(0.0),
 320         };
 321         let btn_h = button_height().min(HEADER_H);
 322         let btn = |x: f32, w: f32| Rect { x, y: header.y + (HEADER_H - btn_h) / 2.0, width: w, height: btn_h };
 323         let sidebar = Rect {
 324             x: header.x + grid_w + gap,
 325             y: inset,
 326             width: sidebar_w,
 327             height: (h - 2.0 * inset).max(0.0),
 328         };
 329         // Inside the sidebar well: `plate_padding` off its rim, the
 330         // heading block, the list and the bottom strip `plate_gap` apart.
 331         let pad = plate_padding();
 332         let strip_h = INPUT_H - 8.0;
 333         let strip = Rect {
 334             x: sidebar.x + pad,
 335             y: sidebar.y + sidebar.height - pad - strip_h,
 336             width: (sidebar.width - 2.0 * pad).max(0.0),
 337             height: strip_h,
 338         };
 339         let list_y = sidebar.y + pad + SIDEBAR_HEAD_H + plate_gap();
 340         let list = Rect {
 341             x: sidebar.x,
 342             y: list_y,
 343             width: sidebar.width,
 344             height: (strip.y - plate_gap() - list_y).max(0.0),
 345         };
 346         // Seven pitches plus one line span the frame, so the outer lines
 347         // are as wide as the inner ones.
 348         let cell_w = ((grid.width - GRID_LINE_W) / 7.0).max(0.0);
 349         let cell_h = ((grid.height - GRID_LINE_W) / 6.0).max(0.0);
 350         let cell_radius = plate_corner_radius()
 351             .min((cell_w.min(cell_h) - GRID_LINE_W).max(0.0) / 4.0);
 352         let first_of_month = NaiveDate::from_ymd_opt(self.view.0, self.view.1, 1)
 353             .unwrap_or(self.today);
 354         let back = weekday_offset(first_of_month.weekday(), self.week_start);
 355         let first_day = first_of_month
 356             .checked_sub_days(Days::new(back as u64))
 357             .unwrap_or(first_of_month);
 358         Geom {
 359             header,
 360             prev_btn: btn(header.x, btn_h),
 361             next_btn: btn(header.x + btn_h + TITLE_W, btn_h),
 362             today_btn: btn(header.x + header.width - TODAY_BTN_W, TODAY_BTN_W),
 363             grid,
 364             cell_w,
 365             cell_h,
 366             cell_radius,
 367             sidebar,
 368             list,
 369             strip,
 370             first_day,
 371         }
 372     }
 373 
 374     fn day_at(&self, g: &Geom, x: f32, y: f32) -> Option<NaiveDate> {
 375         if !g.grid.contains(x, y) {
 376             return None;
 377         }
 378         let col = ((x - g.col_x(0)).max(0.0) / g.cell_w) as u64;
 379         let row = ((y - g.row_y(0)).max(0.0) / g.cell_h) as u64;
 380         g.first_day.checked_add_days(Days::new(row.min(5) * 7 + col.min(6)))
 381     }
 382 
 383     fn header_btn_at(g: &Geom, x: f32, y: f32) -> Option<HeaderBtn> {
 384         [(HeaderBtn::Prev, g.prev_btn), (HeaderBtn::Next, g.next_btn), (HeaderBtn::Today, g.today_btn)]
 385             .into_iter()
 386             .find(|(_, r)| r.contains(x, y))
 387             .map(|(b, _)| b)
 388     }
 389 
 390     /// The selected day's event row at (x, y), if any — only inside the
 391     /// list's clip, so a row scrolled under the heading or strip is not hit.
 392     fn row_at(&self, g: &Geom, x: f32, y: f32) -> Option<usize> {
 393         if !g.list.contains(x, y) {
 394             return None;
 395         }
 396         let events = self.events.get(&self.selected).map_or(0, Vec::len);
 397         (0..events).find(|&i| self.sidebar_row(g, i).contains(x, y))
 398     }
 399 
 400     /// A row's state wash: the colour cce-ui's list-row Button wears in the
 401     /// same state (selected, hovered), asked of the toolkit rather than
 402     /// copied, so the rows follow it. Transparent at rest.
 403     fn row_wash(&self, i: usize) -> [f32; 4] {
 404         let mut row = Button::new_list_row(0.0, 0.0, 0.0, 0.0).with_selected(self.sel_event == Some(i));
 405         row.set_hovered(self.hover_row == Some(i));
 406         row.color()
 407     }
 408 
 409     fn select(&mut self, date: NaiveDate) {
 410         self.selected = date;
 411         self.sel_event = None;
 412         self.sidebar_scroll = 0.0;
 413         self.status = None;
 414         if (date.year(), date.month()) != self.view {
 415             self.view = (date.year(), date.month());
 416         }
 417     }
 418 
 419     /// How far the selected day's event rows overflow the sidebar's list area.
 420     fn sidebar_overflow(&self, g: &Geom) -> f32 {
 421         let events = self.events.get(&self.selected).map_or(0, Vec::len);
 422         (events as f32 * ROW_H - g.list.height).max(0.0)
 423     }
 424 
 425     /// Per-frame sidebar glide/coast; true while `sidebar_scroll` is still
 426     /// moving, so the frame loop keeps drawing.
 427     fn tick_sidebar_scroll(&mut self, dt: f32) -> bool {
 428         self.sidebar_motion.reconcile(0.0, self.sidebar_scroll);
 429         if !self.sidebar_motion.is_animating() {
 430             return false;
 431         }
 432         let g = self.geom();
 433         let overflow = self.sidebar_overflow(&g);
 434         let moved = self.sidebar_motion.tick(dt, Bounds::max(0.0), Bounds::max(overflow));
 435         self.sidebar_scroll = self.sidebar_motion.y.pos();
 436         moved || self.sidebar_motion.is_animating()
 437     }
 438 
 439     fn shift_months(&mut self, delta: i32) {
 440         let (y, m) = add_months(self.view.0, self.view.1, delta);
 441         self.view = (y, m);
 442         let day = self.selected.day().min(days_in_month(y, m));
 443         if let Some(d) = NaiveDate::from_ymd_opt(y, m, day) {
 444             self.selected = d;
 445             self.sel_event = None;
 446         }
 447     }
 448 
 449     fn shift_selected_days(&mut self, delta: i64) {
 450         let moved = if delta >= 0 {
 451             self.selected.checked_add_days(Days::new(delta as u64))
 452         } else {
 453             self.selected.checked_sub_days(Days::new((-delta) as u64))
 454         };
 455         if let Some(d) = moved {
 456             self.select(d);
 457         }
 458     }
 459 
 460     fn save(&mut self) {
 461         let records: Vec<EventRecord> = self
 462             .events
 463             .iter()
 464             .flat_map(|(date, events)| {
 465                 events.iter().map(move |e| EventRecord {
 466                     date: date.to_string(),
 467                     time: e.time.map(|(h, m)| format!("{h:02}:{m:02}")),
 468                     title: e.title.clone(),
 469                     uid: e.uid.clone(),
 470                     source: e.source.clone(),
 471                     recurring: e.recurring,
 472                 })
 473             })
 474             .collect();
 475         self.status = save_records(&records).err().map(|e| format!("save failed: {e}"));
 476         // Our own write must not read as an outside change next tick.
 477         self.file_mtime = file_mtime();
 478     }
 479 
 480     /// Re-read events.json after something else wrote it, keeping the
 481     /// selection where it still makes sense.
 482     fn reload(&mut self) {
 483         self.events = load_events();
 484         self.file_mtime = file_mtime();
 485         let n = self.events.get(&self.selected).map_or(0, Vec::len);
 486         if self.sel_event.is_some_and(|i| i >= n) {
 487             self.sel_event = None;
 488         }
 489     }
 490 
 491     fn commit_input(&mut self) {
 492         let Some(buffer) = self.input.take() else {
 493             return;
 494         };
 495         if let Some(event) = parse_event(&buffer) {
 496             let day = self.events.entry(self.selected).or_default();
 497             day.push(event);
 498             sort_events(day);
 499             self.save();
 500         }
 501     }
 502 
 503     fn delete_selected_event(&mut self) {
 504         let Some(idx) = self.sel_event.take() else {
 505             return;
 506         };
 507         if let Some(day) = self.events.get_mut(&self.selected) {
 508             if day.get(idx).is_some_and(|e| e.recurring) {
 509                 // One instance of a repeating event: the server has no
 510                 // "just this one" the mirror could express, so it stays.
 511                 self.sel_event = Some(idx);
 512                 self.status = Some("repeating event — change it on the phone".to_string());
 513                 return;
 514             }
 515             if idx < day.len() {
 516                 day.remove(idx);
 517                 if day.is_empty() {
 518                     self.events.remove(&self.selected);
 519                 }
 520                 self.save();
 521             }
 522         }
 523     }
 524 
 525     // ── keyboard ──────────────────────────────────────────────────────────
 526 
 527     fn key_input_mode(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) {
 528         let buffer = self.input.as_mut().expect("input mode");
 529         match &event.logical_key {
 530             Key::Named(NamedKey::Escape) => self.input = None,
 531             Key::Named(NamedKey::Enter) => self.commit_input(),
 532             Key::Named(NamedKey::Backspace) => {
 533                 buffer.pop();
 534             }
 535             _ => {
 536                 if let Some(text) = &event.text {
 537                     buffer.extend(text.chars().filter(|c| !c.is_control()));
 538                 }
 539             }
 540         }
 541         *needs_rebuild = true;
 542     }
 543 
 544     fn key_browse_mode(&mut self, event: &KeyEvent) -> (bool, bool) {
 545         let mut quit = false;
 546         let mut handled = true;
 547         match &event.logical_key {
 548             Key::Named(NamedKey::ArrowLeft) => self.shift_selected_days(-1),
 549             Key::Named(NamedKey::ArrowRight) => self.shift_selected_days(1),
 550             Key::Named(NamedKey::ArrowUp) => self.shift_selected_days(-7),
 551             Key::Named(NamedKey::ArrowDown) => self.shift_selected_days(7),
 552             Key::Named(NamedKey::PageUp) => self.shift_months(-1),
 553             Key::Named(NamedKey::PageDown) => self.shift_months(1),
 554             Key::Named(NamedKey::Home) => self.select(self.today),
 555             Key::Named(NamedKey::Enter) => self.input = Some(String::new()),
 556             Key::Named(NamedKey::Delete) => self.delete_selected_event(),
 557             Key::Character(c) => match c.as_str() {
 558                 "t" => self.select(self.today),
 559                 "n" => self.input = Some(String::new()),
 560                 "d" | "x" => self.delete_selected_event(),
 561                 "[" => self.shift_months(-12),
 562                 "]" => self.shift_months(12),
 563                 "q" => quit = true,
 564                 _ => handled = false,
 565             },
 566             _ => handled = false,
 567         }
 568         (handled, quit)
 569     }
 570 
 571     // ── painting ──────────────────────────────────────────────────────────
 572 
 573     fn boxed(rect: Rect, align_h: AlignH) -> TextLayout {
 574         TextLayout {
 575             wrap_width: Some(rect.width),
 576             box_height: rect.height,
 577             align_h,
 578             align_v: AlignV::Middle,
 579         }
 580     }
 581 
 582     /// A header button as cce-ui's Button draws one: a flush control plate
 583     /// at the button radius, its face the DE button colour for its state,
 584     /// then either a bundled cce-icons glyph (placed as Button's icon face:
 585     /// centred, the short side less 8) or, failing that, the label in the
 586     /// button font and the control label colour — a word, never a symbol
 587     /// character, since it shows only when the icon set is missing.
 588     fn paint_header_btn(&self, pc: &mut PaintCtx, rect: Rect, which: HeaderBtn, icon: Option<&str>, label: &str) {
 589         let face = if self.pressed_btn == Some(which) {
 590             button_press_color()
 591         } else if self.hover_btn == Some(which) {
 592             button_hover_color()
 593         } else {
 594             button_background_color()
 595         };
 596         let plate = ControlPlate::control(rect, button_corner_radius(), PlateStance::Flush, Material::face(face));
 597         pc.control_plate(&plate);
 598         if let Some((image, iw, ih)) = icon.and_then(|name| cce_ui::upload_icon(name, 32)) {
 599             let s = (rect.width.min(rect.height) - 8.0).max(4.0);
 600             let (iw, ih) = (iw as f32, ih as f32);
 601             let (dw, dh) = if iw >= ih { (s, s * ih / iw.max(1.0)) } else { (s * iw / ih.max(1.0), s) };
 602             let at = Rect { x: rect.x + (rect.width - dw) / 2.0, y: rect.y + (rect.height - dh) / 2.0, width: dw, height: dh };
 603             pc.image(image, at, 1.0);
 604             return;
 605         }
 606         let font = button_font();
 607         let size = parse_font_string(&font).1.unwrap_or(14.0);
 608         pc.text_boxed(label, rect.x, rect.y, size, control_label_color_u8(), Some(font), None,
 609             TextAttrs::default(), Self::boxed(rect, AlignH::Center));
 610     }
 611 
 612     fn paint_header(&self, pc: &mut PaintCtx, g: &Geom) {
 613         let bold = TextAttrs { italic: false, weight: Some(700), ..Default::default() };
 614         self.paint_header_btn(pc, g.prev_btn, HeaderBtn::Prev, Some("chevron-left"), "Prev");
 615         self.paint_header_btn(pc, g.next_btn, HeaderBtn::Next, Some("chevron-right"), "Next");
 616         let title = Rect {
 617             x: g.prev_btn.x + g.prev_btn.width,
 618             y: g.header.y,
 619             width: g.next_btn.x - (g.prev_btn.x + g.prev_btn.width),
 620             height: HEADER_H,
 621         };
 622         let label = format!("{} {}", MONTHS[self.view.1 as usize - 1], self.view.0);
 623         pc.text_boxed(label, title.x, title.y, 16.0, TEXT, None, None, bold,
 624             Self::boxed(title, AlignH::Center));
 625         self.paint_header_btn(pc, g.today_btn, HeaderBtn::Today, None, "Today");
 626     }
 627 
 628     fn paint_grid(&self, pc: &mut PaintCtx, g: &Geom) {
 629         for (i, name) in WEEKDAYS.iter().cycle()
 630             .skip(self.week_start.num_days_from_monday() as usize)
 631             .take(7)
 632             .enumerate()
 633         {
 634             let rect = Rect {
 635                 x: g.col_x(i as u64),
 636                 y: g.header.y + g.header.height,
 637                 width: g.cell_w,
 638                 height: WEEKDAY_H,
 639             };
 640             pc.text_boxed(*name, rect.x, rect.y, 11.0, TEXT_FAINT, None, None,
 641                 TextAttrs::default(), Self::boxed(rect, AlignH::Center));
 642         }
 643 
 644         for i in 0..42u64 {
 645             let Some(date) = g.first_day.checked_add_days(Days::new(i)) else { continue };
 646             let (row, col) = (i / 7, i % 7);
 647             let cell = g.cell(row, col);
 648             let radius = g.cell_radius;
 649             let all = (true, true, true, true);
 650             let in_month = (date.year(), date.month()) == self.view;
 651             if !in_month {
 652                 pc.rounded_rect(cell, radius, all, BG_OTHER_MONTH);
 653             }
 654             // style: deliberate — the cells are grout-separated grid cells,
 655             // not plates: the selection ring is a 2px band traced on the
 656             // face's own outline (unfilled, so the face shows through), and
 657             // the day number, dots and chips are tight typographic offsets,
 658             // not ladder spacings.
 659             if date == self.selected {
 660                 pc.border(cell, (radius, radius, radius, radius), [0.0; 4],
 661                     [ACCENT[0], ACCENT[1], ACCENT[2], 0.55], 2.0);
 662             }
 663 
 664             // Day number, top-left; today gets an accent pill.
 665             let num = Rect { x: cell.x + 6.0, y: cell.y + 4.0, width: 24.0, height: 17.0 };
 666             if date == self.today {
 667                 pc.rounded_rect(num, 8.0, (true, true, true, true), ACCENT);
 668             }
 669             let num_color = if date == self.today {
 670                 [255, 255, 255]
 671             } else if in_month {
 672                 TEXT
 673             } else {
 674                 TEXT_FAINT
 675             };
 676             pc.text_boxed(date.day().to_string(), num.x, num.y, 11.5, num_color, None, None,
 677                 TextAttrs { italic: false, weight: Some(600), ..Default::default() }, Self::boxed(num, AlignH::Center));
 678 
 679             // Event chips: dot + clipped title, then a "+N" overflow line.
 680             if let Some(events) = self.events.get(&date) {
 681                 let line_h = 15.0;
 682                 let avail = ((cell.height - 26.0) / line_h).max(0.0) as usize;
 683                 let shown = if avail >= events.len() { events.len() } else { avail.saturating_sub(1) };
 684                 pc.clip(cell, |pc| {
 685                     let mut y = cell.y + 24.0;
 686                     for e in events.iter().take(shown) {
 687                         let dot = if e.source.is_some() { SYNC_DOT } else { EVENT_DOT };
 688                         pc.circle(cell.x + 9.0, y + 6.0, 2.5, dot);
 689                         let alpha = if in_month { TEXT } else { TEXT_DIM };
 690                         pc.text_with(e.title.clone(), cell.x + 15.0, y, 10.0, alpha, None,
 691                             Some([cell.x, cell.y, cell.x + cell.width - 4.0, cell.y + cell.height]));
 692                         y += line_h;
 693                     }
 694                     if events.len() > shown {
 695                         pc.text_with(format!("+{} more", events.len() - shown), cell.x + 15.0, y,
 696                             10.0, TEXT_FAINT, None, None);
 697                     }
 698                 });
 699             }
 700         }
 701 
 702         // The lines are grout: one draw paints everything in the frame
 703         // outside the rounded cell faces, so each crossing is filleted by
 704         // the four corners meeting at it. The frame's own outer corners are
 705         // clipped concentric with the corner cells'.
 706         let origin = (g.col_x(0) + g.cell_w / 2.0, g.row_y(0) + g.cell_h / 2.0);
 707         let face = ((g.cell_w - GRID_LINE_W).max(0.0), (g.cell_h - GRID_LINE_W).max(0.0));
 708         pc.clip_rounded(g.grid, g.cell_radius + GRID_LINE_W, |pc| {
 709             pc.grout(g.grid, (g.cell_w, g.cell_h), origin, face, g.cell_radius, GRID_LINE);
 710         });
 711     }
 712 
 713     /// Sidebar event-row rects, matching `paint_sidebar` (shared with hit-testing).
 714     fn sidebar_row(&self, g: &Geom, idx: usize) -> Rect {
 715         let pad = plate_padding();
 716         Rect {
 717             x: g.sidebar.x + pad,
 718             y: g.list.y + idx as f32 * ROW_H - self.sidebar_scroll,
 719             width: (g.sidebar.width - 2.0 * pad).max(0.0),
 720             // style: deliberate — a 4px hairline between rows in a list,
 721             // not a rung gap.
 722             height: ROW_H - 4.0,
 723         }
 724     }
 725 
 726     fn paint_sidebar(&self, pc: &mut PaintCtx, g: &Geom) {
 727         // The day view is a well carved into the root plate, not a plate
 728         // standing on it: the floor is the root plate showing through, the
 729         // wall kept inside the sidebar rect so the gap to the grid is the
 730         // gap. Drawn first so the rows and the strip lie on its floor.
 731         let r = plate_corner_radius();
 732         let depth = bevel_width().min(g.sidebar.height * 0.2);
 733         let (well, radii) = carve_inside(g.sidebar, (r, r, r, r), depth);
 734         pc.recess(well, radii, depth);
 735 
 736         let pad = plate_padding();
 737         let heading = format!(
 738             "{}, {} {}",
 739             WEEKDAYS[self.selected.weekday().num_days_from_monday() as usize],
 740             MONTHS[self.selected.month() as usize - 1],
 741             self.selected.day()
 742         );
 743         let color = if self.selected == self.today { TEXT_ACCENT } else { TEXT };
 744         let head_y = g.sidebar.y + pad;
 745         pc.text(heading, g.sidebar.x + pad, head_y, 14.0, color);
 746         if self.selected == self.today {
 747             // The heading block's second line (a line advance, not a gap).
 748             pc.text("today", g.sidebar.x + pad, head_y + 20.0, 10.5, TEXT_FAINT);
 749         }
 750 
 751         let events = self.events.get(&self.selected).map(Vec::as_slice).unwrap_or(&[]);
 752         // style: deliberate — the text offsets inside a row and inside the
 753         // strip (+8/+9/+12, the -4/-8 clip margins, the time column's 12px
 754         // gutter) are a control's own text insets, not ladder spacings.
 755         //
 756         // The rows are list rows, drawn as the toolkit's list-row Button
 757         // draws one: a state wash at the button radius (`row_wash`), the
 758         // text in the list font. The title wears the list font colour; the
 759         // time keeps the accent that says it is timed.
 760         let font = list_font();
 761         let (_, size) = list_font_parsed();
 762         let fc = list_font_color();
 763         let title_color = [(fc[0] * 255.0) as u8, (fc[1] * 255.0) as u8, (fc[2] * 255.0) as u8];
 764         let time_w = shaped_width("00:00", size, &font);
 765         let radius = button_corner_radius();
 766         pc.clip(g.list, |pc| {
 767             if events.is_empty() {
 768                 pc.text_with("No events", g.sidebar.x + pad, g.list.y + 9.0, size, TEXT_FAINT,
 769                     Some(font.clone()), None);
 770             }
 771             for (i, e) in events.iter().enumerate() {
 772                 let row = self.sidebar_row(g, i);
 773                 let wash = self.row_wash(i);
 774                 if wash[3] > 0.0 {
 775                     pc.rounded_rect(row, radius, (true, true, true, true), wash);
 776                 }
 777                 let ty = align_text_y(row.y, row.height, size, 0.0);
 778                 let time = e.time.map_or("——".to_string(), |(h, m)| format!("{h:02}:{m:02}"));
 779                 pc.text_with(time, row.x + 8.0, ty, size,
 780                     if e.time.is_some() { TEXT_ACCENT } else { TEXT_FAINT }, Some(font.clone()), None);
 781                 pc.text_with(e.title.clone(), row.x + 8.0 + time_w + 12.0, ty, size, title_color,
 782                     Some(font.clone()), Some([row.x, row.y, row.x + row.width - 4.0, row.y + row.height]));
 783             }
 784         });
 785 
 786         // Bottom strip: the input field while typing, else the key hints.
 787         let strip = g.strip;
 788         if let Some(buffer) = &self.input {
 789             // The field at the toolkit's textbox height, centred in the strip.
 790             let field_h = textbox_height().min(strip.height);
 791             let field = Rect { y: strip.y + (strip.height - field_h) / 2.0, height: field_h, ..strip };
 792             self.paint_input(pc, field, buffer);
 793         } else {
 794             self.paint_hints(pc, strip);
 795         }
 796     }
 797 }
 798 
 799 impl CalendarApp {
 800     /// The new-event field, drawn as cce-ui's single-line TextBox draws
 801     /// itself while editing: the textbox background (skipped when
 802     /// transparent, so the floor shows through), a well carved inside the
 803     /// strip at the textbox radius with its rim lit in the highlight accent
 804     /// (a flat accent frame without relief), the value in the detached
 805     /// control-label font at the control text inset, the placeholder in the
 806     /// placeholder colour, and a 1.5px caret at the shaped end of the text.
 807     /// A value wider than the field scrolls so the caret stays in view.
 808     fn paint_input(&self, pc: &mut PaintCtx, strip: Rect, buffer: &str) {
 809         let radius = textbox_corner_radius();
 810         let radii = (radius, radius, radius, radius);
 811         let bg = textbox_background_color();
 812         if control_relief() {
 813             if bg[3] > 0.001 {
 814                 pc.rounded_rect(strip, radius, (true, true, true, true), bg);
 815             }
 816             let depth = bevel_width().min(strip.height * 0.2);
 817             let (well, well_radii) = carve_inside(strip, radii, depth);
 818             let hc = highlight_primary_color();
 819             pc.recess_tinted(well, well_radii, depth, [hc[0], hc[1], hc[2]]);
 820         } else {
 821             pc.border(strip, radii, bg, well_frame_color(false, true), 1.0);
 822         }
 823 
 824         let font = control_label_font_detached();
 825         let (_, size) = control_label_font_detached_parsed();
 826         let inset = CONTROL_TEXT_INSET;
 827         let text_y = align_text_y(strip.y, strip.height, size, 0.0);
 828         let clip = [strip.x + inset, strip.y, strip.x + strip.width - inset, strip.y + strip.height];
 829         if buffer.is_empty() {
 830             pc.text_with("HH:MM title", strip.x + inset, text_y, size, textbox_placeholder_text_color(),
 831                 Some(font.clone()), Some(clip));
 832         }
 833         let advance = shaped_width(buffer, size, &font);
 834         let scroll = (advance - (strip.width - 2.0 * inset)).max(0.0);
 835         let x = strip.x + inset - scroll;
 836         if !buffer.is_empty() {
 837             pc.text_with(buffer, x, text_y, size, [0xee, 0xee, 0xf5], Some(font), Some(clip));
 838         }
 839         let caret_h = size * 1.15;
 840         let caret = Rect { x: x + advance, y: text_y + (size - caret_h) / 2.0, width: 1.5, height: caret_h };
 841         // Open for typing: the on-screen keyboard follows this.
 842         cce_ui::text_input::claim(caret.x, caret.y, caret.width, caret.height);
 843         pc.quad(caret, [0.80, 0.80, 0.85, 1.0]);
 844     }
 845 }
 846 
 847 /// A key hint: the keys (one keycap each) and what they do.
 848 type Hint = (&'static [&'static str], &'static str);
 849 
 850 /// The strip's hints, most useful first: what does not fit is dropped whole.
 851 /// "today" comes last because the header's Today button already says it.
 852 const HINTS: [Hint; 3] = [(&["n"], "new"), (&["pgup", "pgdn"], "month"), (&["t"], "today")];
 853 const HINTS_SELECTED: [Hint; 3] = [(&["d"], "delete"), (&["n"], "new"), (&["t"], "today")];
 854 
 855 impl CalendarApp {
 856     /// The key hints, or the status message in their place, in the status
 857     /// bar font. Each key is a keycap — a raised control plate with no face
 858     /// of its own, so the floor shows through between its lit edges — with
 859     /// the key in the foreground text colour and the action after it in the
 860     /// dim one. Hints run left to right, and one that would overflow the
 861     /// strip is skipped whole (a shorter one after it may still fit), so a
 862     /// narrow strip loses hints rather than clipping one mid-word.
 863     fn paint_hints(&self, pc: &mut PaintCtx, strip: Rect) {
 864         let font = statusbar_font();
 865         let (_, size) = statusbar_font_parsed();
 866         let to_u8 = |c: [f32; 4]| [0, 1, 2].map(|i| (c[i] * 255.0).round() as u8);
 867         let text_y = align_text_y(strip.y, strip.height, size, 0.0);
 868         if let Some(err) = &self.status {
 869             pc.text_with(err.clone(), strip.x, text_y, size, [0xee, 0x5c, 0x5c], Some(font),
 870                 Some([strip.x, strip.y, strip.x + strip.width, strip.y + strip.height]));
 871             return;
 872         }
 873         // style: deliberate — a keycap's 6px label inset, the 3px between
 874         // two caps of one hint, 6px from cap to action and 14px between
 875         // hints are a control's own text spacings, not ladder rungs.
 876         let cap_h = (size + 8.0).min(strip.height);
 877         let cap_y = strip.y + (strip.height - cap_h) / 2.0;
 878         let radius = button_corner_radius().min(cap_h / 2.0);
 879         let right = strip.x + strip.width;
 880         let hints: &[Hint] = if self.sel_event.is_some() { &HINTS_SELECTED } else { &HINTS };
 881         let mut x = strip.x;
 882         for (keys, action) in hints {
 883             let caps: Vec<f32> = keys.iter().map(|k| (shaped_width(k, size, &font) + 12.0).max(cap_h)).collect();
 884             let action_w = shaped_width(action, size, &font);
 885             let w = caps.iter().sum::<f32>() + 3.0 * (caps.len() - 1) as f32 + 6.0 + action_w;
 886             if x + w > right {
 887                 continue;
 888             }
 889             for (key, cap_w) in keys.iter().zip(&caps) {
 890                 let cap = Rect { x, y: cap_y, width: *cap_w, height: cap_h };
 891                 pc.control_plate(&ControlPlate::control(cap, radius, PlateStance::Raised, None));
 892                 pc.text_boxed(*key, cap.x, cap.y, size, to_u8(cce_ui::colors::TEXT_FG), Some(font.clone()), None,
 893                     TextAttrs::default(), Self::boxed(cap, AlignH::Center));
 894                 x += cap_w + 3.0;
 895             }
 896             x += 3.0;
 897             pc.text_with(*action, x, text_y, size, to_u8(cce_ui::colors::TEXT_DIM), Some(font.clone()), None);
 898             x += action_w + 14.0;
 899         }
 900     }
 901 }
 902 
 903 impl Application for CalendarApp {
 904     type Message = Message;
 905 
 906     fn create(_sender: cce_ui::engine::AppSender<Self::Message>) -> Self {
 907         let today = Local::now().date_naive();
 908         Self {
 909             events: load_events(),
 910             view: (today.year(), today.month()),
 911             selected: today,
 912             sel_event: None,
 913             input: None,
 914             week_start: week_start_config(),
 915             today,
 916             win: (1060.0, 720.0),
 917             sidebar_scroll: 0.0,
 918             sidebar_motion: ScrollMotion::new(),
 919             month_wheel_px: 0.0,
 920             status: None,
 921             hover_btn: None,
 922             pressed_btn: None,
 923             hover_row: None,
 924             file_mtime: file_mtime(),
 925             watch_at: std::time::Instant::now(),
 926         }
 927     }
 928 
 929     fn settings(&self) -> WindowSettings {
 930         WindowSettings {
 931             title: "Calendar".to_string(),
 932             app_id: "cce-calendar".to_string(),
 933             width: 1060,
 934             height: 720,
 935             fullscreen: false,
 936             min_size: Some((700, 480)),
 937         }
 938     }
 939 
 940     fn update(&mut self, msg: Self::Message, _needs_rebuild: &mut bool, exit: &mut bool) {
 941         match msg {
 942             Message::Quit => *exit = true,
 943         }
 944     }
 945 
 946     /// The mtime watch in `tick` is work the runner cannot see — nothing
 947     /// redraws until the file changes underneath us — so name the cadence the
 948     /// loop has to come back at.
 949     fn idle_poll_interval(&self) -> Option<std::time::Duration> {
 950         Some(WATCH_EVERY)
 951     }
 952 
 953     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
 954         let now = Local::now().date_naive();
 955         if now != self.today {
 956             self.today = now;
 957             *needs_rebuild = true;
 958         }
 959         if self.tick_sidebar_scroll(dt) {
 960             *needs_rebuild = true;
 961         }
 962         // The sync timer rewrites events.json; pick that up without a
 963         // relaunch — but not mid-typing, which a reload would clobber.
 964         let now_i = std::time::Instant::now();
 965         if now_i >= self.watch_at {
 966             self.watch_at = now_i + WATCH_EVERY;
 967             if self.input.is_none() && file_mtime() != self.file_mtime {
 968                 self.reload();
 969                 *needs_rebuild = true;
 970             }
 971         }
 972     }
 973 
 974     fn handle_resize(&mut self, width: f32, height: f32, _scale: f64) {
 975         self.win = (width, height);
 976     }
 977 
 978     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
 979         let g = self.geom();
 980         let hover = Self::header_btn_at(&g, pos.x, pos.y);
 981         let row = self.row_at(&g, pos.x, pos.y);
 982         if hover != self.hover_btn || row != self.hover_row {
 983             self.hover_btn = hover;
 984             self.hover_row = row;
 985             *needs_rebuild = true;
 986         }
 987     }
 988 
 989     fn handle_mouse_input(
 990         &mut self,
 991         button: MouseButton,
 992         state: ElementState,
 993         pos: LogicalPosition,
 994         needs_rebuild: &mut bool,
 995     ) -> Option<Self::Message> {
 996         if button != MouseButton::Left {
 997             return None;
 998         }
 999         if state != ElementState::Pressed {
1000             if self.pressed_btn.take().is_some() {
1001                 *needs_rebuild = true;
1002             }
1003             return None;
1004         }
1005         let g = self.geom();
1006         let (x, y) = (pos.x, pos.y);
1007         let header_btn = Self::header_btn_at(&g, x, y);
1008         self.pressed_btn = header_btn;
1009         if let Some(b) = header_btn {
1010             match b {
1011                 HeaderBtn::Prev => self.shift_months(-1),
1012                 HeaderBtn::Next => self.shift_months(1),
1013                 HeaderBtn::Today => self.select(self.today),
1014             }
1015         } else if let Some(date) = self.day_at(&g, x, y) {
1016             self.select(date);
1017         } else if g.sidebar.contains(x, y) {
1018             self.sel_event = self.row_at(&g, x, y);
1019         } else {
1020             return None;
1021         }
1022         *needs_rebuild = true;
1023         None
1024     }
1025 
1026     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
1027         let g = self.geom();
1028         if g.sidebar.contains(pos.x, pos.y) {
1029             // A notch is one row, pixel deltas are 1:1. The motion glides
1030             // notches and coasts a flick; `tick_sidebar_scroll` carries the
1031             // drawn offset after it. A true return is the repaint signal.
1032             let overflow = self.sidebar_overflow(&g);
1033             self.sidebar_motion.reconcile(0.0, self.sidebar_scroll);
1034             if self.sidebar_motion.apply(delta, (ROW_H, ROW_H), Bounds::max(0.0), Bounds::max(overflow)) {
1035                 self.sidebar_scroll = self.sidebar_motion.y.pos();
1036                 *needs_rebuild = true;
1037             }
1038         } else {
1039             // Month stepping stays discrete: one month per wheel notch. A
1040             // trackpad gesture accumulates pixels and steps once per
1041             // MONTH_STEP_PX instead of once per pixel event, carrying the
1042             // remainder until the finger lifts.
1043             let step = match delta {
1044                 MouseScrollDelta::LineDelta(_, y) => {
1045                     self.month_wheel_px = 0.0;
1046                     if *y < 0.0 {
1047                         1
1048                     } else if *y > 0.0 {
1049                         -1
1050                     } else {
1051                         0
1052                     }
1053                 }
1054                 MouseScrollDelta::PixelDelta(p) => {
1055                     if current_scroll_phase() == ScrollPhase::FingerEnd {
1056                         self.month_wheel_px = 0.0;
1057                         0
1058                     } else {
1059                         self.month_wheel_px += p.y as f32;
1060                         if self.month_wheel_px <= -MONTH_STEP_PX {
1061                             self.month_wheel_px += MONTH_STEP_PX;
1062                             1
1063                         } else if self.month_wheel_px >= MONTH_STEP_PX {
1064                             self.month_wheel_px -= MONTH_STEP_PX;
1065                             -1
1066                         } else {
1067                             0
1068                         }
1069                     }
1070                 }
1071             };
1072             if step != 0 {
1073                 self.shift_months(step);
1074                 *needs_rebuild = true;
1075             }
1076         }
1077     }
1078 
1079     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
1080         if event.state != ElementState::Pressed {
1081             return None;
1082         }
1083         if self.input.is_some() {
1084             self.key_input_mode(event, needs_rebuild);
1085             return None;
1086         }
1087         let (handled, quit) = self.key_browse_mode(event);
1088         if handled {
1089             *needs_rebuild = true;
1090         }
1091         quit.then_some(Message::Quit)
1092     }
1093 
1094     fn display_list(&mut self, size: LogicalSize, _scale: f64) -> Option<DisplayList> {
1095         self.win = (size.width, size.height);
1096         let g = self.geom();
1097         let mut pc = PaintCtx::new();
1098         // The standard root plate (cce-ui PlateSpec::window).
1099         pc.root_plate(size.width, size.height);
1100         self.paint_header(&mut pc, &g);
1101         self.paint_grid(&mut pc, &g);
1102         self.paint_sidebar(&mut pc, &g);
1103         Some(pc.finish())
1104     }
1105 
1106     fn display_list_text(&self) -> bool {
1107         true
1108     }
1109 
1110     fn clear_color(&self) -> [f32; 4] {
1111         BG
1112     }
1113 }
1114 
1115 fn main() {
1116     env_logger::init();
1117     cce_ui::engine::run::<CalendarApp>();
1118 }