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 }