1use 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
18const CELL: u16 = 4;
20
21const WEEKS: u16 = 6;
23
24const HEADER_ROWS: u16 = 3;
26
27const ARROW: u16 = 3;
30
31pub 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 pointer: PointerGate,
68 pointed: Option<Date>,
71 by_pointer: bool,
73}
74
75impl<Msg: 'static> DatePicker<Msg> {
76 #[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 #[must_use]
84 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
85 self.placeholder = text.into();
86 self
87 }
88
89 #[must_use]
92 pub fn today(mut self, today: Date) -> Self {
93 self.today = Some(today);
94 self
95 }
96
97 #[must_use]
99 pub fn disabled(mut self, disabled: bool) -> Self {
100 self.disabled = disabled;
101 self
102 }
103
104 #[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 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
214fn 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
223fn 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
233fn 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
240fn 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
247fn 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 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 let keyboard_focus = !by_pointer && cx.is_focus_visible();
342 cx.with_clip(shown, |cx| {
343 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}