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> {
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 pointer: PointerGate,
65 pointed: Option<Date>,
68 by_pointer: bool,
70}
71
72impl<Msg: 'static> DatePicker<Msg> {
73 #[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 #[must_use]
81 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
82 self.placeholder = text.into();
83 self
84 }
85
86 #[must_use]
89 pub fn today(mut self, today: Date) -> Self {
90 self.today = Some(today);
91 self
92 }
93
94 #[must_use]
96 pub fn disabled(mut self, disabled: bool) -> Self {
97 self.disabled = disabled;
98 self
99 }
100
101 #[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 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
211fn 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
218fn 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
227fn 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 let keyboard_focus = !by_pointer && cx.is_focus_visible();
315 cx.with_clip(shown, |cx| {
316 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}