teksilo_core/styles/calendar_style.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Tier-3 style protocol for `Calendar`. See `docs/styling-system.md`.
5//!
6//! Multi-method trait because `Calendar` paints three distinct cell
7//! shapes, each with its own state semantics:
8//!
9//! * **Day cell** (`make_day_cell`) — the 6×7 grid of day numbers.
10//! Static state (today / out-of-month / disabled) is computed at
11//! build time; the selection-derived fill role and the roving-focus
12//! ring are reactive signals so navigation doesn't rebuild 42 cells.
13//! * **Zoom cell** (`make_zoom_cell`) — the 4×3 month / year picker
14//! cells. Hover and pressed are reactive (cells handle their own
15//! pointer state); `selected` is reactive against the visible month.
16//! * **Header** (`make_header`) — the prev-double / prev / title /
17//! next / next-double row. The four arrow buttons and the title
18//! button are pre-built (the widget computes the mode-aware step
19//! callbacks); the style only lays them out.
20//!
21//! Outer chrome (the calendar's background frame + padding) is a thin
22//! popover-style surface and stays widget-owned via raw shape tokens —
23//! not an additional style method.
24
25use std::rc::Rc;
26
27use crate::build_context::BuildContext;
28use crate::signal::Signal;
29use crate::widget_id::WidgetId;
30
31/// Selection-derived fill state for a day cell. The reactive signal in
32/// `CalendarDayConfig` recomputes on every selection change without
33/// re-`build()`ing the cell.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum CalendarDayFill {
36 /// No selection touches this cell.
37 #[default]
38 None,
39 /// This cell is the single-selection target *or* a range endpoint.
40 Selected,
41 /// This cell falls inside a multi-day range, not on an endpoint.
42 InRange,
43}
44
45/// Per-day cell input to `CalendarStyle::make_day_cell`.
46///
47/// The static fields (`is_today`, `is_out_of_month`, `is_disabled`)
48/// don't change within a build session — the calendar binds
49/// `visible_month` at `Rebuild` level, so cells are regenerated when
50/// the visible month moves. Everything reactive (selection fill, the
51/// roving-focus ring) flows through `Signal`s so per-click navigation
52/// doesn't tear down the 42-cell subtree.
53///
54/// `label` is passed as a plain string rather than a pre-built widget
55/// id so the recipe owns the label's text-colour binding — the label
56/// colour depends on the reactive fill state (Selected → `OnAccent`,
57/// otherwise `Primary`) which is paint-time data, not widget-construction
58/// data.
59pub struct CalendarDayConfig {
60 /// Day-number string (e.g. `"15"`). The recipe builds the
61 /// `TextWidget` itself so it can drive the text colour from
62 /// `fill` (Selected → `OnAccent`, otherwise `Primary`). Accepts
63 /// either a plain `String` or a `LocalizedString` from `tr!(…)` —
64 /// `LocalizedString` implements `From<…> for String`,
65 /// so `.into()` covers both shapes. Day numbers are pure digits
66 /// in IntUI and pass through as untranslated literals, but a
67 /// custom recipe is free to localize.
68 pub label: String,
69 /// Reactive selection-derived fill.
70 pub fill: Signal<CalendarDayFill>,
71 /// `true` when this cell's date equals the local "today". The
72 /// recipe paints the today ring on top of any selection fill.
73 pub is_today: bool,
74 /// `true` when this cell's date falls outside the currently-visible
75 /// month (leading / trailing 7-day padding). The recipe usually
76 /// dims the label.
77 pub is_out_of_month: bool,
78 /// `true` when this cell is unselectable (filter, min/max, etc.).
79 /// The recipe forces a disabled appearance regardless of fill.
80 pub is_disabled: bool,
81 /// `true` only while the parent calendar holds keyboard focus AND
82 /// this cell's date is the currently-focused one (roving focus).
83 pub is_focused_cell: Signal<bool>,
84 /// Cell edge length — the recipe sizes its rect to this.
85 pub cell_size: f32,
86}
87
88/// Per-cell input to `CalendarStyle::make_zoom_cell` (used for both
89/// `MonthsGrid` and `YearsGrid` cells).
90pub struct CalendarZoomCellConfig {
91 /// Month-name / year-number string. The recipe builds the
92 /// `TextWidget` itself so it can drive the text colour from
93 /// `is_selected` (Selected → `OnAccent`, otherwise `Primary`).
94 /// Month names ride in as `LocalizedString` from `tr!(…)`
95 /// converted via `.into()`; year numbers ride in as untranslated
96 /// literals.
97 pub label: String,
98 /// `true` when this cell represents the visible-month's month
99 /// (Months grid) or year (Years grid).
100 pub is_selected: Signal<bool>,
101 /// Pointer-hover state (managed by the cell widget).
102 pub is_hovered: Signal<bool>,
103 /// Pointer-press state (managed by the cell widget).
104 pub is_pressed: Signal<bool>,
105 /// Cell footprint.
106 pub cell_width: f32,
107 pub cell_height: f32,
108}
109
110/// Pre-built header components passed to `make_header`. All five slots
111/// are pre-built widgets; the recipe lays them out into a horizontal
112/// strip. When `show_navigation = false` on the calendar, every arrow
113/// slot is `None` and the recipe still returns a row containing just
114/// the title.
115pub struct CalendarHeaderConfig {
116 /// Far-left "step coarser" arrow (« — prev year in Days mode, prev
117 /// decade in Months mode, etc.).
118 pub prev_double: Option<WidgetId>,
119 /// Single-step prev arrow (‹).
120 pub prev: Option<WidgetId>,
121 /// Center title — a `Button` whose label reactively reflects the
122 /// visible month / year / decade and whose `on_activate` demotes
123 /// the calendar mode.
124 pub title: WidgetId,
125 /// Single-step next arrow (›).
126 pub next: Option<WidgetId>,
127 /// Far-right "step coarser" next arrow (»).
128 pub next_double: Option<WidgetId>,
129}
130
131pub trait CalendarStyle: 'static {
132 fn make_day_cell(&self, cfg: &CalendarDayConfig, ctx: &mut BuildContext) -> WidgetId;
133 fn make_zoom_cell(&self, cfg: &CalendarZoomCellConfig, ctx: &mut BuildContext) -> WidgetId;
134 fn make_header(&self, cfg: &CalendarHeaderConfig, ctx: &mut BuildContext) -> WidgetId;
135}
136
137pub type SharedCalendarStyle = Rc<dyn CalendarStyle>;