Skip to main content

ravenrs_widgets/
event_calendar.rs

1//! A month with what happens on each day, and the editor of how something repeats.
2
3use std::cell::Cell;
4use std::rc::Rc;
5
6use ravenrs_core::{Border, Color, CursorIcon, Insets, Role};
7use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
8use ravenrs_reactive::Binding;
9use ravenrs_style::{Semantic, TextStyle, Weight};
10
11use crate::calendar::GridKeys;
12use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
13use crate::date::Date;
14use crate::display::Tone;
15use crate::recurrence::{Frequency, RecurrenceEnd, RecurrenceRule};
16use crate::{
17    Button, ButtonSize, ButtonStyle, DatePicker, FormField, HStack, IconButton, IconName, SegmentedControl,
18    Stepper, Text, ToggleButton, VStack, View, util,
19};
20
21/// Something on a [`EventCalendar`]'s day.
22#[derive(Clone, Debug, PartialEq, Eq)]
23#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
24pub struct CalendarEvent {
25    /// Given back by [`EventCalendar::on_event`].
26    pub id: u64,
27    /// Its day.
28    pub date: Date,
29    /// What it is.
30    pub title: String,
31    /// Its color; say what it means in the title or next to the scheduler too.
32    pub tone: Tone,
33    /// It already happened, so it stands back.
34    pub done: bool,
35}
36
37#[cfg_attr(feature = "live", ravenrs_live::expose)]
38impl CalendarEvent {
39    /// An event on this day.
40    pub fn new(id: u64, date: Date, title: impl Into<String>) -> Self {
41        Self { id, date, title: title.into(), tone: Tone::Accent, done: false }
42    }
43
44    /// Sets the color.
45    pub fn tone(mut self, tone: Tone) -> Self {
46        self.tone = tone;
47        self
48    }
49
50    /// Marks it as already done.
51    pub fn done(mut self, done: bool) -> Self {
52        self.done = done;
53        self
54    }
55}
56
57/// A month with its events inside each day, for agendas and bookings. Use a
58/// [`Calendar`](crate::Calendar) to pick a date and this to see what is on them.
59///
60/// From the keyboard the grid is one Tab stop: arrows move by day and week, Home
61/// and End to the ends of the week, Page Up and Page Down by month, and Enter or
62/// Space picks. A day's name reads out what is on it.
63///
64/// ```
65/// use ravenrs_reactive::State;
66/// use ravenrs_widgets::{Date, CalendarEvent, EventCalendar, Tone, Ui};
67///
68/// let today = Date::new(2026, 11, 15).unwrap();
69/// let (month, day) = (State::new(today.first_of_month()), State::new(Some(today)));
70/// let calendar = EventCalendar::new(month.binding(), day.binding())
71///     .today(today)
72///     .events([CalendarEvent::new(1, today, "Aluguel").tone(Tone::Warning)]);
73/// let mut ui = Ui::new(calendar, ravenrs_style::Theme::raven_light());
74/// ui.update();
75/// ```
76#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
77pub struct EventCalendar {
78    month: Binding<Date>,
79    selected: Binding<Option<Date>>,
80    events: Vec<CalendarEvent>,
81    today: Option<Date>,
82    per_day: usize,
83    on_event: Option<Rc<dyn Fn(u64)>>,
84}
85
86impl std::fmt::Debug for EventCalendar {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.debug_struct("EventCalendar").field("events", &self.events.len()).finish_non_exhaustive()
89    }
90}
91
92/// The height of a day with room for its events, in logical pixels.
93const DAY_HEIGHT: f32 = 104.0;
94
95#[cfg_attr(feature = "live", ravenrs_live::expose)]
96impl EventCalendar {
97    /// `month` holds the month shown (any day of it), so the app can load its
98    /// events; `selected` holds the day picked.
99    pub fn new(month: Binding<Date>, selected: Binding<Option<Date>>) -> Self {
100        Self { month, selected, events: Vec::new(), today: None, per_day: 3, on_event: None }
101    }
102
103    /// The events; those outside the days shown are ignored.
104    pub fn events(mut self, events: impl IntoIterator<Item = CalendarEvent>) -> Self {
105        self.events = events.into_iter().collect();
106        self
107    }
108
109    /// Marks this day, and offers a button to come back to it.
110    pub fn today(mut self, today: Date) -> Self {
111        self.today = Some(today);
112        self
113    }
114
115    /// How many events show in a day before the rest become a count (3 by default).
116    pub fn events_per_day(mut self, count: usize) -> Self {
117        self.per_day = count.max(1);
118        self
119    }
120
121    /// Called with an event's `id` when it is clicked, after its day is picked.
122    pub fn on_event(mut self, handler: impl Fn(u64) + 'static) -> Self {
123        self.on_event = Some(Rc::new(handler));
124        self
125    }
126}
127
128struct EventChip<'a> {
129    event: &'a CalendarEvent,
130    selected: &'a Binding<Option<Date>>,
131    on_event: Option<Rc<dyn Fn(u64)>>,
132}
133
134impl View for EventChip<'_> {
135    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
136        let theme = *cx.theme();
137        let event = self.event;
138        let hovered = cx.is_hovered();
139        let color = if event.done { theme.color(Semantic::InkMuted) } else { event.tone.color(&theme) };
140        let fill = color.with_alpha(match (event.done, hovered) {
141            (true, false) => 0.08,
142            (true, true) | (false, false) => 0.16,
143            (false, true) => 0.26,
144        });
145        let mut children = Vec::new();
146        if event.done {
147            children.push(util::icon(cx, IconName::Check, 12.0, color));
148        }
149        let ink = if event.done { color } else { theme.color(Semantic::Ink) };
150        let title =
151            cx.child(&Text::new(event.title.clone()).style(TextStyle::Caption).foreground(ink).truncate());
152        children.push(cx.container(
153            LayoutStyle {
154                grow: 1.0,
155                min_width: Some(0.0),
156                overflow_hidden: true,
157                ..LayoutStyle::stack(Axis::Horizontal)
158            },
159            &[title],
160            Paint::None,
161        ));
162        let node = cx.container(
163            LayoutStyle {
164                padding: Insets::symmetric(2.0, 6.0),
165                spacing: 4.0,
166                align: Align::Center,
167                min_width: Some(0.0),
168                overflow_hidden: true,
169                ..LayoutStyle::stack(Axis::Horizontal)
170            },
171            &children,
172            Paint::Quad(QuadPaint { corner_radius: theme.radii.sm, ..QuadPaint::new(fill) }),
173        );
174        cx.semantics(node, Semantics::new(Role::Button).name(event.title.clone()));
175        let (selected, date, id, on_event) =
176            (self.selected.clone(), event.date, event.id, self.on_event.clone());
177        cx.on_click(
178            node,
179            Rc::new(move || {
180                selected.set(Some(date));
181                if let Some(handler) = &on_event {
182                    handler(id);
183                }
184            }),
185        );
186        cx.cursor(node, CursorIcon::Pointer);
187        node
188    }
189}
190
191struct CalendarDay<'a> {
192    date: Date,
193    in_month: bool,
194    active: bool,
195    events: Vec<&'a CalendarEvent>,
196    scheduler: &'a EventCalendar,
197    keys: &'a GridKeys,
198}
199
200impl View for CalendarDay<'_> {
201    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
202        let theme = *cx.theme();
203        let s = self.scheduler;
204        let labels = cx.labels();
205        let (selected, is_today) = (s.selected.get() == Some(self.date), s.today == Some(self.date));
206        let hovered = cx.is_hovered() || cx.is_focus_visible();
207        let ink = theme.color(Semantic::Ink);
208        let number_color = if is_today {
209            theme.color(Semantic::OnAccent)
210        } else if self.in_month {
211            ink
212        } else {
213            theme.color(Semantic::InkMuted)
214        };
215        let weight = is_today.then_some(Weight::SEMIBOLD);
216        let number = util::label(cx, &self.date.day().to_string(), TextStyle::Caption, weight, number_color);
217        cx.semantics(number, Semantics::hidden());
218        let number = cx.container(
219            LayoutStyle {
220                width: Some(22.0),
221                height: Some(22.0),
222                align: Align::Center,
223                justify: Justify::Center,
224                align_self: Some(Align::Fit),
225                ..LayoutStyle::stack(Axis::Horizontal)
226            },
227            &[number],
228            if is_today {
229                Paint::Quad(QuadPaint {
230                    corner_radius: 11.0,
231                    ..QuadPaint::new(theme.color(Semantic::Accent))
232                })
233            } else {
234                Paint::None
235            },
236        );
237        let mut children = vec![number];
238        for event in self.events.iter().take(s.per_day) {
239            let chip = EventChip { event, selected: &s.selected, on_event: s.on_event.clone() };
240            children.push(cx.keyed_child(event.id, &chip));
241        }
242        let hidden = self.events.len().saturating_sub(s.per_day);
243        if hidden > 0 {
244            let more = format!("+{hidden} {}", labels.calendar_more);
245            let line = util::label(cx, &more, TextStyle::Caption, None, theme.color(Semantic::InkMuted));
246            cx.semantics(line, Semantics::hidden());
247            children.push(line);
248        }
249        let fill = if hovered {
250            ink.with_alpha(0.05)
251        } else if self.in_month {
252            Color::TRANSPARENT
253        } else {
254            ink.with_alpha(0.02)
255        };
256        let border = if selected {
257            Border { width: 1.5, color: theme.color(Semantic::Accent) }
258        } else {
259            Border { width: 1.0, color: theme.color(Semantic::Border) }
260        };
261        let node = cx.container(
262            LayoutStyle {
263                min_height: Some(DAY_HEIGHT),
264                min_width: Some(0.0),
265                padding: Insets::uniform(4.0),
266                spacing: 3.0,
267                align: Align::Stretch,
268                overflow_hidden: true,
269                ..LayoutStyle::stack(Axis::Vertical)
270            },
271            &children,
272            Paint::Quad(QuadPaint {
273                corner_radius: theme.radii.sm,
274                border: Some(border),
275                ..QuadPaint::new(fill)
276            }),
277        );
278        let mut semantics = Semantics::new(Role::Button).name(labels.long_date(self.date)).selected(selected);
279        let mut notes: Vec<String> = Vec::new();
280        if is_today {
281            notes.push(labels.today.clone());
282        }
283        notes.extend(self.events.iter().map(|e| e.title.clone()));
284        if !notes.is_empty() {
285            semantics = semantics.description(notes.join(", "));
286        }
287        cx.semantics(node, semantics);
288        if self.keys.focus_to.get() == Some(self.date) {
289            self.keys.focus_to.set(None);
290            cx.request_focus();
291        }
292        if self.active {
293            cx.focusable(node, theme.radii.sm);
294            cx.on_key(node, self.keys.handler(self.date));
295        }
296        let (pick, date) = (s.selected.clone(), self.date);
297        cx.on_click(node, Rc::new(move || pick.set(Some(date))));
298        cx.cursor(node, CursorIcon::Pointer);
299        node
300    }
301}
302
303impl View for EventCalendar {
304    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
305        let theme = *cx.theme();
306        let labels = cx.labels();
307        let shown = self.month.get().first_of_month();
308        let (prev, next) = (self.month.clone(), self.month.clone());
309        let mut header = vec![
310            cx.child(
311                &IconButton::new(IconName::ChevronLeft)
312                    .label(labels.previous_month.clone())
313                    .size(ButtonSize::Small)
314                    .on_click(move || prev.set(shown.add_months(-1))),
315            ),
316            cx.child(
317                &IconButton::new(IconName::ChevronRight)
318                    .label(labels.next_month.clone())
319                    .size(ButtonSize::Small)
320                    .on_click(move || next.set(shown.add_months(1))),
321            ),
322            util::label(
323                cx,
324                &labels.month_title(shown),
325                TextStyle::Headline,
326                None,
327                theme.color(Semantic::Ink),
328            ),
329        ];
330        if let Some(today) = self.today {
331            let (month, selected) = (self.month.clone(), self.selected.clone());
332            header.push(
333                cx.child(
334                    &Button::new(labels.today.clone())
335                        .style(ButtonStyle::Ghost)
336                        .size(ButtonSize::Small)
337                        .on_click(move || {
338                            month.set(today.first_of_month());
339                            selected.set(Some(today));
340                        }),
341                ),
342            );
343        }
344        let header = cx.container(
345            LayoutStyle { spacing: 6.0, align: Align::Center, ..LayoutStyle::stack(Axis::Horizontal) },
346            &header,
347            Paint::None,
348        );
349        let mut cells: Vec<NodeId> = labels
350            .weekdays
351            .iter()
352            .map(|d| {
353                let name = util::label(cx, d, TextStyle::Caption, None, theme.color(Semantic::InkMuted));
354                // Each day's name already says the date.
355                cx.semantics(name, Semantics::hidden());
356                name
357            })
358            .collect();
359        let keys = GridKeys {
360            month: self.month.clone(),
361            cursor: cx.use_state(|| None),
362            focus_to: cx.use_state(|| Rc::new(Cell::new(None))).get(),
363        };
364        let in_shown = |d: &Date| d.first_of_month() == shown;
365        let active = [keys.cursor.get(), self.selected.get(), self.today]
366            .into_iter()
367            .flatten()
368            .find(in_shown)
369            .unwrap_or(shown);
370        // As many weeks as the month touches, so a short month leaves no empty row.
371        let start = shown.add_days(-i32::from(shown.weekday()));
372        let last = shown.add_months(1).add_days(-1);
373        let days = i32::try_from(last.to_days() - start.to_days()).unwrap_or(34) / 7 * 7 + 7;
374        for i in 0..days {
375            let date = start.add_days(i);
376            let events = self.events.iter().filter(|e| e.date == date).collect();
377            let day = CalendarDay {
378                date,
379                in_month: in_shown(&date),
380                active: date == active,
381                events,
382                scheduler: self,
383                keys: &keys,
384            };
385            cells.push(cx.keyed_child(date.to_days() as u64, &day));
386        }
387        let grid = cx.container(
388            LayoutStyle {
389                grid_columns: Some(7),
390                spacing: 4.0,
391                align_self: Some(Align::Stretch),
392                ..LayoutStyle::stack(Axis::Vertical)
393            },
394            &cells,
395            Paint::None,
396        );
397        cx.semantics(grid, Semantics::new(Role::Calendar).name(labels.month_title(shown)));
398        cx.container(
399            LayoutStyle { spacing: 10.0, align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
400            &[header, grid],
401            Paint::None,
402        )
403    }
404}
405
406/// Edits a [`RecurrenceRule`]: how often, every how many, on which weekdays and
407/// until when, and says in words what it chose. `start` is the first day, which
408/// the rule counts from.
409///
410/// ```
411/// use ravenrs_reactive::State;
412/// use ravenrs_widgets::{Date, RecurrenceEditor, RecurrenceRule, Ui};
413///
414/// let rule = State::new(RecurrenceRule::default());
415/// let start = Date::new(2026, 11, 5).unwrap();
416/// let mut ui = Ui::new(RecurrenceEditor::new(rule.binding(), start), ravenrs_style::Theme::raven_light());
417/// ui.update();
418/// ```
419#[derive(Debug)]
420#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
421pub struct RecurrenceEditor {
422    rule: Binding<RecurrenceRule>,
423    start: Date,
424}
425
426#[cfg_attr(feature = "live", ravenrs_live::expose)]
427impl RecurrenceEditor {
428    /// An editor of `rule`, for something first happening on `start`.
429    pub fn new(rule: Binding<RecurrenceRule>, start: Date) -> Self {
430        Self { rule, start }
431    }
432}
433
434/// A binding to one part of the rule.
435fn part<T: 'static>(
436    rule: &Binding<RecurrenceRule>,
437    read: impl Fn(&RecurrenceRule) -> T + 'static,
438    write: impl Fn(&mut RecurrenceRule, T) + 'static,
439) -> Binding<T> {
440    let (from, to) = (rule.clone(), rule.clone());
441    Binding::new(move || read(&from.get()), move |value| to.update(|r| write(r, value)))
442}
443
444impl View for RecurrenceEditor {
445    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
446        let labels = cx.labels();
447        let rule = self.rule.get();
448        let repeats = rule.frequency != Frequency::Once;
449        let frequency = part(
450            &self.rule,
451            |r| Frequency::ALL.iter().position(|f| *f == r.frequency).unwrap_or(0),
452            |r, i: usize| r.frequency = Frequency::ALL[i.min(Frequency::ALL.len() - 1)],
453        );
454        let mut rows: Vec<crate::AnyView> = vec![Box::new(
455            SegmentedControl::new(frequency, labels.recurrence_frequencies.clone())
456                .label(labels.recurrence_name.clone()),
457        )];
458        if repeats {
459            let interval = part(
460                &self.rule,
461                |r| i32::try_from(r.interval.max(1)).unwrap_or(1),
462                |r, n: i32| r.interval = u32::try_from(n.max(1)).unwrap_or(1),
463            );
464            rows.push(Box::new(
465                HStack::new((
466                    Text::new(labels.recurrence_every.clone()).style(TextStyle::Callout),
467                    Stepper::new(interval).range(1, 99).label(labels.recurrence_every.clone()),
468                    Text::new(labels.recurrence_unit(rule.frequency, rule.interval).to_owned())
469                        .style(TextStyle::Callout),
470                ))
471                .spacing(8.0)
472                .align(Align::Center),
473            ));
474        }
475        if rule.frequency == Frequency::Weekly {
476            let own = usize::from(self.start.weekday());
477            let days: Vec<crate::AnyView> = (0..7)
478                .map(|day| {
479                    let chosen = part(
480                        &self.rule,
481                        // With none chosen the rule keeps the first day's weekday, so that one shows on.
482                        move |r| if r.weekdays.contains(&true) { r.weekdays[day] } else { day == own },
483                        move |r, on: bool| {
484                            if !r.weekdays.contains(&true) {
485                                r.weekdays[own] = true;
486                            }
487                            r.weekdays[day] = on;
488                        },
489                    );
490                    Box::new(ToggleButton::new(chosen, labels.weekdays[day].clone())) as crate::AnyView
491                })
492                .collect();
493            rows.push(Box::new(HStack::new(days).spacing(4.0)));
494        }
495        if repeats {
496            let start = self.start;
497            let kind = part(
498                &self.rule,
499                |r| match r.end {
500                    RecurrenceEnd::Until(_) => 1,
501                    RecurrenceEnd::After(_) => 2,
502                    _ => 0,
503                },
504                move |r, i: usize| {
505                    r.end = match i {
506                        1 => RecurrenceEnd::Until(start.add_months(12)),
507                        2 => RecurrenceEnd::After(12),
508                        _ => RecurrenceEnd::Never,
509                    };
510                },
511            );
512            let ends = SegmentedControl::new(kind, labels.recurrence_ends.clone())
513                .label(labels.recurrence_ends_name.clone());
514            let extra: Option<crate::AnyView> = match rule.end {
515                RecurrenceEnd::Until(day) => {
516                    let last = part(
517                        &self.rule,
518                        |r| if let RecurrenceEnd::Until(day) = r.end { Some(day) } else { None },
519                        |r, day: Option<Date>| {
520                            if let Some(day) = day {
521                                r.end = RecurrenceEnd::Until(day);
522                            }
523                        },
524                    );
525                    Some(Box::new(
526                        DatePicker::new(last, day).label(labels.recurrence_ends_name.clone()).width(190.0),
527                    ))
528                }
529                RecurrenceEnd::After(_) => {
530                    let count = part(
531                        &self.rule,
532                        |r| {
533                            if let RecurrenceEnd::After(n) = r.end {
534                                i32::try_from(n).unwrap_or(1)
535                            } else {
536                                1
537                            }
538                        },
539                        |r, n: i32| r.end = RecurrenceEnd::After(u32::try_from(n.max(1)).unwrap_or(1)),
540                    );
541                    Some(Box::new(
542                        HStack::new((
543                            Stepper::new(count).range(1, 999).label(labels.recurrence_times.clone()),
544                            Text::new(labels.recurrence_times.clone()).style(TextStyle::Callout),
545                        ))
546                        .spacing(8.0)
547                        .align(Align::Center),
548                    ))
549                }
550                _ => None,
551            };
552            rows.push(Box::new(FormField::new(
553                labels.recurrence_ends_name.clone(),
554                VStack::new((ends, extra)).spacing(8.0),
555            )));
556        }
557        let summary = labels.recurrence_summary(&rule, self.start);
558        rows.push(Box::new(Text::new(summary).style(TextStyle::Callout).foreground(Semantic::InkMuted)));
559        cx.child(&VStack::new(rows).spacing(10.0).align(Align::Stretch))
560    }
561}