Skip to main content

teksilo_widgets/styles/
recipe_calendar_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `CalendarStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeCalendarStyle` ports the IntUI calendar chrome exactly:
7//!
8//! * **Day cell** — the per-state background fill (Selected → accent;
9//!   InRange → `SelectedInactive`; otherwise transparent), an optional
10//!   today-ring border, the reactive roving-focus ring, and a centered
11//!   day-number label whose colour follows the fill state.
12//! * **Zoom cell** — selected/pressed/hover/transparent background
13//!   precedence + a centered month/year label whose colour flips to
14//!   `OnAccent` when selected.
15//! * **Header** — the 5-slot row: prev-double, prev, title (Expand'd to
16//!   fill), next, next-double, with a small inter-button gap.
17//!
18//! Calendar-specific layout numbers (cell size / gap, header height,
19//! ring widths, etc.) live as `pub const`s on this module. `calendar.rs`
20//! reads them directly when it needs sizing data outside the cell chrome
21//! (weekday row, week-number column, outer padding, mode-switcher
22//! footprint).
23
24use teksilo_core::build_context::BuildContext;
25use teksilo_core::color_prop::ColorProp;
26use teksilo_core::signal::Signal;
27use teksilo_core::styles::{
28    CalendarDayConfig, CalendarDayFill, CalendarHeaderConfig, CalendarStyle, CalendarZoomCellConfig,
29};
30use teksilo_core::widget_id::WidgetId;
31use teksilo_i18n::lit;
32use teksilo_tokens::{
33    BorderRole, CornerRadius, InputTokens, SurfaceRole, TargetRole, TextRole, TextStyleRole,
34};
35
36use crate::primitives::{Center, Expand, FixedSize, HStack, RectWidget, TextWidget, ZStack};
37use teksilo_core::styles::density::{dp, spacing};
38
39// ─── IntUI design tokens for Calendar ──────────────────────────────
40// `calendar.rs` reads these directly for sizing outside the per-cell
41// chrome (weekday row, outer padding, footer divider, mode-switcher
42// footprint).
43
44/// Outer padding inside the calendar's framed surface.
45pub const CALENDAR_OUTER_PADDING: f32 = 8.0;
46/// Vertical gap between header / weekday row / day grid / footer.
47pub const CALENDAR_SECTION_GAP: f32 = 4.0;
48/// Height of the navigation header row (prev / label / next).
49pub const CALENDAR_HEADER_HEIGHT: f32 = 28.0;
50/// Height of the weekday-name row.
51pub const CALENDAR_WEEKDAY_ROW_HEIGHT: f32 = 20.0;
52/// Side length of each day cell (square cells).
53pub const CALENDAR_CELL_SIZE: f32 = 32.0;
54/// Visible day-cell content radius (selection fill).
55pub const CALENDAR_CELL_RADIUS: f32 = 4.0;
56/// Gap between day cells in both axes.
57pub const CALENDAR_CELL_GAP: f32 = 0.0;
58/// Stroke width of the today ring.
59pub const CALENDAR_TODAY_RING_WIDTH: f32 = 1.0;
60/// Edge length of header navigation arrow icons.
61pub const CALENDAR_NAV_ICON_SIZE: f32 = 12.0;
62/// Width of the optional week-number column.
63pub const CALENDAR_WEEK_NUMBER_COLUMN_WIDTH: f32 = 28.0;
64/// Header nav-arrow button footprint.
65pub const CALENDAR_NAV_ARROW_SIZE: f32 = 24.0;
66/// Header nav-arrow corner radius.
67pub const CALENDAR_NAV_ARROW_RADIUS: f32 = 4.0;
68/// Horizontal gap between the five header buttons.
69pub const CALENDAR_HEADER_GAP: f32 = 4.0;
70/// Cell corner radius shared by `MonthsGrid` and `YearsGrid` zoom cells.
71pub const CALENDAR_ZOOM_CELL_RADIUS: f32 = 6.0;
72
73/// Recipe dimensions for [`RecipeCalendarStyle`].
74#[derive(Debug, Clone, Copy, PartialEq)]
75pub struct CalendarRecipe {
76    pub outer_padding: f32,
77    pub section_gap: f32,
78    pub header_height: f32,
79    pub weekday_row_height: f32,
80    pub cell_size: f32,
81    pub cell_radius: f32,
82    pub cell_gap: f32,
83    pub today_ring_width: f32,
84    pub nav_icon_size: f32,
85    pub week_number_column_width: f32,
86    pub nav_arrow_size: f32,
87    pub nav_arrow_radius: f32,
88    pub header_gap: f32,
89    pub zoom_cell_radius: f32,
90}
91
92impl CalendarRecipe {
93    /// This recipe's dimensions resolved against a density's [`InputTokens`].
94    ///
95    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
96    /// ladder — so the shipped values below are the Compact column by
97    /// construction and cannot drift from it.
98    pub fn for_tokens(tokens: &InputTokens) -> Self {
99        Self {
100            outer_padding: spacing(CALENDAR_OUTER_PADDING, tokens),
101            section_gap: spacing(CALENDAR_SECTION_GAP, tokens),
102            header_height: dp(CALENDAR_HEADER_HEIGHT, TargetRole::Target, tokens),
103            weekday_row_height: CALENDAR_WEEKDAY_ROW_HEIGHT,
104            cell_size: dp(CALENDAR_CELL_SIZE, TargetRole::Target, tokens),
105            cell_radius: CALENDAR_CELL_RADIUS,
106            cell_gap: spacing(CALENDAR_CELL_GAP, tokens),
107            today_ring_width: CALENDAR_TODAY_RING_WIDTH,
108            nav_icon_size: CALENDAR_NAV_ICON_SIZE,
109            week_number_column_width: CALENDAR_WEEK_NUMBER_COLUMN_WIDTH,
110            nav_arrow_size: dp(CALENDAR_NAV_ARROW_SIZE, TargetRole::Target, tokens),
111            nav_arrow_radius: CALENDAR_NAV_ARROW_RADIUS,
112            header_gap: spacing(CALENDAR_HEADER_GAP, tokens),
113            zoom_cell_radius: CALENDAR_ZOOM_CELL_RADIUS,
114        }
115    }
116}
117
118impl Default for CalendarRecipe {
119    fn default() -> Self {
120        Self::for_tokens(&InputTokens::default())
121    }
122}
123
124/// Default `CalendarStyle` shipped with Teksilo.
125#[derive(Debug, Default, Clone, Copy)]
126pub struct RecipeCalendarStyle {
127    pub recipe: CalendarRecipe,
128}
129
130impl RecipeCalendarStyle {
131    pub fn new(recipe: CalendarRecipe) -> Self {
132        Self { recipe }
133    }
134
135    /// This style with every dimension resolved against a density's
136    /// [`InputTokens`], as `RecipeCalendarStyle::for_tokens(&ctx.theme().input)` at
137    /// the widget's own build site.
138    ///
139    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
140    /// tree gets exactly the values this module documents.
141    pub fn for_tokens(tokens: &InputTokens) -> Self {
142        Self {
143            recipe: CalendarRecipe::for_tokens(tokens),
144        }
145    }
146}
147
148impl CalendarStyle for RecipeCalendarStyle {
149    fn make_day_cell(&self, cfg: &CalendarDayConfig, ctx: &mut BuildContext) -> WidgetId {
150        let radius = self.recipe.cell_radius;
151        let today_ring_width = self.recipe.today_ring_width;
152
153        // ── Background fill — Selected → Selected, InRange →
154        // SelectedInactive, otherwise Transparent.
155        let bg_role: Signal<SurfaceRole> = cfg.fill.map(|f| match f {
156            CalendarDayFill::Selected => SurfaceRole::Selected,
157            CalendarDayFill::InRange => SurfaceRole::SelectedInactive,
158            CalendarDayFill::None => SurfaceRole::Transparent,
159        });
160        let bg_widget = RectWidget::new()
161            .background(bg_role)
162            .corner_radius(CornerRadius::uniform(radius));
163        let bg_id = ctx.add(bg_widget);
164
165        // ── Today ring — outline border drawn on top of the bg.
166        let ring_id = if cfg.is_today && !cfg.is_out_of_month {
167            let ring = RectWidget::new()
168                .background(SurfaceRole::Transparent)
169                .border_color(BorderRole::Focused)
170                .border_width(today_ring_width)
171                .corner_radius(CornerRadius::uniform(radius));
172            Some(ctx.add(ring))
173        } else {
174            None
175        };
176
177        // ── Roving focus ring — visible only while the parent
178        // Calendar holds keyboard focus AND this cell's date is the
179        // currently-focused one.
180        let focus_ring_width = ctx.theme_signal().get().shape.focus_ring_width;
181        let focus_ring = RectWidget::new()
182            .background(SurfaceRole::Transparent)
183            .border_color(BorderRole::Focused)
184            .border_width(focus_ring_width)
185            .corner_radius(CornerRadius::uniform(radius));
186        let focus_ring_id = ctx.add(focus_ring);
187        ctx.visible_when(focus_ring_id, cfg.is_focused_cell.clone());
188
189        // ── Day-number label. Text colour: Disabled when disabled
190        // or out-of-month; otherwise Selected → OnAccent / Normal →
191        // Primary tracked via fill.
192        let text_color: ColorProp = if cfg.is_disabled || cfg.is_out_of_month {
193            TextRole::Disabled.into()
194        } else {
195            let role_signal: Signal<TextRole> = cfg.fill.map(|f| match f {
196                CalendarDayFill::Selected => TextRole::OnAccent,
197                _ => TextRole::Primary,
198            });
199            ColorProp::DynamicTextRole(role_signal)
200        };
201        let label = TextWidget::new(lit!(cfg.label.clone()))
202            .style(TextStyleRole::Body)
203            .color(text_color)
204            .single_line()
205            .a11y_hidden();
206        let label_id = ctx.add(label);
207        let centered = ctx.add(Center::new().child(label_id));
208
209        // ── Compose: ZStack[bg, today_ring?, focus_ring, label]
210        let mut z = ZStack::new().child(bg_id);
211        if let Some(ring) = ring_id {
212            z = z.child(ring);
213        }
214        z = z.child(focus_ring_id).child(centered);
215        let z_id = ctx.add(z);
216
217        ctx.add(
218            FixedSize::new()
219                .width(cfg.cell_size)
220                .height(cfg.cell_size)
221                .child(z_id),
222        )
223    }
224
225    fn make_zoom_cell(&self, cfg: &CalendarZoomCellConfig, ctx: &mut BuildContext) -> WidgetId {
226        // Background role precedence: Selected → Pressed → Hover →
227        // Transparent. Same precedence the day grid uses.
228        let bg_role = cfg
229            .is_selected
230            .clone()
231            .zip3(&cfg.is_hovered, &cfg.is_pressed)
232            .map(|(sel, hov, prs)| {
233                if *sel {
234                    SurfaceRole::Accent
235                } else if *prs {
236                    SurfaceRole::Pressed
237                } else if *hov {
238                    SurfaceRole::Hover
239                } else {
240                    SurfaceRole::Transparent
241                }
242            });
243        let text_role = cfg.is_selected.map(|sel| {
244            if *sel {
245                TextRole::OnAccent
246            } else {
247                TextRole::Primary
248            }
249        });
250
251        let bg = RectWidget::new()
252            .background(ColorProp::DynamicSurfaceRole(bg_role))
253            .corner_radius(CornerRadius::uniform(self.recipe.zoom_cell_radius));
254        let bg_id = ctx.add(bg);
255
256        let text = TextWidget::new(lit!(cfg.label.clone()))
257            .style(TextStyleRole::Body)
258            .color(ColorProp::DynamicTextRole(text_role))
259            .single_line()
260            .a11y_hidden();
261        let text_id = ctx.add(Center::new().child(text));
262
263        let z_id = ctx.add(ZStack::new().child(bg_id).child(text_id));
264        ctx.add(
265            FixedSize::new()
266                .width(cfg.cell_width)
267                .height(cfg.cell_height)
268                .child(z_id),
269        )
270    }
271
272    fn make_header(&self, cfg: &CalendarHeaderConfig, ctx: &mut BuildContext) -> WidgetId {
273        // Wrap the title in an `Expand::horizontal()` so it fills the
274        // slack between the leading and trailing arrow pairs — the
275        // pre-migration layout used the same trick.
276        let title_filled = ctx.add(Expand::horizontal().child(cfg.title));
277
278        let mut row = HStack::new().spacing(self.recipe.header_gap);
279        if let Some(id) = cfg.prev_double {
280            row = row.child(id);
281        }
282        if let Some(id) = cfg.prev {
283            row = row.child(id);
284        }
285        row = row.child(title_filled);
286        if let Some(id) = cfg.next {
287            row = row.child(id);
288        }
289        if let Some(id) = cfg.next_double {
290            row = row.child(id);
291        }
292        ctx.add(row)
293    }
294
295    /// This style's own arrow footprint, so a recipe built by a preset —
296    /// macOS's 20 dp `NSDatePicker` stepper, say — is the one the arrows are
297    /// sized to. `tokens` is unused: the recipe was already resolved against a
298    /// density by [`CalendarRecipe::for_tokens`] at the widget's build site.
299    fn nav_arrow_size(&self, _tokens: &InputTokens) -> f32 {
300        self.recipe.nav_arrow_size
301    }
302}