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