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