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::{BorderRole, CornerRadius, SurfaceRole, TextRole, TextStyleRole};
33
34use crate::primitives::{Center, Expand, FixedSize, HStack, RectWidget, TextWidget, ZStack};
35
36// ─── IntUI design tokens for Calendar ──────────────────────────────
37// `calendar.rs` reads these directly for sizing outside the per-cell
38// chrome (weekday row, outer padding, footer divider, mode-switcher
39// footprint).
40
41/// Outer padding inside the calendar's framed surface.
42pub const CALENDAR_OUTER_PADDING: f32 = 8.0;
43/// Vertical gap between header / weekday row / day grid / footer.
44pub const CALENDAR_SECTION_GAP: f32 = 4.0;
45/// Height of the navigation header row (prev / label / next).
46pub const CALENDAR_HEADER_HEIGHT: f32 = 28.0;
47/// Height of the weekday-name row.
48pub const CALENDAR_WEEKDAY_ROW_HEIGHT: f32 = 20.0;
49/// Side length of each day cell (square cells).
50pub const CALENDAR_CELL_SIZE: f32 = 32.0;
51/// Visible day-cell content radius (selection fill).
52pub const CALENDAR_CELL_RADIUS: f32 = 4.0;
53/// Gap between day cells in both axes.
54pub const CALENDAR_CELL_GAP: f32 = 0.0;
55/// Stroke width of the today ring.
56pub const CALENDAR_TODAY_RING_WIDTH: f32 = 1.0;
57/// Edge length of header navigation arrow icons.
58pub const CALENDAR_NAV_ICON_SIZE: f32 = 12.0;
59/// Width of the optional week-number column.
60pub const CALENDAR_WEEK_NUMBER_COLUMN_WIDTH: f32 = 28.0;
61/// Header nav-arrow button footprint.
62pub const CALENDAR_NAV_ARROW_SIZE: f32 = 24.0;
63/// Header nav-arrow corner radius.
64pub const CALENDAR_NAV_ARROW_RADIUS: f32 = 4.0;
65/// Horizontal gap between the five header buttons.
66pub const CALENDAR_HEADER_GAP: f32 = 4.0;
67/// Cell corner radius shared by `MonthsGrid` and `YearsGrid` zoom cells.
68pub const CALENDAR_ZOOM_CELL_RADIUS: f32 = 6.0;
69
70/// Recipe dimensions for [`RecipeCalendarStyle`].
71#[derive(Debug, Clone, Copy, PartialEq)]
72pub struct CalendarRecipe {
73    pub outer_padding: f32,
74    pub section_gap: f32,
75    pub header_height: f32,
76    pub weekday_row_height: f32,
77    pub cell_size: f32,
78    pub cell_radius: f32,
79    pub cell_gap: f32,
80    pub today_ring_width: f32,
81    pub nav_icon_size: f32,
82    pub week_number_column_width: f32,
83    pub nav_arrow_size: f32,
84    pub nav_arrow_radius: f32,
85    pub header_gap: f32,
86    pub zoom_cell_radius: f32,
87}
88
89impl Default for CalendarRecipe {
90    fn default() -> Self {
91        Self {
92            outer_padding: CALENDAR_OUTER_PADDING,
93            section_gap: CALENDAR_SECTION_GAP,
94            header_height: CALENDAR_HEADER_HEIGHT,
95            weekday_row_height: CALENDAR_WEEKDAY_ROW_HEIGHT,
96            cell_size: CALENDAR_CELL_SIZE,
97            cell_radius: CALENDAR_CELL_RADIUS,
98            cell_gap: CALENDAR_CELL_GAP,
99            today_ring_width: CALENDAR_TODAY_RING_WIDTH,
100            nav_icon_size: CALENDAR_NAV_ICON_SIZE,
101            week_number_column_width: CALENDAR_WEEK_NUMBER_COLUMN_WIDTH,
102            nav_arrow_size: CALENDAR_NAV_ARROW_SIZE,
103            nav_arrow_radius: CALENDAR_NAV_ARROW_RADIUS,
104            header_gap: CALENDAR_HEADER_GAP,
105            zoom_cell_radius: CALENDAR_ZOOM_CELL_RADIUS,
106        }
107    }
108}
109
110/// Default `CalendarStyle` shipped with Teksilo.
111#[derive(Debug, Default, Clone, Copy)]
112pub struct RecipeCalendarStyle {
113    pub recipe: CalendarRecipe,
114}
115
116impl RecipeCalendarStyle {
117    pub fn new(recipe: CalendarRecipe) -> Self {
118        Self { recipe }
119    }
120}
121
122impl CalendarStyle for RecipeCalendarStyle {
123    fn make_day_cell(&self, cfg: &CalendarDayConfig, ctx: &mut BuildContext) -> WidgetId {
124        let radius = self.recipe.cell_radius;
125        let today_ring_width = self.recipe.today_ring_width;
126
127        // ── Background fill — Selected → Selected, InRange →
128        // SelectedInactive, otherwise Transparent.
129        let bg_role: Signal<SurfaceRole> = cfg.fill.map(|f| match f {
130            CalendarDayFill::Selected => SurfaceRole::Selected,
131            CalendarDayFill::InRange => SurfaceRole::SelectedInactive,
132            CalendarDayFill::None => SurfaceRole::Transparent,
133        });
134        let bg_widget = RectWidget::new()
135            .background(bg_role)
136            .corner_radius(CornerRadius::uniform(radius));
137        let bg_id = ctx.add(bg_widget);
138
139        // ── Today ring — outline border drawn on top of the bg.
140        let ring_id = if cfg.is_today && !cfg.is_out_of_month {
141            let ring = RectWidget::new()
142                .background(SurfaceRole::Transparent)
143                .border_color(BorderRole::Focused)
144                .border_width(today_ring_width)
145                .corner_radius(CornerRadius::uniform(radius));
146            Some(ctx.add(ring))
147        } else {
148            None
149        };
150
151        // ── Roving focus ring — visible only while the parent
152        // Calendar holds keyboard focus AND this cell's date is the
153        // currently-focused one.
154        let focus_ring_width = ctx.theme_signal().get().shape.focus_ring_width;
155        let focus_ring = RectWidget::new()
156            .background(SurfaceRole::Transparent)
157            .border_color(BorderRole::Focused)
158            .border_width(focus_ring_width)
159            .corner_radius(CornerRadius::uniform(radius));
160        let focus_ring_id = ctx.add(focus_ring);
161        ctx.visible_when(focus_ring_id, cfg.is_focused_cell.clone());
162
163        // ── Day-number label. Text colour: Disabled when disabled
164        // or out-of-month; otherwise Selected → OnAccent / Normal →
165        // Primary tracked via fill.
166        let text_color: ColorProp = if cfg.is_disabled || cfg.is_out_of_month {
167            TextRole::Disabled.into()
168        } else {
169            let role_signal: Signal<TextRole> = cfg.fill.map(|f| match f {
170                CalendarDayFill::Selected => TextRole::OnAccent,
171                _ => TextRole::Primary,
172            });
173            ColorProp::DynamicTextRole(role_signal)
174        };
175        let label = TextWidget::new(lit!(cfg.label.clone()))
176            .style(TextStyleRole::Body)
177            .color(text_color)
178            .single_line()
179            .a11y_hidden();
180        let label_id = ctx.add(label);
181        let centered = ctx.add(Center::new().child_id(label_id));
182
183        // ── Compose: ZStack[bg, today_ring?, focus_ring, label]
184        let mut z = ZStack::new().add_child(bg_id);
185        if let Some(ring) = ring_id {
186            z = z.add_child(ring);
187        }
188        z = z.add_child(focus_ring_id).add_child(centered);
189        let z_id = ctx.add(z);
190
191        ctx.add(
192            FixedSize::new()
193                .width(cfg.cell_size)
194                .height(cfg.cell_size)
195                .child_id(z_id),
196        )
197    }
198
199    fn make_zoom_cell(&self, cfg: &CalendarZoomCellConfig, ctx: &mut BuildContext) -> WidgetId {
200        // Background role precedence: Selected → Pressed → Hover →
201        // Transparent. Same precedence the day grid uses.
202        let bg_role = cfg
203            .is_selected
204            .clone()
205            .zip3(&cfg.is_hovered, &cfg.is_pressed)
206            .map(|(sel, hov, prs)| {
207                if *sel {
208                    SurfaceRole::Accent
209                } else if *prs {
210                    SurfaceRole::Pressed
211                } else if *hov {
212                    SurfaceRole::Hover
213                } else {
214                    SurfaceRole::Transparent
215                }
216            });
217        let text_role = cfg.is_selected.map(|sel| {
218            if *sel {
219                TextRole::OnAccent
220            } else {
221                TextRole::Primary
222            }
223        });
224
225        let bg = RectWidget::new()
226            .background(ColorProp::DynamicSurfaceRole(bg_role))
227            .corner_radius(CornerRadius::uniform(self.recipe.zoom_cell_radius));
228        let bg_id = ctx.add(bg);
229
230        let text = TextWidget::new(lit!(cfg.label.clone()))
231            .style(TextStyleRole::Body)
232            .color(ColorProp::DynamicTextRole(text_role))
233            .single_line();
234        let text_id = ctx.add(Center::new().child(text));
235
236        let z_id = ctx.add(ZStack::new().add_child(bg_id).add_child(text_id));
237        ctx.add(
238            FixedSize::new()
239                .width(cfg.cell_width)
240                .height(cfg.cell_height)
241                .child_id(z_id),
242        )
243    }
244
245    fn make_header(&self, cfg: &CalendarHeaderConfig, ctx: &mut BuildContext) -> WidgetId {
246        // Wrap the title in an `Expand::horizontal()` so it fills the
247        // slack between the leading and trailing arrow pairs — the
248        // pre-migration layout used the same trick.
249        let title_filled = ctx.add(Expand::horizontal().child_id(cfg.title));
250
251        let mut row = HStack::new().spacing(self.recipe.header_gap);
252        if let Some(id) = cfg.prev_double {
253            row = row.add_child(id);
254        }
255        if let Some(id) = cfg.prev {
256            row = row.add_child(id);
257        }
258        row = row.add_child(title_filled);
259        if let Some(id) = cfg.next {
260            row = row.add_child(id);
261        }
262        if let Some(id) = cfg.next_double {
263            row = row.add_child(id);
264        }
265        ctx.add(row)
266    }
267}