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 }