Skip to main content

qframe/widgets/
date_picker.rs

1//! Date picker: a field that opens a month calendar as a layer.
2
3use std::time::Duration;
4
5use super::cells;
6use super::layer::PointerGate;
7use super::placement::{self, Placement};
8use crate::date::{Date, Weekday};
9use crate::event::{Event, KeyEvent, MouseButton, MouseKind};
10use crate::geometry::{Rect, Size};
11use crate::keymap::{Key, Modifiers};
12use crate::motion::Easing;
13use crate::style::CellStyle;
14use crate::text;
15use crate::theme::State;
16use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
17
18/// Width of one day column.
19const CELL: u16 = 4;
20
21/// Rows of weeks; always six so the calendar keeps its height from month to month.
22const WEEKS: u16 = 6;
23
24/// Title row, a spare row and the weekday row above the weeks.
25const HEADER_ROWS: u16 = 3;
26
27/// Cells of a month arrow beside the title: the glyph with a cell on each side, all of them the
28/// press target.
29const ARROW: u16 = 3;
30
31/// A field showing a chosen date that opens a calendar to choose another.
32///
33/// Closed: Enter, Space, ↓ or a click opens it. Open: ←/→ move a day, ↑/↓ a week, PgUp/PgDn a
34/// month, Shift+PgUp/PgDn a year, Home/End go to the ends of the week, Enter or Space chooses,
35/// Esc or a click elsewhere closes; that click still reaches what it landed on, and a click on
36/// the field while open only closes it. The arrows beside the title and the mouse wheel change the
37/// month (each arrow is three cells that light up under the pointer). One day is highlighted:
38/// the keyboard moves it and so does the pointer once it moves. Whatever the pointer is over, the
39/// field, an arrow or a day, shows the pillar `▌` in its leftmost cell, a column that is always
40/// blank, so nothing in the date picker slides whatever [`Env::slide`](crate::env::Env::slide)
41/// says. The highlighted day's pillar breathes only while the keyboard moved it last. Month and
42/// weekday names, the first day of the week and the field format come from the `quvyta.date`
43/// locale keys.
44///
45/// The field uses the `select` styles. The calendar uses `calendar` (`bg`, `padding`),
46/// `calendar-title`, `calendar-arrow` with `hover` (`bg` over its three cells, `pillar`),
47/// `calendar-weekday`, and `calendar-day` with `hover` (the highlighted day, `pillar`), `focus` (added
48/// to `hover` while the keyboard moved the highlight), `selected`, variants `outside` and `today`.
49pub struct DatePicker<Msg> {
50    value: Option<Date>,
51    today: Option<Date>,
52    placeholder: String,
53    disabled: bool,
54    on_change: Option<Box<dyn Fn(Date) -> Msg>>,
55}
56
57#[derive(Debug, Default)]
58struct DatePickerMemory {
59    open: bool,
60    opened_at: Duration,
61    cursor: Option<Date>,
62    popup: Rect,
63    /// The pointer moves the one highlighted day only when it moves.
64    pointer: PointerGate,
65    /// A day of a neighbouring month under the pointer: it is the highlighted day without
66    /// turning the calendar to its month.
67    pointed: Option<Date>,
68    /// The pointer, not the keyboard, put the highlight where it is: its pillar stays calm.
69    by_pointer: bool,
70}
71
72impl<Msg: 'static> DatePicker<Msg> {
73    /// A date picker showing `value`.
74    #[must_use]
75    pub fn new(value: Option<Date>) -> Self {
76        Self { value, today: None, placeholder: String::new(), disabled: false, on_change: None }
77    }
78
79    /// Faint text shown while no date is chosen.
80    #[must_use]
81    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
82        self.placeholder = text.into();
83        self
84    }
85
86    /// The day marked as today; by default [`Date::today_local`], the day on the machine's own
87    /// clock and time zone, so the mark does not jump a day early or late around midnight.
88    #[must_use]
89    pub fn today(mut self, today: Date) -> Self {
90        self.today = Some(today);
91        self
92    }
93
94    /// Greys the field out; it cannot be opened.
95    #[must_use]
96    pub fn disabled(mut self, disabled: bool) -> Self {
97        self.disabled = disabled;
98        self
99    }
100
101    /// Message for choosing a different date.
102    #[must_use]
103    pub fn on_change(mut self, message: impl Fn(Date) -> Msg + 'static) -> Self {
104        self.on_change = Some(Box::new(message));
105        self
106    }
107
108    fn today_or_clock(&self) -> Date {
109        self.today.unwrap_or_else(Date::today_local)
110    }
111
112    fn open(&self, cx: &mut EventCx<'_, Msg>) {
113        let now = cx.now();
114        let cursor = self.value.unwrap_or_else(|| self.today_or_clock());
115        let pointer = cx.interaction.pointer;
116        let memory = cx.memory::<DatePickerMemory>();
117        memory.open = true;
118        memory.opened_at = now;
119        memory.cursor = Some(cursor);
120        memory.pointer = PointerGate::new(pointer);
121        memory.pointed = None;
122        memory.by_pointer = false;
123        cx.capture_keys(true);
124    }
125
126    fn close(cx: &mut EventCx<'_, Msg>) {
127        cx.memory::<DatePickerMemory>().open = false;
128        cx.capture_keys(false);
129    }
130
131    fn choose(&self, cx: &mut EventCx<'_, Msg>, date: Date) {
132        Self::close(cx);
133        cx.flash();
134        if Some(date) != self.value
135            && let Some(message) = &self.on_change
136        {
137            cx.emit(message(date));
138        }
139    }
140
141    fn cursor(&self, cx: &mut EventCx<'_, Msg>) -> Date {
142        let fallback = self.value.unwrap_or_else(|| self.today_or_clock());
143        *cx.memory::<DatePickerMemory>().cursor.get_or_insert(fallback)
144    }
145
146    fn key(&self, cx: &mut EventCx<'_, Msg>, key: &KeyEvent) -> bool {
147        // Keys move on from the day the keyboard or the pointer last put the highlight on.
148        let memory = cx.memory::<DatePickerMemory>();
149        memory.pointed = None;
150        memory.by_pointer = false;
151        let cursor = self.cursor(cx);
152        let shift = Modifiers { shift: true, ..Modifiers::default() };
153        let first = first_weekday(cx.env().i18n());
154        let moved = if key.is_plain(Key::Left) {
155            Some(cursor.add_days(-1))
156        } else if key.is_plain(Key::Right) {
157            Some(cursor.add_days(1))
158        } else if key.is_plain(Key::Up) {
159            Some(cursor.add_days(-7))
160        } else if key.is_plain(Key::Down) {
161            Some(cursor.add_days(7))
162        } else if key.is_plain(Key::PageUp) {
163            Some(cursor.add_months(-1))
164        } else if key.is_plain(Key::PageDown) {
165            Some(cursor.add_months(1))
166        } else if key.chord.mods == shift && key.chord.key == Key::PageUp {
167            Some(cursor.add_months(-12))
168        } else if key.chord.mods == shift && key.chord.key == Key::PageDown {
169            Some(cursor.add_months(12))
170        } else if key.is_plain(Key::Home) {
171            Some(cursor.start_of_week(first))
172        } else if key.is_plain(Key::End) {
173            Some(cursor.start_of_week(first).add_days(6))
174        } else {
175            None
176        };
177        if let Some(date) = moved {
178            cx.memory::<DatePickerMemory>().cursor = Some(date);
179        } else if key.is_plain(Key::Enter) || key.is_plain(Key::Space) {
180            self.choose(cx, cursor);
181        } else if key.is_plain(Key::Esc) {
182            Self::close(cx);
183        } else if key.is_plain(Key::Tab) {
184            Self::close(cx);
185            return false;
186        }
187        true
188    }
189
190    fn click_calendar(&self, cx: &mut EventCx<'_, Msg>, popup: Rect, x: i32, y: i32) {
191        let cursor = self.cursor(cx);
192        let inner = popup.inset(calendar_padding(cx.env()));
193        if y == inner.y {
194            if x < inner.x + i32::from(ARROW) {
195                cx.memory::<DatePickerMemory>().cursor = Some(cursor.add_months(-1));
196            } else if x >= inner.right() - i32::from(ARROW) {
197                cx.memory::<DatePickerMemory>().cursor = Some(cursor.add_months(1));
198            }
199            return;
200        }
201        let row = y - inner.y - i32::from(HEADER_ROWS);
202        let column = (x - inner.x) / i32::from(CELL);
203        if !(0..i32::from(WEEKS)).contains(&row) || !(0..7).contains(&column) || x < inner.x {
204            return;
205        }
206        let date = grid_start(cursor, first_weekday(cx.env().i18n())).add_days(i64::from(row * 7 + column));
207        self.choose(cx, date);
208    }
209}
210
211/// The first day of the week for the active language. Reads the environment's translator
212/// directly because events are handled outside the translation scope of painting.
213fn first_weekday(i18n: &crate::i18n::I18n) -> Weekday {
214    let number = i18n.translate("quvyta.date.first-weekday", &[]);
215    number.trim().parse::<u8>().ok().and_then(Weekday::from_number).unwrap_or(Weekday::Monday)
216}
217
218/// The first day shown for the month of `cursor`.
219fn grid_start(cursor: Date, first: Weekday) -> Date {
220    cursor.first_of_month().start_of_week(first)
221}
222
223fn month_name(month: u8) -> String {
224    crate::t!(&format!("quvyta.date.month-{month}"))
225}
226
227/// `date` written the way the active language writes dates.
228fn format_date(date: Date) -> String {
229    crate::t!("quvyta.date.format", day = u32::from(date.day()), month = month_name(date.month()), year = date.year())
230}
231
232fn calendar_padding(env: &crate::env::Env) -> crate::geometry::Padding {
233    crate::style::WidgetStyle::new(env.theme().style("calendar", None, &[]), 0.0).padding()
234}
235
236impl<Msg: 'static> Widget<Msg> for DatePicker<Msg> {
237    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
238        let style = cx.env().theme().style("select", None, &[]);
239        let (vertical, horizontal) = style.pair("padding").unwrap_or((0, 1));
240        let sample = self.value.map_or(0, |date| text::width(&format_date(date)));
241        let longest = sample.max(text::width(&self.placeholder)).max(12);
242        Size::new(cells::sum([longest, 3, horizontal.saturating_mul(2)]), vertical.saturating_mul(2).saturating_add(1))
243            .min(available)
244    }
245
246    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
247        let open = cx.memory::<DatePickerMemory>().open;
248        let mut states = if self.disabled { vec![State::Disabled] } else { cx.pressable_states() };
249        if open {
250            states.push(State::Active);
251        }
252        let label = self.value.map(format_date);
253        super::select::paint_field(cx, area, &states, label.as_deref(), &self.placeholder);
254        if !self.disabled {
255            cx.register_hit(area);
256        }
257        if open && !self.disabled {
258            cx.request_overlay(area);
259        }
260    }
261
262    fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
263        let style = cx.style("calendar", None, &[]);
264        let padding = style.padding();
265        let background = style.text().bg.unwrap_or_else(|| cx.color("overlay"));
266        let size = Size::new(
267            (CELL * 7).saturating_add(padding.horizontal()),
268            (HEADER_ROWS + WEEKS).saturating_add(padding.vertical()),
269        );
270        let (full, side) = placement::place(anchor, size, cx.clip(), Placement::Below);
271        let (opened_at, remembered) = {
272            let memory = cx.memory::<DatePickerMemory>();
273            memory.popup = full;
274            (memory.opened_at, memory.cursor)
275        };
276        let enter = cx.env().theme().motion().enter;
277        let progress = cx.progress_since(opened_at, enter, Easing::EaseOut);
278        let shown = placement::unfold(full, side, progress);
279        let grounds = cx.grounds_around(shown);
280        cx.clear(shown, background);
281        cx.register_hit(shown);
282        let mut cursor = remembered.or(self.value).unwrap_or_else(|| self.today_or_clock());
283        let today = self.today_or_clock();
284        let first = first_weekday(cx.env().i18n());
285        let inner = full.inset(padding);
286        let pointer = cx.pointer();
287        let weekday_row = inner.y + 2;
288        let day_rect = |index: i64| {
289            let (row, column) = (u16::try_from(index / 7).unwrap_or(0), u16::try_from(index % 7).unwrap_or(0));
290            Rect::new(inner.x + i32::from(column * CELL), weekday_row + 1 + i32::from(row), CELL, 1)
291        };
292        let start = grid_start(cursor, first);
293        let under_pointer = pointer
294            .and_then(|(x, y)| (0..i64::from(WEEKS * 7)).find(|index| day_rect(*index).contains(x, y)))
295            .map(|index| start.add_days(index));
296        let anywhere = cx.pointer_anywhere();
297        let highlighted = {
298            let memory = cx.memory::<DatePickerMemory>();
299            if memory.pointer.moved(anywhere) {
300                memory.pointed = None;
301                memory.by_pointer |= under_pointer.is_some();
302                match under_pointer {
303                    Some(date) if date.month() == cursor.month() => {
304                        cursor = date;
305                        memory.cursor = Some(date);
306                    }
307                    other => memory.pointed = other,
308                }
309            }
310            (memory.pointed.unwrap_or(cursor), memory.by_pointer)
311        };
312        let (highlighted, by_pointer) = highlighted;
313        // Only a highlight the keyboard moved counts as visible focus, so its pillar breathes.
314        let keyboard_focus = !by_pointer && cx.is_focus_visible();
315        cx.with_clip(shown, |cx| {
316            // Title with the month arrows at both ends.
317            let title = crate::t!("quvyta.date.title", month = month_name(cursor.month()), year = cursor.year());
318            let title_style = cx.style("calendar-title", None, &[]).text();
319            let title_width = text::width(&title);
320            let title_x = inner.x + i32::from(inner.width.saturating_sub(title_width) / 2);
321            cx.text(title_x, inner.y, &title, title_style, title_width);
322            for (glyph, x) in [("chevron-left", inner.x), ("chevron-right", inner.right() - i32::from(ARROW))] {
323                let area = Rect::new(x, inner.y, ARROW, 1);
324                let arrow = cx.env().icons().glyph(glyph).into_owned();
325                let hovered = pointer.is_some_and(|(px, py)| area.contains(px, py));
326                let states = if hovered { vec![State::Hover] } else { Vec::new() };
327                let arrow_style = cx.style("calendar-arrow", None, &states);
328                let pillar = arrow_style.color("pillar");
329                let arrow_style = arrow_style.text();
330                if let Some(bg) = arrow_style.bg {
331                    cx.fill(area, bg);
332                }
333                // The arrow's leftmost cell is blank padding around the glyph, so the pillar fits.
334                if let Some(color) = pillar {
335                    cx.pillar(x, inner.y, color);
336                }
337                cx.text(x + 1, inner.y, &arrow, CellStyle { bg: None, ..arrow_style }, text::width(&arrow));
338            }
339
340            let weekday_style = cx.style("calendar-weekday", None, &[]).text();
341            for column in 0..7u8 {
342                let weekday = Weekday::from_number((first.number() - 1 + column) % 7 + 1).unwrap_or(Weekday::Monday);
343                let name = crate::t!(&format!("quvyta.date.weekday-{}", weekday.number()));
344                let x = inner.x + i32::from(u16::from(column) * CELL) + 1;
345                cx.text(x, weekday_row, &name, weekday_style, CELL - 1);
346            }
347
348            let start = grid_start(cursor, first);
349            for index in 0..i64::from(WEEKS * 7) {
350                let date = start.add_days(index);
351                let cell = day_rect(index);
352                let mut states = Vec::new();
353                if date == highlighted {
354                    states.push(State::Hover);
355                    if keyboard_focus {
356                        states.push(State::Focus);
357                    }
358                }
359                if Some(date) == self.value {
360                    states.push(State::Selected);
361                }
362                let variant = if date.month() != cursor.month() {
363                    Some("outside")
364                } else if date == today {
365                    Some("today")
366                } else {
367                    None
368                };
369                let day_style = cx.style("calendar-day", variant, &states);
370                let pillar = day_style.color("pillar");
371                let day_style = day_style.text();
372                if let Some(bg) = day_style.bg {
373                    cx.fill(cell, bg);
374                }
375                // Every day cell keeps its first column blank for the pillar: the number never moves.
376                if let Some(color) = pillar {
377                    cx.pillar(cell.x, cell.y, color);
378                }
379                let label = format!("{:>2}", date.day());
380                cx.text(cell.x + 1, cell.y, &label, CellStyle { bg: None, ..day_style }, 2);
381            }
382        });
383        cx.stand_apart(shown, &grounds, Some(background));
384    }
385
386    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
387        if self.disabled {
388            return false;
389        }
390        let open = cx.memory::<DatePickerMemory>().open;
391        match event {
392            Event::PointerOutside => {
393                Self::close(cx);
394                true
395            }
396            Event::Key(key) if !open => {
397                let opens = key.is_plain(Key::Enter) || key.is_plain(Key::Space) || key.is_plain(Key::Down);
398                if opens {
399                    self.open(cx);
400                }
401                opens
402            }
403            Event::Key(key) => self.key(cx, key),
404            Event::Mouse(mouse) => {
405                let popup = cx.memory::<DatePickerMemory>().popup;
406                let in_popup = open && popup.contains(mouse.x, mouse.y);
407                match mouse.kind {
408                    MouseKind::Down(MouseButton::Left) if in_popup => {
409                        self.click_calendar(cx, popup, mouse.x, mouse.y);
410                        true
411                    }
412                    MouseKind::Down(MouseButton::Left) => {
413                        if open {
414                            Self::close(cx);
415                        } else {
416                            self.open(cx);
417                        }
418                        true
419                    }
420                    MouseKind::ScrollUp | MouseKind::ScrollDown if in_popup => {
421                        let months = if mouse.kind == MouseKind::ScrollUp { -1 } else { 1 };
422                        let cursor = self.cursor(cx);
423                        cx.memory::<DatePickerMemory>().cursor = Some(cursor.add_months(months));
424                        true
425                    }
426                    _ => false,
427                }
428            }
429            Event::Paste(_) => false,
430        }
431    }
432
433    fn focusable(&self) -> bool {
434        !self.disabled
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::runtime::{App, Command, Harness};
442    use crate::widget::{Length, View};
443
444    struct Demo {
445        date: Option<Date>,
446    }
447
448    impl App for Demo {
449        type Msg = Date;
450        fn update(&mut self, date: Date) -> Command<Date> {
451            self.date = Some(date);
452            Command::none()
453        }
454        fn view(&self, ui: &mut View<'_, Date>) {
455            let today = Date::new(2026, 9, 16).expect("valid");
456            ui.add(DatePicker::new(self.date).today(today).placeholder("Release date").on_change(|date| date))
457                .width(Length::Cells(24))
458                .id("date");
459        }
460    }
461
462    fn date(year: i32, month: u8, day: u8) -> Date {
463        Date::new(year, month, day).expect("valid")
464    }
465
466    #[test]
467    fn opens_a_calendar_with_weeks_from_the_locale() {
468        let mut h = Harness::new(Demo { date: Some(date(2026, 9, 3)) }, 40, 14);
469        h.set_reduced_motion(true);
470        assert!(h.screen().starts_with("  September 3, 2026"), "{}", h.screen());
471        h.press("tab").press("enter");
472        let screen = h.screen();
473        let lines: Vec<&str> = screen.lines().collect();
474        assert_eq!(lines[2], "   ◀     September 2026     ▶", "{screen}");
475        assert_eq!(lines[4], "   Su  Mo  Tu  We  Th  Fr  Sa");
476        // The highlighted day (the chosen 3rd) shows the pillar in its blank first column.
477        assert_eq!(lines[5], "   30  31   1   2 ▌ 3   4   5");
478        assert_eq!(lines[10], "    4   5   6   7   8   9  10");
479        let theme = h.env().theme();
480        // The chosen day is filled with the accent; days of other months are faint.
481        assert_eq!(h.bg(18, 5), theme.color("accent"));
482        assert_eq!(h.fg(20, 5), theme.color("ink"));
483        assert_eq!(h.fg(4, 5), theme.color("muted"));
484    }
485
486    #[test]
487    fn today_is_marked_and_turkish_weeks_start_on_monday_with_names() {
488        let mut h = Harness::new(Demo { date: None }, 40, 14);
489        h.set_reduced_motion(true).set_locale("tr").press("tab").press("enter");
490        let screen = h.screen();
491        assert!(screen.contains("Eylül 2026"), "{screen}");
492        assert!(screen.contains("Pt  Sa  Ça  Pe  Cu  Ct  Pz"), "{screen}");
493        let (x, y) = h.find("16").expect("today shown");
494        let theme = h.env().theme();
495        assert_eq!(h.fg(u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0)), theme.color("accent"));
496    }
497
498    #[test]
499    fn today_is_the_local_day_unless_given() {
500        // Read the local day on both sides, so a midnight passing in between cannot fail the test.
501        let before = Date::today_local();
502        let marked = DatePicker::<Date>::new(None).today_or_clock();
503        let after = Date::today_local();
504        assert!(marked == before || marked == after, "{marked} is not the local day {before}");
505        let given = date(2026, 9, 16);
506        assert_eq!(DatePicker::<Date>::new(None).today(given).today_or_clock(), given);
507    }
508
509    #[test]
510    fn english_weeks_start_on_sunday() {
511        let mut h = Harness::new(Demo { date: None }, 40, 14);
512        h.set_reduced_motion(true).press("tab").press("enter");
513        assert!(h.screen().contains("Su  Mo  Tu"), "{}", h.screen());
514    }
515
516    #[test]
517    fn keyboard_moves_by_day_week_month_and_week_ends() {
518        let mut h = Harness::new(Demo { date: Some(date(2026, 1, 31)) }, 40, 14);
519        h.set_reduced_motion(true).press("tab").press("enter");
520        h.press("pgdn").press("enter");
521        assert_eq!(h.app().date, Some(date(2026, 2, 28)));
522        h.press("enter").press("right").press("down").press("enter");
523        assert_eq!(h.app().date, Some(date(2026, 3, 8)));
524        h.press("enter").press("right").press("right").press("home").press("enter");
525        assert_eq!(h.app().date, Some(date(2026, 3, 8)), "English weeks start on Sunday");
526        h.press("enter").press("end").press("left").press("up").press("enter");
527        assert_eq!(h.app().date, Some(date(2026, 3, 6)));
528        h.press("enter").press("shift+pgup").press("enter");
529        assert_eq!(h.app().date, Some(date(2025, 3, 6)));
530        h.press("enter").press("esc");
531        assert!(!h.screen().contains("Mo  Tu"));
532    }
533
534    #[test]
535    fn clicks_choose_days_and_arrows_change_the_month() {
536        let mut h = Harness::new(Demo { date: Some(date(2026, 9, 3)) }, 40, 14);
537        h.set_reduced_motion(true);
538        h.click(3, 0);
539        let (x, y) = h.find("▶").expect("next month arrow");
540        h.click(x, y);
541        assert!(h.screen().contains("October 2026"), "{}", h.screen());
542        h.click_text("15");
543        assert_eq!(h.app().date, Some(date(2026, 10, 15)));
544        assert!(h.screen().contains("October 15, 2026"), "{}", h.screen());
545    }
546
547    #[test]
548    fn unfolds_over_motion_enter() {
549        let mut h = Harness::new(Demo { date: None }, 40, 14);
550        h.press("tab").press("enter");
551        assert!(!h.screen().contains("Mo"));
552        h.advance(Duration::from_millis(300));
553        assert!(h.screen().contains("Mo"));
554    }
555
556    // One highlight, and the mouse does what the keys do.
557
558    /// Days whose cell carries the highlight surface.
559    fn lit_days(h: &Harness<Demo>) -> Vec<String> {
560        let active = h.env().theme().color("active");
561        let screen = h.screen();
562        let mut days = Vec::new();
563        for (y, line) in screen.lines().enumerate().skip(5) {
564            // Day cells are four wide from the calendar's inner edge, two cells in.
565            for (column, day) in line.chars().skip(2).collect::<Vec<_>>().chunks(4).enumerate() {
566                let x = u16::try_from(column * 4 + 3).unwrap_or(0);
567                if u16::try_from(y).is_ok_and(|y| h.bg(x, y) == active) {
568                    days.push(day.iter().filter(|c| **c != '▌').collect::<String>().trim().to_owned());
569                }
570            }
571        }
572        days
573    }
574
575    #[test]
576    fn the_pointer_moves_the_one_highlighted_day() {
577        let mut h = Harness::new(Demo { date: None }, 40, 14);
578        h.set_reduced_motion(true).press("tab").press("enter");
579        assert_eq!(lit_days(&h), ["16"], "today has the keyboard highlight:\n{}", h.screen());
580        let (x, y) = h.find("22").expect("a day");
581        h.hover(x, y);
582        assert_eq!(lit_days(&h), ["22"], "only the hovered day is lit:\n{}", h.screen());
583        h.press("right");
584        assert_eq!(lit_days(&h), ["23"], "the keyboard goes on from it and the resting pointer waits");
585        h.press("enter");
586        assert_eq!(h.app().date, Some(date(2026, 9, 23)));
587    }
588
589    #[test]
590    fn a_neighbouring_months_day_under_the_pointer_is_lit_without_turning_the_month() {
591        let mut h = Harness::new(Demo { date: Some(date(2026, 9, 3)) }, 40, 14);
592        h.set_reduced_motion(true).press("tab").press("enter");
593        let (x, y) = h.find("30").expect("August 30");
594        h.hover(x, y);
595        assert!(h.screen().contains("September 2026"), "{}", h.screen());
596        assert_eq!(lit_days(&h), ["30"], "{}", h.screen());
597        h.press("right");
598        assert_eq!(lit_days(&h), ["4"], "keys continue from the September day");
599    }
600
601    #[test]
602    fn month_arrows_are_three_cell_buttons_and_the_wheel_turns_the_month() {
603        let mut h = Harness::new(Demo { date: Some(date(2026, 9, 3)) }, 40, 14);
604        h.set_reduced_motion(true).press("tab").press("enter");
605        let (x, y) = h.find("▶").expect("next month arrow");
606        let (column, row) = (u16::try_from(x).expect("x"), u16::try_from(y).expect("y"));
607        let resting = h.bg(column, row);
608        h.hover(x + 1, y);
609        let lit = h.env().theme().color("active");
610        assert_ne!(resting, lit);
611        assert_eq!([h.bg(column - 1, row), h.bg(column, row), h.bg(column + 1, row)], [lit, lit, lit]);
612        h.click(x - 1, y);
613        assert!(h.screen().contains("October 2026"), "{}", h.screen());
614        let (x, y) = h.find("◀").expect("previous month arrow");
615        h.click(x + 1, y);
616        assert!(h.screen().contains("September 2026"), "{}", h.screen());
617        let (x, y) = h.find("15").expect("a day");
618        h.mouse(MouseKind::ScrollDown, x, y);
619        assert!(h.screen().contains("October 2026"), "{}", h.screen());
620        h.mouse(MouseKind::ScrollUp, x, y).mouse(MouseKind::ScrollUp, x, y);
621        assert!(h.screen().contains("August 2026"), "{}", h.screen());
622        h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
623        assert!(!h.screen().contains('▶'), "{}", h.screen());
624    }
625
626    // Every hovered part shows the pillar in its leftmost cell and nothing slides, whether the
627    // slide setting is on or off.
628
629    /// A harness with the slide setting forced `on` or off, reduced motion so layers open at once.
630    fn harness(value: Option<Date>, slide: bool, width: u16) -> Harness<Demo> {
631        let mut env = crate::env::Env::builtin();
632        env.set_slide(slide);
633        let mut h = Harness::with_env(Demo { date: value }, env, width, 14);
634        h.set_reduced_motion(true);
635        h
636    }
637
638    /// The pillar colour a calendar style gives in `states` at pulse phase zero.
639    fn pillar_of(h: &Harness<Demo>, widget: &str, states: &[State]) -> Option<crate::color::Rgb> {
640        crate::style::WidgetStyle::new(h.env().theme().style(widget, None, states), 0.0).color("pillar")
641    }
642
643    fn cell(x: i32, y: i32) -> (u16, u16) {
644        (u16::try_from(x).expect("x"), u16::try_from(y).expect("y"))
645    }
646
647    /// The open calendar of September 2026 as the tests below see it, with `pillars` drawn over
648    /// the resting screen at `(column, row)`.
649    fn calendar_with(pillars: &[(usize, usize)]) -> String {
650        let mut lines: Vec<Vec<char>> = [
651            "▌ September 3, 2026  ▾",
652            "",
653            "   ◀     September 2026     ▶",
654            "",
655            "   Su  Mo  Tu  We  Th  Fr  Sa",
656            "   30  31   1   2   3   4   5",
657            "    6   7   8   9  10  11  12",
658            "   13  14  15  16  17  18  19",
659            "   20  21  22  23  24  25  26",
660            "   27  28  29  30   1   2   3",
661            "    4   5   6   7   8   9  10",
662            "",
663            "",
664            "",
665        ]
666        .iter()
667        .map(|line| line.chars().collect())
668        .collect();
669        for &(column, row) in pillars {
670            let line = &mut lines[row];
671            assert_eq!(line.get(column), Some(&' '), "a pillar only takes a blank cell");
672            line[column] = '▌';
673        }
674        lines.iter().map(|line| line.iter().collect::<String>()).collect::<Vec<_>>().join("\n") + "\n"
675    }
676
677    #[test]
678    fn a_hovered_day_shows_the_pillar_in_its_blank_first_column_and_never_slides() {
679        for slide in [false, true] {
680            let mut h = harness(Some(date(2026, 9, 3)), slide, 40);
681            h.click(3, 0);
682            // Opened with a click: the chosen day carries the one highlight and its calm pillar.
683            assert_eq!(h.screen(), calendar_with(&[(18, 5)]), "slide {slide}");
684            let (x, y) = h.find("22").expect("a day");
685            h.hover(x + 1, y);
686            // The pillar moves to the hovered day's first column; every number stays in place.
687            assert_eq!(h.screen(), calendar_with(&[(10, 8)]), "slide {slide}");
688            let soft = pillar_of(&h, "calendar-day", &[State::Hover]);
689            assert!(soft.is_some());
690            assert_eq!(h.fg(10, 8), soft, "a pointer highlight has the soft pillar");
691            assert_eq!(h.bg(10, 8), h.bg(11, 8), "the pillar sits on the day's lit surface");
692            // Hovering the chosen day keeps its accent fill and adds an ink pillar.
693            h.hover(20, 5);
694            assert_eq!(h.screen(), calendar_with(&[(18, 5)]), "slide {slide}");
695            assert_eq!(h.bg(19, 5), h.env().theme().color("accent"));
696            assert_eq!(h.fg(18, 5), pillar_of(&h, "calendar-day", &[State::Hover, State::Selected]));
697        }
698    }
699
700    #[test]
701    fn a_hovered_month_arrow_shows_the_pillar_in_its_first_cell() {
702        for slide in [false, true] {
703            let mut h = harness(Some(date(2026, 9, 3)), slide, 40);
704            h.click(3, 0);
705            let (x, y) = h.find("◀").expect("previous month arrow");
706            h.hover(x, y);
707            assert_eq!(h.screen(), calendar_with(&[(2, 2), (18, 5)]), "slide {slide}");
708            let soft = pillar_of(&h, "calendar-arrow", &[State::Hover]);
709            assert!(soft.is_some());
710            assert_eq!(h.fg(2, 2), soft);
711            let (x, y) = h.find("▶").expect("next month arrow");
712            h.hover(x + 1, y);
713            assert_eq!(h.screen(), calendar_with(&[(27, 2), (18, 5)]), "slide {slide}");
714            assert_eq!(h.fg(27, 2), soft);
715        }
716    }
717
718    #[test]
719    fn a_hovered_field_shows_the_pillar_and_its_label_stays() {
720        for slide in [false, true] {
721            let mut h = harness(Some(date(2026, 9, 3)), slide, 40);
722            assert_eq!(h.screen().lines().next(), Some("  September 3, 2026  ▾"), "slide {slide}");
723            h.hover(10, 0);
724            assert_eq!(h.screen().lines().next(), Some("▌ September 3, 2026  ▾"), "slide {slide}");
725        }
726    }
727
728    #[test]
729    fn only_a_highlight_the_keyboard_moved_breathes() {
730        let mut h = harness(Some(date(2026, 9, 3)), true, 40);
731        h.press("tab").press("enter").press("right");
732        assert_eq!(h.screen(), calendar_with(&[(22, 5)]));
733        let breathing = pillar_of(&h, "calendar-day", &[State::Hover, State::Focus]);
734        let soft = pillar_of(&h, "calendar-day", &[State::Hover]);
735        assert_ne!(breathing, soft);
736        assert_eq!(h.fg(22, 5), breathing, "keyboard focus breathes");
737        let breathes = h.env().theme().style("calendar-day", None, &[State::Hover, State::Focus]);
738        assert!(crate::style::WidgetStyle::new(breathes, 0.0).is_animated());
739        // The pointer takes the highlight over: its day stays calm although focus came from keys.
740        let (x, y) = h.find("22").expect("a day");
741        h.hover(x, y);
742        assert_eq!(h.screen(), calendar_with(&[(10, 8)]));
743        assert_eq!(h.fg(10, 8), soft);
744        h.press("left");
745        assert_eq!(h.screen(), calendar_with(&[(6, 8)]));
746        assert_eq!(h.fg(6, 8), breathing);
747    }
748
749    #[test]
750    fn narrow_screens_keep_the_pillar_in_the_first_column() {
751        let mut h = harness(Some(date(2026, 9, 3)), true, 26);
752        h.click(3, 0);
753        let (x, y) = h.find("22").expect("a day");
754        h.hover(x, y);
755        let screen = h.screen();
756        let lines: Vec<&str> = screen.lines().collect();
757        assert_eq!(lines[0], "▌ September 3, 2026  ▾", "{screen}");
758        assert_eq!(lines[7], "   13  14  15  16  17  18", "{screen}");
759        assert_eq!(lines[8], "   20  21 ▌22  23  24  25", "{screen}");
760        let (x, y) = h.find("◀").expect("previous month arrow");
761        h.hover(x, y);
762        assert_eq!(h.screen().lines().nth(2), Some("  ▌◀  September 2026  ▶"), "{}", h.screen());
763    }
764
765    #[test]
766    fn ascii_mode_draws_the_pillar_as_a_filled_cell() {
767        let mut h = harness(Some(date(2026, 9, 3)), false, 40);
768        h.set_glyph_mode(crate::icons::GlyphMode::Ascii).click(3, 0);
769        let (x, y) = h.find("22").expect("a day");
770        h.hover(x, y);
771        let (column, row) = cell(x - 1, y);
772        assert_eq!(h.bg(column, row), pillar_of(&h, "calendar-day", &[State::Hover]));
773        assert_eq!(h.screen().lines().nth(8), Some("   20  21  22  23  24  25  26"), "{}", h.screen());
774    }
775}