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

commit4166f9d95b1fdbc38a842aff59b61b5fe82a767b
authorLucas Galante <[email protected]>
date2026-08-11 20:12
cce-calendar: month-view calendar MVP

Month grid + day pane on the cce-ui display-list path. Events persist to
$XDG_DATA_HOME/cce/calendar/events.json; a leading HH:MM token on the
inline input sets the event time. week-start configurable via
~/.config/cce/cce-calendar/config.kdl.

Co-Authored-By: Claude Fable 5 <[email protected]>

 .gitignore  |   2 +
 Cargo.toml  |  15 ++
 src/main.rs | 710 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 727 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..96ef6c0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+Cargo.lock
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..be37636
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "cce-calendar"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+cce-ui = { path = "../cce-ui" }
+calloop = "0.13.0"
+wayland-client = { version = "0.31", features = ["system"] }
+chrono = "0.4"
+kdl = "4.6"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+log = "0.4"
+env_logger = "0.11"
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..b29d88b
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,710 @@
+//! cce-calendar — month-view calendar with per-day events.
+//!
+//! A 6×7 month grid on the left, a day pane on the right. Events live in
+//! `$XDG_DATA_HOME/cce/calendar/events.json` (one flat list of
+//! date/time/title records) and are saved on every mutation.
+//!
+//! Keys: arrows move the selected day · PageUp/PageDown month · [/] year ·
+//! t/Home today · n/Enter new event (a leading `HH:MM` token sets the
+//! time) · d/Delete remove the clicked event · q quit. The wheel flips
+//! months over the grid and scrolls the event list over the pane.
+//!
+//! Config (`~/.config/cce/cce-calendar/config.kdl`): `week-start "sunday"`
+//! (default monday).
+
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+
+use chrono::{Datelike, Days, Local, NaiveDate, Weekday};
+use wayland_client::QueueHandle;
+
+use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{AlignH, AlignV, DisplayList, PaintCtx, TextAttrs, TextLayout};
+use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey};
+
+const HEADER_H: f32 = 46.0;
+const WEEKDAY_H: f32 = 24.0;
+const SIDEBAR_W: f32 = 300.0;
+const PAD: f32 = 12.0;
+const ROW_H: f32 = 36.0;
+const INPUT_H: f32 = 40.0;
+
+const BG: [f32; 4] = [0.075, 0.08, 0.09, 1.0];
+const BG_OTHER_MONTH: [f32; 4] = [0.06, 0.064, 0.072, 1.0];
+const SIDEBAR_BG: [f32; 4] = [0.10, 0.105, 0.12, 1.0];
+const GRID_LINE: [f32; 4] = [1.0, 1.0, 1.0, 0.06];
+const ACCENT: [f32; 4] = [0.22, 0.42, 0.85, 1.0];
+const EVENT_DOT: [f32; 4] = [0.95, 0.72, 0.30, 1.0];
+const ROW_SEL: [f32; 4] = [1.0, 1.0, 1.0, 0.08];
+const TEXT: [u8; 3] = [225, 228, 232];
+const TEXT_DIM: [u8; 3] = [140, 145, 152];
+const TEXT_FAINT: [u8; 3] = [95, 100, 108];
+const TEXT_ACCENT: [u8; 3] = [120, 165, 255];
+
+#[derive(Debug, Clone)]
+enum Message {
+    Quit,
+}
+
+/// One event on a day. `time` is (hour, minute); untimed events sort after
+/// timed ones.
+#[derive(Clone, Debug)]
+struct Event {
+    time: Option<(u32, u32)>,
+    title: String,
+}
+
+/// The on-disk shape: a flat list keeps the file trivially mergeable and
+/// greppable.
+#[derive(serde::Serialize, serde::Deserialize)]
+struct EventRecord {
+    date: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    time: Option<String>,
+    title: String,
+}
+
+fn data_path() -> PathBuf {
+    std::env::var_os("XDG_DATA_HOME")
+        .map(PathBuf::from)
+        .filter(|p| p.is_absolute())
+        .unwrap_or_else(|| {
+            PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".local/share")
+        })
+        .join("cce/calendar/events.json")
+}
+
+fn load_events() -> BTreeMap<NaiveDate, Vec<Event>> {
+    let mut map: BTreeMap<NaiveDate, Vec<Event>> = BTreeMap::new();
+    let Ok(text) = std::fs::read_to_string(data_path()) else {
+        return map;
+    };
+    let records: Vec<EventRecord> = match serde_json::from_str(&text) {
+        Ok(r) => r,
+        Err(e) => {
+            log::error!("events.json unreadable, starting empty: {e}");
+            return map;
+        }
+    };
+    for rec in records {
+        let Ok(date) = rec.date.parse::<NaiveDate>() else {
+            continue;
+        };
+        let time = rec.time.as_deref().and_then(parse_time);
+        map.entry(date).or_default().push(Event { time, title: rec.title });
+    }
+    for events in map.values_mut() {
+        sort_events(events);
+    }
+    map
+}
+
+fn sort_events(events: &mut [Event]) {
+    events.sort_by_key(|e| e.time.map_or((1, 0, 0), |(h, m)| (0, h, m)));
+}
+
+/// `"H:MM"` / `"HH:MM"` → (hour, minute).
+fn parse_time(tok: &str) -> Option<(u32, u32)> {
+    let (h, m) = tok.split_once(':')?;
+    let (h, m) = (h.parse::<u32>().ok()?, m.parse::<u32>().ok()?);
+    (h < 24 && m < 60 && !tok.starts_with('+')).then_some((h, m))
+}
+
+/// A leading `HH:MM` token becomes the event time; the rest is the title.
+fn parse_event(raw: &str) -> Option<Event> {
+    let raw = raw.trim();
+    if raw.is_empty() {
+        return None;
+    }
+    let (time, title) = match raw.split_once(char::is_whitespace) {
+        Some((tok, rest)) if parse_time(tok).is_some() => (parse_time(tok), rest.trim().to_string()),
+        _ => match parse_time(raw) {
+            Some(t) => (Some(t), String::new()),
+            None => (None, raw.to_string()),
+        },
+    };
+    let title = if title.is_empty() { "(untitled)".to_string() } else { title };
+    Some(Event { time, title })
+}
+
+fn week_start_config() -> Weekday {
+    let path = cce_ui::config::get_app_config_path("cce-calendar");
+    if let Ok(text) = std::fs::read_to_string(path) {
+        if let Ok(doc) = text.parse::<kdl::KdlDocument>() {
+            if let Some(v) = doc
+                .get("week-start")
+                .and_then(|n| n.entries().first())
+                .and_then(|e| e.value().as_string())
+            {
+                return match v.to_ascii_lowercase().as_str() {
+                    "sunday" | "sun" => Weekday::Sun,
+                    "saturday" | "sat" => Weekday::Sat,
+                    _ => Weekday::Mon,
+                };
+            }
+        }
+    }
+    Weekday::Mon
+}
+
+fn add_months(year: i32, month: u32, delta: i32) -> (i32, u32) {
+    let idx = year * 12 + month as i32 - 1 + delta;
+    (idx.div_euclid(12), (idx.rem_euclid(12) + 1) as u32)
+}
+
+fn days_in_month(year: i32, month: u32) -> u32 {
+    let (ny, nm) = add_months(year, month, 1);
+    NaiveDate::from_ymd_opt(ny, nm, 1)
+        .and_then(|d| d.pred_opt())
+        .map_or(28, |d| d.day())
+}
+
+const MONTHS: [&str; 12] = [
+    "January", "February", "March", "April", "May", "June", "July", "August", "September",
+    "October", "November", "December",
+];
+const WEEKDAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
+
+fn weekday_offset(day: Weekday, week_start: Weekday) -> u32 {
+    (day.num_days_from_monday() + 7 - week_start.num_days_from_monday()) % 7
+}
+
+/// The window geometry every frame and every hit-test derive from.
+struct Geom {
+    prev_btn: Rect,
+    next_btn: Rect,
+    today_btn: Rect,
+    grid: Rect,
+    cell_w: f32,
+    cell_h: f32,
+    sidebar: Rect,
+    /// Date of the grid's top-left cell (always a 42-day window).
+    first_day: NaiveDate,
+}
+
+fn hit(r: &Rect, x: f32, y: f32) -> bool {
+    x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
+}
+
+struct CalendarApp {
+    events: BTreeMap<NaiveDate, Vec<Event>>,
+    /// Displayed (year, month).
+    view: (i32, u32),
+    selected: NaiveDate,
+    /// Index into the selected day's (sorted) events, for deletion.
+    sel_event: Option<usize>,
+    /// `Some(buffer)` while typing a new event.
+    input: Option<String>,
+    week_start: Weekday,
+    today: NaiveDate,
+    win: (f32, f32),
+    sidebar_scroll: f32,
+    status: Option<String>,
+}
+
+impl CalendarApp {
+    fn geom(&self) -> Geom {
+        let (w, h) = self.win;
+        let sidebar_w = SIDEBAR_W.min(w * 0.4);
+        let grid_w = w - sidebar_w;
+        let grid = Rect {
+            x: 0.0,
+            y: HEADER_H + WEEKDAY_H,
+            width: grid_w,
+            height: h - HEADER_H - WEEKDAY_H,
+        };
+        let btn = |x: f32| Rect { x, y: (HEADER_H - 28.0) / 2.0, width: 28.0, height: 28.0 };
+        let first_of_month = NaiveDate::from_ymd_opt(self.view.0, self.view.1, 1)
+            .unwrap_or(self.today);
+        let back = weekday_offset(first_of_month.weekday(), self.week_start);
+        let first_day = first_of_month
+            .checked_sub_days(Days::new(back as u64))
+            .unwrap_or(first_of_month);
+        Geom {
+            prev_btn: btn(PAD),
+            next_btn: btn(PAD + 28.0 + 190.0),
+            today_btn: Rect {
+                x: grid_w - PAD - 64.0,
+                y: (HEADER_H - 24.0) / 2.0,
+                width: 64.0,
+                height: 24.0,
+            },
+            grid,
+            cell_w: grid.width / 7.0,
+            cell_h: grid.height / 6.0,
+            sidebar: Rect { x: grid_w, y: 0.0, width: sidebar_w, height: h },
+            first_day,
+        }
+    }
+
+    fn day_at(&self, g: &Geom, x: f32, y: f32) -> Option<NaiveDate> {
+        if !hit(&g.grid, x, y) {
+            return None;
+        }
+        let col = ((x - g.grid.x) / g.cell_w) as u64;
+        let row = ((y - g.grid.y) / g.cell_h) as u64;
+        g.first_day.checked_add_days(Days::new(row.min(5) * 7 + col.min(6)))
+    }
+
+    fn select(&mut self, date: NaiveDate) {
+        self.selected = date;
+        self.sel_event = None;
+        self.sidebar_scroll = 0.0;
+        if (date.year(), date.month()) != self.view {
+            self.view = (date.year(), date.month());
+        }
+    }
+
+    fn shift_months(&mut self, delta: i32) {
+        let (y, m) = add_months(self.view.0, self.view.1, delta);
+        self.view = (y, m);
+        let day = self.selected.day().min(days_in_month(y, m));
+        if let Some(d) = NaiveDate::from_ymd_opt(y, m, day) {
+            self.selected = d;
+            self.sel_event = None;
+        }
+    }
+
+    fn shift_selected_days(&mut self, delta: i64) {
+        let moved = if delta >= 0 {
+            self.selected.checked_add_days(Days::new(delta as u64))
+        } else {
+            self.selected.checked_sub_days(Days::new((-delta) as u64))
+        };
+        if let Some(d) = moved {
+            self.select(d);
+        }
+    }
+
+    fn save(&mut self) {
+        let records: Vec<EventRecord> = self
+            .events
+            .iter()
+            .flat_map(|(date, events)| {
+                events.iter().map(move |e| EventRecord {
+                    date: date.to_string(),
+                    time: e.time.map(|(h, m)| format!("{h:02}:{m:02}")),
+                    title: e.title.clone(),
+                })
+            })
+            .collect();
+        let path = data_path();
+        let write = || -> std::io::Result<()> {
+            if let Some(dir) = path.parent() {
+                std::fs::create_dir_all(dir)?;
+            }
+            std::fs::write(&path, serde_json::to_string_pretty(&records).unwrap_or_default())
+        };
+        self.status = write().err().map(|e| format!("save failed: {e}"));
+    }
+
+    fn commit_input(&mut self) {
+        let Some(buffer) = self.input.take() else {
+            return;
+        };
+        if let Some(event) = parse_event(&buffer) {
+            let day = self.events.entry(self.selected).or_default();
+            day.push(event);
+            sort_events(day);
+            self.save();
+        }
+    }
+
+    fn delete_selected_event(&mut self) {
+        let Some(idx) = self.sel_event.take() else {
+            return;
+        };
+        if let Some(day) = self.events.get_mut(&self.selected) {
+            if idx < day.len() {
+                day.remove(idx);
+                if day.is_empty() {
+                    self.events.remove(&self.selected);
+                }
+                self.save();
+            }
+        }
+    }
+
+    // ── keyboard ──────────────────────────────────────────────────────────
+
+    fn key_input_mode(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) {
+        let buffer = self.input.as_mut().expect("input mode");
+        match &event.logical_key {
+            Key::Named(NamedKey::Escape) => self.input = None,
+            Key::Named(NamedKey::Enter) => self.commit_input(),
+            Key::Named(NamedKey::Backspace) => {
+                buffer.pop();
+            }
+            _ => {
+                if let Some(text) = &event.text {
+                    buffer.extend(text.chars().filter(|c| !c.is_control()));
+                }
+            }
+        }
+        *needs_rebuild = true;
+    }
+
+    fn key_browse_mode(&mut self, event: &KeyEvent) -> (bool, bool) {
+        let mut quit = false;
+        let mut handled = true;
+        match &event.logical_key {
+            Key::Named(NamedKey::ArrowLeft) => self.shift_selected_days(-1),
+            Key::Named(NamedKey::ArrowRight) => self.shift_selected_days(1),
+            Key::Named(NamedKey::ArrowUp) => self.shift_selected_days(-7),
+            Key::Named(NamedKey::ArrowDown) => self.shift_selected_days(7),
+            Key::Named(NamedKey::PageUp) => self.shift_months(-1),
+            Key::Named(NamedKey::PageDown) => self.shift_months(1),
+            Key::Named(NamedKey::Home) => self.select(self.today),
+            Key::Named(NamedKey::Enter) => self.input = Some(String::new()),
+            Key::Named(NamedKey::Delete) => self.delete_selected_event(),
+            Key::Character(c) => match c.as_str() {
+                "t" => self.select(self.today),
+                "n" => self.input = Some(String::new()),
+                "d" | "x" => self.delete_selected_event(),
+                "[" => self.shift_months(-12),
+                "]" => self.shift_months(12),
+                "q" => quit = true,
+                _ => handled = false,
+            },
+            _ => handled = false,
+        }
+        (handled, quit)
+    }
+
+    // ── painting ──────────────────────────────────────────────────────────
+
+    fn boxed(rect: Rect, align_h: AlignH) -> TextLayout {
+        TextLayout {
+            wrap_width: Some(rect.width),
+            box_height: rect.height,
+            align_h,
+            align_v: AlignV::Middle,
+        }
+    }
+
+    fn paint_header(&self, pc: &mut PaintCtx, g: &Geom) {
+        let bold = TextAttrs { italic: false, weight: Some(700) };
+        for (rect, glyph) in [(&g.prev_btn, "‹"), (&g.next_btn, "›")] {
+            pc.rounded_rect(*rect, 6.0, (true, true, true, true), [1.0, 1.0, 1.0, 0.07]);
+            pc.text_boxed(glyph, rect.x, rect.y - 1.0, 18.0, TEXT, None, None, bold,
+                Self::boxed(*rect, AlignH::Center));
+        }
+        let title = Rect {
+            x: g.prev_btn.x + g.prev_btn.width,
+            y: 0.0,
+            width: g.next_btn.x - (g.prev_btn.x + g.prev_btn.width),
+            height: HEADER_H,
+        };
+        let label = format!("{} {}", MONTHS[self.view.1 as usize - 1], self.view.0);
+        pc.text_boxed(label, title.x, title.y, 16.0, TEXT, None, None, bold,
+            Self::boxed(title, AlignH::Center));
+        pc.rounded_rect(g.today_btn, 6.0, (true, true, true, true), [1.0, 1.0, 1.0, 0.07]);
+        pc.text_boxed("Today", g.today_btn.x, g.today_btn.y, 12.0, TEXT_DIM, None, None,
+            TextAttrs::default(), Self::boxed(g.today_btn, AlignH::Center));
+    }
+
+    fn paint_grid(&self, pc: &mut PaintCtx, g: &Geom) {
+        for (i, name) in WEEKDAYS.iter().cycle()
+            .skip(self.week_start.num_days_from_monday() as usize)
+            .take(7)
+            .enumerate()
+        {
+            let rect = Rect {
+                x: g.grid.x + i as f32 * g.cell_w,
+                y: HEADER_H,
+                width: g.cell_w,
+                height: WEEKDAY_H,
+            };
+            pc.text_boxed(*name, rect.x, rect.y, 11.0, TEXT_FAINT, None, None,
+                TextAttrs::default(), Self::boxed(rect, AlignH::Center));
+        }
+
+        for i in 0..42u64 {
+            let Some(date) = g.first_day.checked_add_days(Days::new(i)) else { continue };
+            let (row, col) = (i / 7, i % 7);
+            let cell = Rect {
+                x: g.grid.x + col as f32 * g.cell_w,
+                y: g.grid.y + row as f32 * g.cell_h,
+                width: g.cell_w,
+                height: g.cell_h,
+            };
+            let in_month = (date.year(), date.month()) == self.view;
+            if !in_month {
+                pc.quad(cell, BG_OTHER_MONTH);
+            }
+            if date == self.selected {
+                let r = Rect {
+                    x: cell.x + 1.0,
+                    y: cell.y + 1.0,
+                    width: cell.width - 2.0,
+                    height: cell.height - 2.0,
+                };
+                pc.rounded_rect(r, 5.0, (true, true, true, true), [ACCENT[0], ACCENT[1], ACCENT[2], 0.55]);
+                let inner = Rect { x: r.x + 2.0, y: r.y + 2.0, width: r.width - 4.0, height: r.height - 4.0 };
+                pc.rounded_rect(inner, 4.0, (true, true, true, true), if in_month { BG } else { BG_OTHER_MONTH });
+            }
+
+            // Day number, top-left; today gets an accent pill.
+            let num = Rect { x: cell.x + 6.0, y: cell.y + 4.0, width: 24.0, height: 17.0 };
+            if date == self.today {
+                pc.rounded_rect(num, 8.0, (true, true, true, true), ACCENT);
+            }
+            let num_color = if date == self.today {
+                [255, 255, 255]
+            } else if in_month {
+                TEXT
+            } else {
+                TEXT_FAINT
+            };
+            pc.text_boxed(date.day().to_string(), num.x, num.y, 11.5, num_color, None, None,
+                TextAttrs { italic: false, weight: Some(600) }, Self::boxed(num, AlignH::Center));
+
+            // Event chips: dot + clipped title, then a "+N" overflow line.
+            if let Some(events) = self.events.get(&date) {
+                let line_h = 15.0;
+                let avail = ((cell.height - 26.0) / line_h).max(0.0) as usize;
+                let shown = if avail >= events.len() { events.len() } else { avail.saturating_sub(1) };
+                pc.clip(cell, |pc| {
+                    let mut y = cell.y + 24.0;
+                    for e in events.iter().take(shown) {
+                        pc.circle(cell.x + 9.0, y + 6.0, 2.5, EVENT_DOT);
+                        let alpha = if in_month { TEXT } else { TEXT_DIM };
+                        pc.text_with(e.title.clone(), cell.x + 15.0, y, 10.0, alpha, None,
+                            Some([cell.x, cell.y, cell.x + cell.width - 4.0, cell.y + cell.height]));
+                        y += line_h;
+                    }
+                    if events.len() > shown {
+                        pc.text_with(format!("+{} more", events.len() - shown), cell.x + 15.0, y,
+                            10.0, TEXT_FAINT, None, None);
+                    }
+                });
+            }
+        }
+
+        for col in 1..7 {
+            let x = g.grid.x + col as f32 * g.cell_w;
+            pc.quad(Rect { x, y: g.grid.y, width: 1.0, height: g.grid.height }, GRID_LINE);
+        }
+        for row in 0..6 {
+            let y = g.grid.y + row as f32 * g.cell_h;
+            pc.quad(Rect { x: g.grid.x, y, width: g.grid.width, height: 1.0 }, GRID_LINE);
+        }
+    }
+
+    /// Sidebar event-row rects, matching `paint_sidebar` (shared with hit-testing).
+    fn sidebar_row(&self, g: &Geom, idx: usize) -> Rect {
+        Rect {
+            x: g.sidebar.x + PAD,
+            y: 64.0 + idx as f32 * ROW_H - self.sidebar_scroll,
+            width: g.sidebar.width - 2.0 * PAD,
+            height: ROW_H - 4.0,
+        }
+    }
+
+    fn paint_sidebar(&self, pc: &mut PaintCtx, g: &Geom) {
+        pc.quad(g.sidebar, SIDEBAR_BG);
+        pc.quad(Rect { x: g.sidebar.x, y: 0.0, width: 1.0, height: g.sidebar.height }, GRID_LINE);
+
+        let heading = format!(
+            "{}, {} {}",
+            WEEKDAYS[self.selected.weekday().num_days_from_monday() as usize],
+            MONTHS[self.selected.month() as usize - 1],
+            self.selected.day()
+        );
+        let color = if self.selected == self.today { TEXT_ACCENT } else { TEXT };
+        pc.text(heading, g.sidebar.x + PAD, 18.0, 14.0, color);
+        if self.selected == self.today {
+            pc.text("today", g.sidebar.x + PAD, 38.0, 10.5, TEXT_FAINT);
+        }
+
+        let events = self.events.get(&self.selected).map(Vec::as_slice).unwrap_or(&[]);
+        let bottom_h = INPUT_H + 8.0;
+        let list = Rect {
+            x: g.sidebar.x,
+            y: 58.0,
+            width: g.sidebar.width,
+            height: g.sidebar.height - 58.0 - bottom_h,
+        };
+        pc.clip(list, |pc| {
+            if events.is_empty() {
+                pc.text("No events", g.sidebar.x + PAD, 70.0, 12.0, TEXT_FAINT);
+            }
+            for (i, e) in events.iter().enumerate() {
+                let row = self.sidebar_row(g, i);
+                if self.sel_event == Some(i) {
+                    pc.rounded_rect(row, 5.0, (true, true, true, true), ROW_SEL);
+                }
+                let time = e.time.map_or("——".to_string(), |(h, m)| format!("{h:02}:{m:02}"));
+                pc.text(time, row.x + 8.0, row.y + 9.0, 11.0,
+                    if e.time.is_some() { TEXT_ACCENT } else { TEXT_FAINT });
+                pc.text_with(e.title.clone(), row.x + 54.0, row.y + 8.0, 12.5, TEXT, None,
+                    Some([row.x, row.y, row.x + row.width - 4.0, row.y + row.height]));
+            }
+        });
+
+        // Bottom strip: the input field while typing, else the key hints.
+        let strip = Rect {
+            x: g.sidebar.x + PAD,
+            y: g.sidebar.height - bottom_h,
+            width: g.sidebar.width - 2.0 * PAD,
+            height: INPUT_H - 8.0,
+        };
+        if let Some(buffer) = &self.input {
+            pc.rounded_rect(strip, 6.0, (true, true, true, true), [0.0, 0.0, 0.0, 0.35]);
+            pc.rounded_rect(
+                Rect { x: strip.x, y: strip.y + strip.height - 2.0, width: strip.width, height: 2.0 },
+                1.0, (true, true, true, true), ACCENT);
+            let shown = if buffer.is_empty() { "HH:MM title".to_string() } else { format!("{buffer}▏") };
+            let color = if buffer.is_empty() { TEXT_FAINT } else { TEXT };
+            pc.text_with(shown, strip.x + 8.0, strip.y + 9.0, 12.5, color, None,
+                Some([strip.x, strip.y, strip.x + strip.width - 8.0, strip.y + strip.height]));
+        } else {
+            let hint = if let Some(err) = &self.status {
+                err.clone()
+            } else if self.sel_event.is_some() {
+                "d delete · n new · t today".to_string()
+            } else {
+                "n new · t today · pgup/pgdn month".to_string()
+            };
+            let color = if self.status.is_some() { [230, 130, 120] } else { TEXT_FAINT };
+            pc.text(hint, strip.x, strip.y + 12.0, 10.5, color);
+        }
+    }
+}
+
+impl Application for CalendarApp {
+    type Message = Message;
+
+    fn new(_qh: &QueueHandle<EngineState<Self>>, _sender: calloop::channel::Sender<Self::Message>) -> Self {
+        let today = Local::now().date_naive();
+        Self {
+            events: load_events(),
+            view: (today.year(), today.month()),
+            selected: today,
+            sel_event: None,
+            input: None,
+            week_start: week_start_config(),
+            today,
+            win: (1060.0, 720.0),
+            sidebar_scroll: 0.0,
+            status: None,
+        }
+    }
+
+    fn settings(&self) -> WindowSettings {
+        WindowSettings {
+            title: "Calendar".to_string(),
+            app_id: "cce-calendar".to_string(),
+            width: 1060,
+            height: 720,
+            fullscreen: false,
+            min_size: Some((700, 480)),
+        }
+    }
+
+    fn update(&mut self, msg: Self::Message, _needs_rebuild: &mut bool, exit: &mut bool) {
+        match msg {
+            Message::Quit => *exit = true,
+        }
+    }
+
+    fn tick(&mut self, _dt: f32, needs_rebuild: &mut bool) {
+        let now = Local::now().date_naive();
+        if now != self.today {
+            self.today = now;
+            *needs_rebuild = true;
+        }
+    }
+
+    fn handle_resize(&mut self, width: f32, height: f32, _scale: f64) {
+        self.win = (width, height);
+    }
+
+    fn handle_pointer_move(&mut self, _pos: LogicalPosition, _needs_rebuild: &mut bool) {}
+
+    fn handle_mouse_input(
+        &mut self,
+        button: MouseButton,
+        state: ElementState,
+        pos: LogicalPosition,
+        needs_rebuild: &mut bool,
+    ) -> Option<Self::Message> {
+        if button != MouseButton::Left || state != ElementState::Pressed {
+            return None;
+        }
+        let g = self.geom();
+        let (x, y) = (pos.x, pos.y);
+        if hit(&g.prev_btn, x, y) {
+            self.shift_months(-1);
+        } else if hit(&g.next_btn, x, y) {
+            self.shift_months(1);
+        } else if hit(&g.today_btn, x, y) {
+            self.select(self.today);
+        } else if let Some(date) = self.day_at(&g, x, y) {
+            self.select(date);
+        } else if hit(&g.sidebar, x, y) {
+            let events = self.events.get(&self.selected).map_or(0, Vec::len);
+            self.sel_event = (0..events).find(|&i| hit(&self.sidebar_row(&g, i), x, y));
+        } else {
+            return None;
+        }
+        *needs_rebuild = true;
+        None
+    }
+
+    fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
+        let g = self.geom();
+        if hit(&g.sidebar, pos.x, pos.y) {
+            let events = self.events.get(&self.selected).map_or(0, Vec::len);
+            let overflow = (events as f32 * ROW_H - (g.sidebar.height - 58.0 - INPUT_H - 8.0)).max(0.0);
+            self.sidebar_scroll = (self.sidebar_scroll - delta.notches_y() * ROW_H).clamp(0.0, overflow);
+            *needs_rebuild = true;
+        } else {
+            let notches = delta.notches_y();
+            if notches != 0.0 {
+                self.shift_months(if notches < 0.0 { 1 } else { -1 });
+                *needs_rebuild = true;
+            }
+        }
+    }
+
+    fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
+        if event.state != ElementState::Pressed {
+            return None;
+        }
+        if self.input.is_some() {
+            self.key_input_mode(event, needs_rebuild);
+            return None;
+        }
+        let (handled, quit) = self.key_browse_mode(event);
+        if handled {
+            *needs_rebuild = true;
+        }
+        quit.then_some(Message::Quit)
+    }
+
+    fn display_list(&mut self, size: LogicalSize, _scale: f64) -> Option<DisplayList> {
+        self.win = (size.width, size.height);
+        let g = self.geom();
+        let mut pc = PaintCtx::new();
+        pc.quad(Rect { x: 0.0, y: 0.0, width: size.width, height: size.height }, BG);
+        self.paint_header(&mut pc, &g);
+        self.paint_grid(&mut pc, &g);
+        self.paint_sidebar(&mut pc, &g);
+        Some(pc.finish())
+    }
+
+    fn display_list_text(&self) -> bool {
+        true
+    }
+
+    fn clear_color(&self) -> [f32; 4] {
+        BG
+    }
+}
+
+fn main() {
+    env_logger::init();
+    cce_ui::engine::run::<CalendarApp>();
+}