Skip to main content

ravenrs_widgets/lists/
year_view.rs

1//! A whole year at a glance.
2
3use std::cell::Cell;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{Border, Color, CursorIcon, Role};
8use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{Semantic, TextStyle, Weight};
11
12use crate::calendar::GridKeys;
13use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
14use crate::date::{Date, days_in_month};
15use crate::display::Tone;
16use crate::{ButtonSize, IconButton, IconName, View, util};
17
18/// The side of a day.
19const DAY: f32 = 26.0;
20/// The gap between days.
21const DAY_GAP: f32 = 2.0;
22/// The gap between months.
23const MONTH_GAP: f32 = 20.0;
24/// How many months go on a line: only those that divide a year into whole lines.
25const LINES: [usize; 5] = [6, 4, 3, 2, 1];
26
27/// What marks a day of a [`YearView`].
28#[derive(Clone, Debug, Default, PartialEq, Eq)]
29#[non_exhaustive]
30#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
31pub struct DayMark {
32    /// The color of its dot.
33    pub tone: Tone,
34    /// What screen readers say of the day, instead of the word in [`Labels`](crate::Labels).
35    pub label: Option<String>,
36}
37
38#[cfg_attr(feature = "live", ravenrs_live::expose)]
39impl DayMark {
40    /// A mark in the neutral tone.
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// The color of its dot.
46    pub fn tone(mut self, tone: Tone) -> Self {
47        self.tone = tone;
48        self
49    }
50
51    /// What screen readers say of the day.
52    pub fn label(mut self, label: impl Into<String>) -> Self {
53        self.label = Some(label.into());
54        self
55    }
56}
57
58/// The twelve months of a year, with a dot on the days that have something and a click on any
59/// of them. As many months go on a line as fit. From the keyboard the year is one Tab stop:
60/// arrows move by day and week, Home and End to the ends of the week, Page Up and Page Down by
61/// month, and Enter or Space picks.
62///
63/// ```
64/// use ravenrs_reactive::State;
65/// use ravenrs_widgets::{Date, DayMark, Ui, YearView};
66///
67/// let year = State::new(2026);
68/// let view = YearView::new(year.binding())
69///     .today(Date::new(2026, 11, 5).unwrap())
70///     .marks(|day| (day.day() == 1).then(DayMark::new))
71///     .on_pick(|day| println!("{day}"));
72/// let mut ui = Ui::new(view, ravenrs_style::Theme::raven_dark());
73/// ui.update();
74/// ```
75#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
76pub struct YearView {
77    year: Binding<i32>,
78    selected: Option<Binding<Option<Date>>>,
79    today: Option<Date>,
80    marks: Option<Rc<dyn Fn(Date) -> Option<DayMark>>>,
81    on_pick: Option<Rc<dyn Fn(Date)>>,
82    on_month: Option<Rc<dyn Fn(Date)>>,
83    columns: Option<usize>,
84}
85
86impl fmt::Debug for YearView {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        f.debug_struct("YearView").field("year", &self.year.get()).finish_non_exhaustive()
89    }
90}
91
92#[cfg_attr(feature = "live", ravenrs_live::expose)]
93impl YearView {
94    /// The year in `year`, which its buttons and the keyboard also change.
95    pub fn new(year: Binding<i32>) -> Self {
96        Self { year, selected: None, today: None, marks: None, on_pick: None, on_month: None, columns: None }
97    }
98
99    /// The chosen day, shown filled.
100    pub fn selected(mut self, selected: Binding<Option<Date>>) -> Self {
101        self.selected = Some(selected);
102        self
103    }
104
105    /// Marks this day with a ring.
106    pub fn today(mut self, today: Date) -> Self {
107        self.today = Some(today);
108        self
109    }
110
111    /// Says which days have something. It is asked for each day on each build.
112    pub fn marks(mut self, marks: impl Fn(Date) -> Option<DayMark> + 'static) -> Self {
113        self.marks = Some(Rc::new(marks));
114        self
115    }
116
117    /// Called with a day on a click, or on Enter or Space.
118    pub fn on_pick(mut self, handler: impl Fn(Date) + 'static) -> Self {
119        self.on_pick = Some(Rc::new(handler));
120        self
121    }
122
123    /// Makes the name of each month a button, called with its first day.
124    pub fn on_month(mut self, handler: impl Fn(Date) + 'static) -> Self {
125        self.on_month = Some(Rc::new(handler));
126        self
127    }
128
129    /// How many months go on a line. As many as fit by default.
130    pub fn columns(mut self, columns: usize) -> Self {
131        self.columns = Some(columns.clamp(1, 12));
132        self
133    }
134}
135
136struct YearDay<'a> {
137    date: Date,
138    view: &'a YearView,
139    active: bool,
140    keys: &'a GridKeys,
141}
142
143impl View for YearDay<'_> {
144    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
145        let theme = *cx.theme();
146        let labels = cx.labels();
147        let view = self.view;
148        let selected = view.selected.as_ref().is_some_and(|s| s.get() == Some(self.date));
149        let today = view.today == Some(self.date);
150        let mark = view.marks.as_ref().and_then(|marks| marks(self.date));
151        let lit = cx.is_hovered() || cx.is_focus_visible();
152        let ink = theme.color(Semantic::Ink);
153        let color = if selected { theme.color(Semantic::OnAccent) } else { ink };
154        let weight = (selected || today || mark.is_some()).then_some(Weight::SEMIBOLD);
155        let number = util::label(cx, &self.date.day().to_string(), TextStyle::Caption, weight, color);
156        let mut parts = vec![number];
157        if let Some(mark) = &mark {
158            let dot = if selected { color } else { mark.tone.color(&theme) };
159            parts.push(cx.container(
160                LayoutStyle {
161                    absolute: Some((DAY / 2.0 - 2.0, DAY - 6.0)),
162                    width: Some(4.0),
163                    height: Some(4.0),
164                    ..LayoutStyle::leaf()
165                },
166                &[],
167                Paint::Quad(QuadPaint { corner_radius: 2.0, ..QuadPaint::new(dot) }),
168            ));
169        }
170        let fill = if selected {
171            theme.color(Semantic::Accent)
172        } else if lit {
173            ink.with_alpha(0.08)
174        } else {
175            Color::TRANSPARENT
176        };
177        let border =
178            (today && !selected).then(|| Border { width: 1.0, color: theme.color(Semantic::Accent) });
179        let node = cx.container(
180            LayoutStyle {
181                width: Some(DAY),
182                height: Some(DAY),
183                align: Align::Center,
184                justify: Justify::Center,
185                ..LayoutStyle::stack(Axis::Horizontal)
186            },
187            &parts,
188            Paint::Quad(QuadPaint { corner_radius: theme.radii.sm, border, ..QuadPaint::new(fill) }),
189        );
190        let mut words: Vec<&str> = Vec::new();
191        if today {
192            words.push(&labels.today);
193        }
194        if let Some(mark) = &mark {
195            words.push(mark.label.as_deref().unwrap_or(&labels.day_marked));
196        }
197        let mut semantics = Semantics::new(Role::Button).name(labels.long_date(self.date)).selected(selected);
198        if !words.is_empty() {
199            semantics = semantics.description(words.join(", "));
200        }
201        cx.semantics(node, semantics);
202        if self.keys.focus_to.get() == Some(self.date) {
203            self.keys.focus_to.set(None);
204            cx.request_focus();
205        }
206        if self.active {
207            cx.focusable(node, theme.radii.sm);
208            cx.on_key(node, self.keys.handler(self.date));
209        }
210        let (chosen, date, pick) = (view.selected.clone(), self.date, view.on_pick.clone());
211        cx.on_click(
212            node,
213            Rc::new(move || {
214                if let Some(chosen) = &chosen {
215                    chosen.set(Some(date));
216                }
217                if let Some(pick) = &pick {
218                    pick(date);
219                }
220            }),
221        );
222        cx.cursor(node, CursorIcon::Pointer);
223        node
224    }
225}
226
227/// The name of a month that goes to it.
228struct MonthTitle<'a> {
229    text: &'a str,
230    date: Date,
231    open: Rc<dyn Fn(Date)>,
232}
233
234impl View for MonthTitle<'_> {
235    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
236        let theme = *cx.theme();
237        let lit = cx.is_hovered() || cx.is_focus_visible();
238        let color = theme.color(if lit { Semantic::Accent } else { Semantic::Ink });
239        let node = util::label(cx, self.text, TextStyle::Callout, Some(Weight::SEMIBOLD), color);
240        let node = cx.container(LayoutStyle::stack(Axis::Horizontal), &[node], Paint::None);
241        let (open, date) = (self.open.clone(), self.date);
242        cx.on_click(node, Rc::new(move || open(date)));
243        cx.semantics(node, Semantics::new(Role::Button).name(self.text.to_owned()));
244        cx.focusable(node, theme.radii.sm);
245        cx.cursor(node, CursorIcon::Pointer);
246        node
247    }
248}
249
250impl View for YearView {
251    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
252        let theme = *cx.theme();
253        let labels = cx.labels();
254        let year = self.year.get();
255        let first_of_year = |y: i32| Date::new(y, 1, 1).unwrap_or_else(|| Date::from_days(0));
256        let month: State<Date> = cx.use_state(|| first_of_year(year));
257        let seen = cx.use_state(|| Rc::new(Cell::new(year))).peek().clone();
258        // The keyboard walks past December through the month it keeps, and the year follows;
259        // a year set from outside takes the keyboard with it.
260        let shown = if year != seen.get() {
261            month.set(first_of_year(year));
262            year
263        } else if month.get().year() != year {
264            self.year.set(month.peek().year());
265            month.peek().year()
266        } else {
267            year
268        };
269        seen.set(shown);
270        let keys = GridKeys {
271            month: month.binding(),
272            cursor: cx.use_state(|| None),
273            focus_to: cx.use_state(|| Rc::new(Cell::new(None))).get(),
274        };
275        let selected = self.selected.as_ref().and_then(Binding::get);
276        let active = [keys.cursor.get(), selected, self.today]
277            .into_iter()
278            .flatten()
279            .find(|d| d.year() == shown)
280            .unwrap_or(first_of_year(shown));
281
282        let (back_to, on_to) = (self.year.clone(), self.year.clone());
283        let back = cx.child(
284            &IconButton::new(IconName::ChevronLeft)
285                .label(labels.previous_year.clone())
286                .size(ButtonSize::Small)
287                .on_click(move || back_to.set(shown - 1)),
288        );
289        let title = util::label(
290            cx,
291            &shown.to_string(),
292            TextStyle::Body,
293            Some(Weight::SEMIBOLD),
294            theme.color(Semantic::Ink),
295        );
296        let forward = cx.child(
297            &IconButton::new(IconName::ChevronRight)
298                .label(labels.next_year.clone())
299                .size(ButtonSize::Small)
300                .on_click(move || on_to.set(shown + 1)),
301        );
302        let header = cx.container(
303            LayoutStyle {
304                spacing: theme.spacing.s2,
305                align: Align::Center,
306                ..LayoutStyle::stack(Axis::Horizontal)
307            },
308            &[back, title, forward],
309            Paint::None,
310        );
311
312        let width = 7.0 * DAY + 6.0 * DAY_GAP;
313        let room = cx.scroll_viewport().map_or(cx.viewport().width, |size| size.width);
314        let columns = self.columns.unwrap_or_else(|| {
315            let fit = ((room + MONTH_GAP) / (width + MONTH_GAP)).floor() as usize;
316            LINES.into_iter().find(|line| *line <= fit).unwrap_or(1)
317        });
318        let mut months = Vec::with_capacity(12);
319        for number in 1..=12_u8 {
320            let Some(first) = Date::new(shown, number, 1) else { continue };
321            let name = labels.months[usize::from(number - 1)].clone();
322            let title = match &self.on_month {
323                Some(open) => cx.keyed_child(
324                    u64::from(number),
325                    &MonthTitle { text: &name, date: first, open: open.clone() },
326                ),
327                None => util::label(
328                    cx,
329                    &name,
330                    TextStyle::Callout,
331                    Some(Weight::SEMIBOLD),
332                    theme.color(Semantic::Ink),
333                ),
334            };
335            let mut cells = Vec::with_capacity(49);
336            for short in &labels.weekdays {
337                let letter: String = short.chars().take(1).collect();
338                let l = util::label(cx, &letter, TextStyle::Caption, None, theme.color(Semantic::InkMuted));
339                cx.semantics(l, Semantics::hidden());
340                cells.push(cx.container(
341                    LayoutStyle {
342                        width: Some(DAY),
343                        align: Align::Center,
344                        justify: Justify::Center,
345                        ..LayoutStyle::stack(Axis::Horizontal)
346                    },
347                    &[l],
348                    Paint::None,
349                ));
350            }
351            for _ in 0..first.weekday() {
352                cells.push(cx.container(
353                    LayoutStyle { width: Some(DAY), height: Some(DAY), ..LayoutStyle::leaf() },
354                    &[],
355                    Paint::None,
356                ));
357            }
358            for day in 1..=days_in_month(shown, number) {
359                let date = first.add_days(i32::from(day) - 1);
360                let view = YearDay { date, view: self, active: date == active, keys: &keys };
361                cells.push(cx.keyed_child(date.to_days() as u64 + (1 << 32), &view));
362            }
363            let grid = cx.container(
364                LayoutStyle { grid_columns: Some(7), spacing: DAY_GAP, ..LayoutStyle::stack(Axis::Vertical) },
365                &cells,
366                Paint::None,
367            );
368            cx.semantics(grid, Semantics::new(Role::Calendar).name(labels.month_title(first)));
369            months.push(cx.container(
370                LayoutStyle {
371                    spacing: theme.spacing.s2,
372                    width: Some(width),
373                    ..LayoutStyle::stack(Axis::Vertical)
374                },
375                &[title, grid],
376                Paint::None,
377            ));
378        }
379        let grid = cx.container(
380            LayoutStyle {
381                grid_columns: Some(columns as u16),
382                spacing: MONTH_GAP,
383                align_self: Some(Align::Start),
384                ..LayoutStyle::stack(Axis::Vertical)
385            },
386            &months,
387            Paint::None,
388        );
389        let node = cx.container(
390            LayoutStyle {
391                spacing: theme.spacing.s3,
392                align: Align::Start,
393                align_self: Some(Align::Stretch),
394                ..LayoutStyle::stack(Axis::Vertical)
395            },
396            &[header, grid],
397            Paint::None,
398        );
399        cx.report_frame(node);
400        node
401    }
402}