1mod cell;
71mod header;
72#[cfg(test)]
73mod tests;
74mod zoom_grid;
75
76use std::cell::RefCell;
77use std::rc::Rc;
78use teksilo_i18n::lit;
79
80use jiff::civil::Weekday;
81use teksilo_canvas::{Point, Rect, Size, SizeProposal};
82use teksilo_core::accessibility::AccessNodeBuilder;
83use teksilo_core::accesskit::{Action, Live, Role};
84use teksilo_core::build_context::BuildContext;
85use teksilo_core::event::{EventResponse, Key, WidgetEvent};
86use teksilo_core::signal::{Prop, Signal};
87use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
88use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
89use teksilo_core::widget_id::WidgetId;
90use teksilo_i18n::resolve_message_widget;
91use teksilo_tokens::{TextRole, TextStyleRole};
92
93use crate::button::{Button, ButtonVariant};
94use crate::common::datetime::Date;
95use crate::common::datetime::month_long_key;
96use crate::common::datetime::types::{YearMonth, today_local, weekday_from_monday_zero};
97use crate::common::datetime::weekday_short_key;
98use crate::primitives::{Center, Divider, FixedSize, HStack, Padding, Spacer, TextWidget, VStack};
99use crate::styles::recipe_calendar_style as cal_recipe;
100
101use self::cell::DayCell;
102use self::header::CalendarHeader;
103use teksilo_i18n::LocalizedString;
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
110pub struct DateRange {
111 pub start: Date,
112 pub end: Date,
113}
114
115impl DateRange {
116 pub fn new(a: Date, b: Date) -> Self {
119 if a <= b {
120 Self { start: a, end: b }
121 } else {
122 Self { start: b, end: a }
123 }
124 }
125
126 pub fn contains(&self, d: Date) -> bool {
128 d >= self.start && d <= self.end
129 }
130}
131
132#[derive(Clone)]
136pub(crate) enum SelectionBinding {
137 Single(Signal<Option<Date>>),
138 Range {
139 value: Signal<Option<DateRange>>,
140 anchor: Signal<Option<Date>>,
141 },
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
149pub enum CalendarMode {
150 #[default]
153 Days,
154 Months,
157 Years,
161}
162
163impl CalendarMode {
164 pub fn demote(self) -> Self {
167 match self {
168 Self::Days => Self::Months,
169 Self::Months => Self::Years,
170 Self::Years => Self::Years,
171 }
172 }
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
177pub enum WeekNumberDisplay {
178 #[default]
180 None,
181 Iso8601,
184}
185
186pub(crate) type DisabledDateFilter = Rc<dyn Fn(Date) -> bool>;
189pub(crate) type OnSelectionChanged = Rc<dyn Fn(Option<Date>, &mut EventContext)>;
190pub(crate) type OnRangeChanged = Rc<dyn Fn(Option<DateRange>, &mut EventContext)>;
191pub(crate) type OnMonthChanged = Rc<dyn Fn(YearMonth, &mut EventContext)>;
192pub(crate) type OnActivate = Rc<dyn Fn(Date, &mut EventContext)>;
193
194pub struct Calendar {
197 selection: SelectionBinding,
198 visible_month: Signal<YearMonth>,
199 focused_date: Signal<Date>,
200 mode: Signal<CalendarMode>,
204 first_day_of_week_override: Option<Weekday>,
206 week_numbers: WeekNumberDisplay,
207 show_today_button: bool,
208 show_navigation: bool,
209 min_date: Option<Date>,
210 max_date: Option<Date>,
211 disabled_date_filter: Option<DisabledDateFilter>,
212 label: Option<LocalizedString>,
213 enabled: Prop<bool>,
216 on_selection_changed: Option<OnSelectionChanged>,
217 on_range_changed: Option<OnRangeChanged>,
218 on_month_changed: Option<OnMonthChanged>,
219 on_activate: Option<OnActivate>,
220 range_status: Signal<String>,
223 focused: Signal<bool>,
228 root_child_id: Option<WidgetId>,
230}
231
232impl std::fmt::Debug for Calendar {
233 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234 f.debug_struct("Calendar")
235 .field("enabled", &self.enabled.get())
236 .finish_non_exhaustive()
237 }
238}
239
240impl Calendar {
241 pub fn single(value: Signal<Option<Date>>) -> Self {
244 let initial = value.get().unwrap_or_else(today_local);
245 Self::new(SelectionBinding::Single(value), initial)
246 }
247
248 pub fn range(value: Signal<Option<DateRange>>) -> Self {
251 let initial = value.get().map(|r| r.start).unwrap_or_else(today_local);
252 let anchor = Signal::new(None);
253 Self::new(SelectionBinding::Range { value, anchor }, initial)
254 }
255
256 fn new(selection: SelectionBinding, initial_focus: Date) -> Self {
257 Self {
258 selection,
259 visible_month: Signal::new(YearMonth::from_date(initial_focus)),
260 focused_date: Signal::new(initial_focus),
261 mode: Signal::new(CalendarMode::default()),
262 first_day_of_week_override: None,
263 week_numbers: WeekNumberDisplay::None,
264 show_today_button: false,
265 show_navigation: true,
266 min_date: None,
267 max_date: None,
268 disabled_date_filter: None,
269 label: None,
270 enabled: Prop::Static(true),
271 on_selection_changed: None,
272 on_range_changed: None,
273 on_month_changed: None,
274 on_activate: None,
275 range_status: Signal::new(String::new()),
276 focused: Signal::new(false),
277 root_child_id: None,
278 }
279 }
280
281 pub fn first_day_of_week(mut self, w: Weekday) -> Self {
283 self.first_day_of_week_override = Some(w);
284 self
285 }
286
287 pub fn week_numbers(mut self, mode: WeekNumberDisplay) -> Self {
289 self.week_numbers = mode;
290 self
291 }
292
293 pub fn show_today_button(mut self, show: bool) -> Self {
296 self.show_today_button = show;
297 self
298 }
299
300 pub fn show_navigation(mut self, show: bool) -> Self {
302 self.show_navigation = show;
303 self
304 }
305
306 pub fn min_date(mut self, d: Date) -> Self {
308 self.min_date = Some(d);
309 self
310 }
311
312 pub fn max_date(mut self, d: Date) -> Self {
314 self.max_date = Some(d);
315 self
316 }
317
318 pub fn disabled_date_filter(mut self, f: impl Fn(Date) -> bool + 'static) -> Self {
321 self.disabled_date_filter = Some(Rc::new(f));
322 self
323 }
324
325 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
328 let ls: LocalizedString = label.into();
329 self.label = Some(ls);
330 self
331 }
332
333 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
336 self.enabled = enabled.into();
337 self
338 }
339
340 pub fn on_selection_changed(
345 mut self,
346 f: impl Fn(Option<Date>, &mut EventContext) + 'static,
347 ) -> Self {
348 self.on_selection_changed = Some(Rc::new(f));
349 self
350 }
351
352 pub fn on_range_changed(
356 mut self,
357 f: impl Fn(Option<DateRange>, &mut EventContext) + 'static,
358 ) -> Self {
359 self.on_range_changed = Some(Rc::new(f));
360 self
361 }
362
363 pub fn on_month_changed(mut self, f: impl Fn(YearMonth, &mut EventContext) + 'static) -> Self {
366 self.on_month_changed = Some(Rc::new(f));
367 self
368 }
369
370 pub fn on_activate(mut self, f: impl Fn(Date, &mut EventContext) + 'static) -> Self {
375 self.on_activate = Some(Rc::new(f));
376 self
377 }
378
379 pub fn visible_month_signal(&self) -> Signal<YearMonth> {
381 self.visible_month.clone()
382 }
383
384 pub fn focused_date_signal(&self) -> Signal<Date> {
386 self.focused_date.clone()
387 }
388
389 pub fn mode_signal(&self) -> Signal<CalendarMode> {
393 self.mode.clone()
394 }
395}
396
397impl Widget for Calendar {
398 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
399 let theme = ctx.theme_signal().get();
400 let self_id = ctx.self_id();
401 let scale = ctx.text_scale();
408 ctx.text_scale_signal().bind_to(
409 self_id,
410 ctx.binding_registry(),
411 teksilo_core::binding::BindingLevel::Rebuild,
412 );
413 ctx.enabled_when(self_id, self.enabled.clone());
416 let enabled = self.enabled.get();
420 let week_numbers = self.week_numbers;
421 let week_number_col_width = match week_numbers {
422 WeekNumberDisplay::None => 0.0,
423 _ => cal_recipe::CALENDAR_WEEK_NUMBER_COLUMN_WIDTH * scale,
424 };
425
426 ctx.locale_signal().bind_to(
435 ctx.self_id(),
436 ctx.binding_registry(),
437 teksilo_core::binding::BindingLevel::Rebuild,
438 );
439
440 let first_dow = self.first_day_of_week_override.unwrap_or_else(|| {
442 let tag = ctx.locale_signal().get().unwrap_or_default();
443 crate::common::datetime::first_day_of_week_for_locale(&tag)
444 });
445
446 let header_id = if self.show_navigation {
448 ctx.add(CalendarHeader::new(
449 self.visible_month.clone(),
450 self.focused_date.clone(),
451 self.mode.clone(),
452 self.on_month_changed.clone(),
453 ))
454 } else {
455 ctx.add(FixedSize::new().width(0.0).height(0.0).child(Spacer::new()))
457 };
458
459 let weekday_row_id = build_weekday_row(ctx, first_dow, week_number_col_width);
462 ctx.visible_when(
463 weekday_row_id,
464 self.mode.map(|m| matches!(m, CalendarMode::Days)),
465 );
466
467 let day_body = self::CalendarBody::new(BuildGridParams {
474 visible_month: self.visible_month.clone(),
475 focused_date: self.focused_date.clone(),
476 focused: self.focused.clone(),
477 selection: self.selection.clone(),
478 first_dow,
479 week_numbers,
480 min_date: self.min_date,
481 max_date: self.max_date,
482 disabled_filter: self.disabled_date_filter.clone(),
483 enabled,
484 on_selection_changed: self.on_selection_changed.clone(),
485 on_range_changed: self.on_range_changed.clone(),
486 on_activate: self.on_activate.clone(),
487 range_status: self.range_status.clone(),
488 });
489 let zoom_cell_height = (cal_recipe::CALENDAR_CELL_SIZE * 1.4).max(36.0) * scale;
494 let zoom_cell_width = (cal_recipe::CALENDAR_CELL_SIZE * 7.0 / 3.0).max(64.0) * scale;
495 let months_body = zoom_grid::MonthsGrid::new(
496 self.visible_month.clone(),
497 self.mode.clone(),
498 enabled,
499 zoom_cell_width,
500 zoom_cell_height,
501 );
502 let years_body = zoom_grid::YearsGrid::new(
503 self.visible_month.clone(),
504 self.mode.clone(),
505 enabled,
506 zoom_cell_width,
507 zoom_cell_height,
508 );
509 let mode_index = self.mode.map(|m| match m {
510 CalendarMode::Days => 0_usize,
511 CalendarMode::Months => 1,
512 CalendarMode::Years => 2,
513 });
514 let grid_id = ctx.add(
515 crate::primitives::Switcher::new(mode_index)
516 .child(day_body)
517 .child(months_body)
518 .child(years_body),
519 );
520
521 let footer_id =
523 if self.show_today_button || matches!(self.selection, SelectionBinding::Range { .. }) {
524 Some(build_footer(
525 ctx,
526 self.show_today_button,
527 self.visible_month.clone(),
528 self.focused_date.clone(),
529 self.selection.clone(),
530 self.on_selection_changed.clone(),
531 self.on_month_changed.clone(),
532 self.range_status.clone(),
533 matches!(self.selection, SelectionBinding::Range { .. }),
534 ))
535 } else {
536 None
537 };
538
539 let mut col = VStack::new()
541 .spacing(cal_recipe::CALENDAR_SECTION_GAP * scale)
542 .add_child(header_id)
543 .add_child(weekday_row_id)
544 .add_child(grid_id);
545 if let Some(footer_id) = footer_id {
546 let divider_id = ctx.add(Divider::horizontal());
547 col = col.add_child(divider_id).add_child(footer_id);
548 }
549 let col_id = ctx.add(col);
550 let padded_id =
551 ctx.add(Padding::uniform(cal_recipe::CALENDAR_OUTER_PADDING * scale).child_id(col_id));
552
553 let bg_id = ctx.add(
563 crate::primitives::RectWidget::new()
564 .background(teksilo_tokens::SurfaceRole::Raised)
565 .border_color(teksilo_tokens::BorderRole::Default)
566 .border_width(theme.shape.border_width)
567 .corner_radius(teksilo_tokens::CornerRadius::uniform(
568 theme.shape.radius_popup,
569 )),
570 );
571 let framed_id = ctx.add(
572 crate::primitives::ZStack::new()
573 .add_child(bg_id)
574 .add_child(padded_id),
575 );
576 self.root_child_id = Some(framed_id);
577
578 let key_handler = build_keyboard_handler(
596 self.visible_month.clone(),
597 self.focused_date.clone(),
598 self.selection.clone(),
599 self.min_date,
600 self.max_date,
601 self.disabled_date_filter.clone(),
602 self.on_selection_changed.clone(),
603 self.on_range_changed.clone(),
604 self.on_activate.clone(),
605 self.on_month_changed.clone(),
606 enabled,
607 first_dow,
608 );
609
610 let focused_signal = self.focused.clone();
615 let handlers = HandlerSet::new()
616 .focusable(enabled)
617 .on_focus(move |has_focus, _ctx| {
618 focused_signal.set(has_focus);
619 })
620 .on_key(key_handler);
621 ctx.apply_self_handlers(handlers);
622
623 let self_id = ctx.self_id();
628 let registry = ctx.binding_registry();
629 self.visible_month.bind_to(
630 self_id,
631 registry,
632 teksilo_core::binding::BindingLevel::AccessibilityOnly,
633 );
634 self.focused_date.bind_to(
635 self_id,
636 registry,
637 teksilo_core::binding::BindingLevel::AccessibilityOnly,
638 );
639 match &self.selection {
640 SelectionBinding::Single(sig) => sig.bind_to(
641 self_id,
642 registry,
643 teksilo_core::binding::BindingLevel::AccessibilityOnly,
644 ),
645 SelectionBinding::Range { value, .. } => value.bind_to(
646 self_id,
647 registry,
648 teksilo_core::binding::BindingLevel::AccessibilityOnly,
649 ),
650 }
651
652 vec![framed_id]
653 }
654
655 fn layout_response(
656 &self,
657 proposal: SizeProposal,
658 ctx: &LayoutContext,
659 ) -> teksilo_core::widget::LayoutResponse {
660 match self.root_child_id {
661 Some(id) => ctx
662 .child_size(id, proposal)
663 .unwrap_or_else(|| proposal.resolve(0.0, 0.0)),
664 None => proposal.resolve(0.0, 0.0),
665 }
666 .into()
667 }
668
669 fn place_children(
670 &self,
671 bounds: Rect,
672 _proposal: SizeProposal,
673 children: &mut [WidgetPlacement],
674 _ctx: &LayoutContext,
675 ) {
676 for child in children.iter_mut() {
677 child.origin = bounds.origin();
678 child.size = bounds.size();
679 }
680 }
681
682 fn children(&self) -> Vec<WidgetId> {
683 self.root_child_id.into_iter().collect()
684 }
685
686 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
687 let ym = self.visible_month.get();
688 builder.set_role(Role::Grid);
689
690 let label = match &self.label {
691 Some(s) => s.resolve_now(),
692 None => {
693 let month_name = resolve_message_widget(month_long_key(ym.month()), &[]);
694 format!("Calendar, {} {}", month_name, ym.year())
695 }
696 };
697 builder.set_name(label);
698
699 builder.set_live(Live::Polite);
707
708 let focused = self.focused_date.get();
713 let focused_str = format!(
714 "{:04}-{:02}-{:02}",
715 focused.year(),
716 focused.month(),
717 focused.day()
718 );
719 let selection_str = match &self.selection {
720 SelectionBinding::Single(sig) => sig
721 .get()
722 .map(|d| format!("{:04}-{:02}-{:02}", d.year(), d.month(), d.day())),
723 SelectionBinding::Range { value, .. } => value.get().map(|r| {
724 format!(
725 "{:04}-{:02}-{:02} to {:04}-{:02}-{:02}",
726 r.start.year(),
727 r.start.month(),
728 r.start.day(),
729 r.end.year(),
730 r.end.month(),
731 r.end.day(),
732 )
733 }),
734 };
735 let value_text = match selection_str {
736 Some(sel) => format!("{} (selected: {})", focused_str, sel),
737 None => focused_str,
738 };
739 builder.set_value(value_text);
740
741 builder.add_action(Action::Focus);
743 }
744}
745
746fn build_weekday_row(
749 ctx: &mut BuildContext,
750 first_dow: Weekday,
751 week_number_col_width: f32,
752) -> WidgetId {
753 let scale = ctx.text_scale();
756 let mut row = HStack::new().spacing(cal_recipe::CALENDAR_CELL_GAP * scale);
757 if week_number_col_width > 0.0 {
758 let spacer = ctx.add(
760 FixedSize::new()
761 .width(week_number_col_width)
762 .height(cal_recipe::CALENDAR_WEEKDAY_ROW_HEIGHT * scale)
763 .child(Spacer::new()),
764 );
765 row = row.add_child(spacer);
766 }
767 let first_offset = first_dow.to_monday_zero_offset();
768 for i in 0..7 {
769 let dow = weekday_from_monday_zero(first_offset + i);
770 let key = weekday_short_key(dow);
771 let label = resolve_message_widget(key, &[]);
772 let long_label =
773 resolve_message_widget(crate::common::datetime::weekday_long_key(dow), &[]);
774 let text = TextWidget::new(lit!(label))
775 .style(TextStyleRole::Body)
776 .color(TextRole::Secondary)
777 .single_line()
778 .a11y_hidden();
779 let text_id = ctx.add(text);
780 let cell = WeekdayHeaderCell::new(
781 text_id,
782 long_label,
783 cal_recipe::CALENDAR_CELL_SIZE * scale,
784 cal_recipe::CALENDAR_WEEKDAY_ROW_HEIGHT * scale,
785 );
786 row = row.add_child(ctx.add(cell));
787 }
788 ctx.add(row.access_role(Role::Row))
792}
793
794struct BuildGridParams {
795 visible_month: Signal<YearMonth>,
796 focused_date: Signal<Date>,
797 focused: Signal<bool>,
798 selection: SelectionBinding,
799 first_dow: Weekday,
800 week_numbers: WeekNumberDisplay,
801 min_date: Option<Date>,
802 max_date: Option<Date>,
803 disabled_filter: Option<DisabledDateFilter>,
804 enabled: bool,
805 on_selection_changed: Option<OnSelectionChanged>,
806 on_range_changed: Option<OnRangeChanged>,
807 on_activate: Option<OnActivate>,
808 range_status: Signal<String>,
809}
810
811fn build_footer(
812 ctx: &mut BuildContext,
813 show_today: bool,
814 visible_month: Signal<YearMonth>,
815 focused_date: Signal<Date>,
816 selection: SelectionBinding,
817 on_selection_changed: Option<OnSelectionChanged>,
818 on_month_changed: Option<OnMonthChanged>,
819 range_status: Signal<String>,
820 is_range_mode: bool,
821) -> WidgetId {
822 let mut row = HStack::new().spacing(8.0);
823 if show_today {
824 let today_label = resolve_message_widget("calendar-button-today", &[]);
825 let cb_visible = visible_month.clone();
826 let cb_focused = focused_date.clone();
827 let cb_selection = selection.clone();
828 let cb_on_sel = on_selection_changed.clone();
829 let cb_on_month = on_month_changed.clone();
830 let today_btn = Button::new(lit!(today_label))
831 .variant(ButtonVariant::Filled)
832 .on_activate_fn(move |ctx_evt| {
833 let today = today_local();
834 let new_month = YearMonth::from_date(today);
835 if cb_visible.get() != new_month {
836 cb_visible.set(new_month);
837 if let Some(cb) = cb_on_month.as_ref() {
838 cb(new_month, ctx_evt);
839 }
840 }
841 cb_focused.set(today);
842 if let SelectionBinding::Single(sig) = &cb_selection
843 && sig.get() != Some(today)
844 {
845 sig.set(Some(today));
846 if let Some(cb) = cb_on_sel.as_ref() {
847 cb(Some(today), ctx_evt);
848 }
849 }
850 ctx_evt.request_frame();
851 });
852 row = row.child(today_btn);
853 }
854 if is_range_mode {
855 let status_label = TextWidget::new(lit!(""))
856 .style(TextStyleRole::Body)
857 .color(TextRole::Secondary)
858 .text(range_status.clone())
859 .single_line()
860 .a11y_hidden();
861 let spacer = ctx.add(Spacer::new());
862 row = row.add_child(spacer).child(status_label);
863 } else {
864 row = row.child(Spacer::new());
865 }
866 ctx.add(row)
867}
868
869#[derive(Debug)]
872struct WeekdayHeaderCell {
873 child_id: WidgetId,
874 long_label: String,
875 cell_size: f32,
876 cell_height: f32,
877}
878
879impl WeekdayHeaderCell {
880 fn new(child_id: WidgetId, long_label: String, cell_size: f32, cell_height: f32) -> Self {
881 Self {
882 child_id,
883 long_label,
884 cell_size,
885 cell_height,
886 }
887 }
888}
889
890impl Widget for WeekdayHeaderCell {
891 fn layout_response(
892 &self,
893 _proposal: SizeProposal,
894 _ctx: &LayoutContext,
895 ) -> teksilo_core::widget::LayoutResponse {
896 Size::new(self.cell_size, self.cell_height).into()
897 }
898
899 fn place_children(
900 &self,
901 bounds: Rect,
902 _proposal: SizeProposal,
903 children: &mut [WidgetPlacement],
904 _ctx: &LayoutContext,
905 ) {
906 for child in children.iter_mut() {
907 child.origin = Point::new(bounds.x, bounds.y);
908 child.size = bounds.size();
909 }
910 }
911
912 fn children(&self) -> Vec<WidgetId> {
913 vec![self.child_id]
914 }
915
916 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
917 builder.set_role(Role::ColumnHeader);
918 builder.set_name(&self.long_label);
919 }
920}
921
922struct CalendarBody {
925 params: BuildGridParams,
926 row_ids: RefCell<Vec<WidgetId>>,
927}
928
929impl std::fmt::Debug for CalendarBody {
930 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
931 f.debug_struct("CalendarBody").finish()
932 }
933}
934
935impl CalendarBody {
936 fn new(params: BuildGridParams) -> Self {
937 Self {
938 params,
939 row_ids: RefCell::new(Vec::new()),
940 }
941 }
942}
943
944impl Widget for CalendarBody {
945 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
946 let ym = self.params.visible_month.get();
948 let first_of_month = ym.first_day();
949 let first_dow_offset = first_of_month.weekday().to_monday_zero_offset();
950 let target_first_offset = self.params.first_dow.to_monday_zero_offset();
951 let lead = (first_dow_offset - target_first_offset).rem_euclid(7);
954 let grid_start = first_of_month
955 .checked_sub(jiff::Span::new().days(lead as i32 as i64))
956 .unwrap_or(first_of_month);
957
958 let scale = ctx.text_scale();
962 let mut row_ids = Vec::with_capacity(6);
963 let cell_size = cal_recipe::CALENDAR_CELL_SIZE * scale;
964 let cell_height = cal_recipe::CALENDAR_CELL_SIZE * scale;
965 let gap = cal_recipe::CALENDAR_CELL_GAP * scale;
966 let week_number_col_width = match self.params.week_numbers {
967 WeekNumberDisplay::None => 0.0,
968 _ => cal_recipe::CALENDAR_WEEK_NUMBER_COLUMN_WIDTH * scale,
969 };
970
971 for week in 0..6 {
972 let mut row = HStack::new().spacing(gap);
973 if week_number_col_width > 0.0 {
974 let week_first = grid_start
976 .checked_add(jiff::Span::new().days((week * 7) as i64))
977 .unwrap_or(grid_start);
978 let iso_wk = week_first
979 .checked_add(jiff::Span::new().days(3i64))
980 .unwrap_or(week_first)
981 .iso_week_date();
982 let label_text = format!("{}", iso_wk.week());
983 let week_text = TextWidget::new(lit!(label_text))
984 .style(TextStyleRole::Body)
985 .color(TextRole::Secondary)
986 .single_line()
987 .a11y_hidden();
988 let week_text_id = ctx.add(week_text);
989 row = row.add_child(
990 ctx.add(
991 FixedSize::new()
992 .width(week_number_col_width)
993 .height(cell_height)
994 .child(Center::new().child_id(week_text_id)),
995 ),
996 );
997 }
998 for day_idx in 0..7 {
999 let day_offset = (week * 7 + day_idx) as i64;
1000 let day_date = grid_start
1001 .checked_add(jiff::Span::new().days(day_offset))
1002 .unwrap_or(grid_start);
1003 let cell = DayCell::new(
1004 day_date,
1005 self.params.visible_month.clone(),
1006 self.params.focused_date.clone(),
1007 self.params.focused.clone(),
1008 self.params.selection.clone(),
1009 cell_size,
1010 self.params.min_date,
1011 self.params.max_date,
1012 self.params.disabled_filter.clone(),
1013 self.params.enabled,
1014 self.params.on_selection_changed.clone(),
1015 self.params.on_range_changed.clone(),
1016 self.params.on_activate.clone(),
1017 self.params.range_status.clone(),
1018 );
1019 row = row.add_child(ctx.add(cell));
1020 }
1021 row_ids.push(ctx.add(row.access_role(Role::Row)));
1024 }
1025 let mut col = VStack::new().spacing(gap);
1026 for id in &row_ids {
1027 col = col.add_child(*id);
1028 }
1029 let col_id = ctx.add(col);
1030 *self.row_ids.borrow_mut() = vec![col_id];
1031 let self_id = ctx.self_id();
1037 self.params.visible_month.bind_to(
1038 self_id,
1039 ctx.binding_registry(),
1040 teksilo_core::binding::BindingLevel::Rebuild,
1041 );
1042 vec![col_id]
1043 }
1044
1045 fn layout_response(
1046 &self,
1047 proposal: SizeProposal,
1048 ctx: &LayoutContext,
1049 ) -> teksilo_core::widget::LayoutResponse {
1050 let row_ids = self.row_ids.borrow();
1051 match row_ids.first() {
1052 Some(id) => ctx
1053 .child_size(*id, proposal)
1054 .unwrap_or_else(|| proposal.resolve(0.0, 0.0)),
1055 None => proposal.resolve(0.0, 0.0),
1056 }
1057 .into()
1058 }
1059
1060 fn place_children(
1061 &self,
1062 bounds: Rect,
1063 _proposal: SizeProposal,
1064 children: &mut [WidgetPlacement],
1065 _ctx: &LayoutContext,
1066 ) {
1067 for child in children.iter_mut() {
1068 child.origin = bounds.origin();
1069 child.size = bounds.size();
1070 }
1071 }
1072
1073 fn children(&self) -> Vec<WidgetId> {
1074 self.row_ids.borrow().clone()
1075 }
1076
1077 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1078 builder.set_role(Role::Group);
1082 builder.set_hidden();
1083 }
1084}
1085
1086fn build_keyboard_handler(
1089 visible_month: Signal<YearMonth>,
1090 focused_date: Signal<Date>,
1091 selection: SelectionBinding,
1092 min_date: Option<Date>,
1093 max_date: Option<Date>,
1094 disabled_filter: Option<DisabledDateFilter>,
1095 on_selection_changed: Option<OnSelectionChanged>,
1096 on_range_changed: Option<OnRangeChanged>,
1097 on_activate: Option<OnActivate>,
1098 on_month_changed: Option<OnMonthChanged>,
1099 enabled: bool,
1100 first_dow: Weekday,
1101) -> impl Fn(&WidgetEvent, &mut EventContext) -> EventResponse + 'static {
1102 let first_offset = first_dow.to_monday_zero_offset();
1103 move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
1104 if !enabled {
1105 return EventResponse::Ignored;
1106 }
1107 let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
1108 return EventResponse::Ignored;
1109 };
1110 let cur = focused_date.get();
1111 let mut new_focus: Option<Date> = None;
1112 let mut new_visible: Option<YearMonth> = None;
1113 let mut commit: bool = false;
1114
1115 match key {
1116 Key::ArrowLeft => new_focus = step_focus(cur, -1),
1117 Key::ArrowRight => new_focus = step_focus(cur, 1),
1118 Key::ArrowUp => new_focus = step_focus(cur, -7),
1119 Key::ArrowDown => new_focus = step_focus(cur, 7),
1120 Key::Home if modifiers.command() => {
1123 let ym = YearMonth::from_date(cur);
1124 new_focus = Some(ym.first_day());
1125 }
1126 Key::End if modifiers.command() => {
1127 let ym = YearMonth::from_date(cur);
1128 new_focus = Some(ym.last_day());
1129 }
1130 Key::Home => {
1131 let dow_offset = cur.weekday().to_monday_zero_offset();
1132 let lead = (dow_offset - first_offset).rem_euclid(7);
1133 new_focus = step_focus(cur, -(lead as i32));
1134 }
1135 Key::End => {
1136 let dow_offset = cur.weekday().to_monday_zero_offset();
1137 let lead = (dow_offset - first_offset).rem_euclid(7);
1138 new_focus = step_focus(cur, 6 - lead as i32);
1139 }
1140 Key::PageUp if modifiers.shift() => {
1141 let ym = YearMonth::from_date(cur).offset_months(-12);
1142 new_visible = Some(ym);
1143 new_focus = clamp_to_month(cur, ym);
1144 }
1145 Key::PageDown if modifiers.shift() => {
1146 let ym = YearMonth::from_date(cur).offset_months(12);
1147 new_visible = Some(ym);
1148 new_focus = clamp_to_month(cur, ym);
1149 }
1150 Key::PageUp => {
1151 let ym = YearMonth::from_date(cur).offset_months(-1);
1152 new_visible = Some(ym);
1153 new_focus = clamp_to_month(cur, ym);
1154 }
1155 Key::PageDown => {
1156 let ym = YearMonth::from_date(cur).offset_months(1);
1157 new_visible = Some(ym);
1158 new_focus = clamp_to_month(cur, ym);
1159 }
1160 Key::Enter | Key::Space => {
1161 commit = true;
1162 }
1163 Key::Escape => {
1164 if let SelectionBinding::Range { anchor, .. } = &selection
1165 && anchor.get().is_some()
1166 {
1167 anchor.set(None);
1168 return EventResponse::Handled;
1169 }
1170 return EventResponse::Ignored;
1171 }
1172 Key::Character(c) if (*c == 't' || *c == 'T') => {
1173 let today = today_local();
1174 let ym = YearMonth::from_date(today);
1175 if visible_month.get() != ym {
1176 visible_month.set(ym);
1177 if let Some(cb) = on_month_changed.as_ref() {
1178 cb(ym, ctx);
1179 }
1180 }
1181 focused_date.set(today);
1182 ctx.request_frame();
1183 return EventResponse::Handled;
1184 }
1185 _ => return EventResponse::Ignored,
1186 }
1187
1188 if let Some(nf) = new_focus {
1189 let nf = match (min_date, max_date) {
1191 (Some(min), _) if nf < min => min,
1192 (_, Some(max)) if nf > max => max,
1193 _ => nf,
1194 };
1195 focused_date.set(nf);
1196 let nfm = YearMonth::from_date(nf);
1198 if YearMonth::from_date(cur) != nfm && new_visible.is_none() {
1199 new_visible = Some(nfm);
1200 }
1201 }
1202 if let Some(nv) = new_visible
1203 && visible_month.get() != nv
1204 {
1205 visible_month.set(nv);
1206 if let Some(cb) = on_month_changed.as_ref() {
1207 cb(nv, ctx);
1208 }
1209 }
1210 if commit {
1211 let target = focused_date.get();
1212 if !is_date_disabled(target, min_date, max_date, disabled_filter.as_ref()) {
1213 commit_date(
1214 target,
1215 &selection,
1216 on_selection_changed.as_ref(),
1217 on_range_changed.as_ref(),
1218 on_activate.as_ref(),
1219 ctx,
1220 );
1221 }
1222 }
1223 ctx.request_frame();
1224 EventResponse::Handled
1225 }
1226}
1227
1228fn step_focus(cur: Date, days: i32) -> Option<Date> {
1229 cur.checked_add(jiff::Span::new().days(days as i64)).ok()
1230}
1231
1232fn clamp_to_month(cur: Date, ym: YearMonth) -> Option<Date> {
1233 let last = ym.last_day().day();
1234 let day = cur.day().min(last);
1235 Date::new(ym.year(), ym.month(), day).ok()
1236}
1237
1238pub(crate) fn is_date_disabled(
1239 d: Date,
1240 min: Option<Date>,
1241 max: Option<Date>,
1242 filter: Option<&DisabledDateFilter>,
1243) -> bool {
1244 if let Some(min) = min
1245 && d < min
1246 {
1247 return true;
1248 }
1249 if let Some(max) = max
1250 && d > max
1251 {
1252 return true;
1253 }
1254 if let Some(f) = filter
1255 && f(d)
1256 {
1257 return true;
1258 }
1259 false
1260}
1261
1262pub(crate) fn commit_date(
1263 d: Date,
1264 selection: &SelectionBinding,
1265 on_sel: Option<&OnSelectionChanged>,
1266 on_range: Option<&OnRangeChanged>,
1267 on_activate: Option<&OnActivate>,
1268 ctx: &mut EventContext,
1269) {
1270 match selection {
1271 SelectionBinding::Single(sig) => {
1272 sig.set(Some(d));
1273 if let Some(cb) = on_sel {
1274 cb(Some(d), ctx);
1275 }
1276 if let Some(cb) = on_activate {
1277 cb(d, ctx);
1278 }
1279 }
1280 SelectionBinding::Range { value, anchor } => {
1281 match anchor.get() {
1282 None => {
1283 anchor.set(Some(d));
1290 if let Some(cb) = on_sel {
1291 cb(Some(d), ctx);
1292 }
1293 }
1294 Some(start) => {
1295 let range = DateRange::new(start, d);
1298 anchor.set(None);
1299 value.set(Some(range));
1300 if let Some(cb) = on_range {
1301 cb(Some(range), ctx);
1302 }
1303 if let Some(cb) = on_sel {
1304 cb(Some(d), ctx);
1305 }
1306 }
1307 }
1308 }
1309 }
1310}