1use 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
18const DAY: f32 = 26.0;
20const DAY_GAP: f32 = 2.0;
22const MONTH_GAP: f32 = 20.0;
24const LINES: [usize; 5] = [6, 4, 3, 2, 1];
26
27#[derive(Clone, Debug, Default, PartialEq, Eq)]
29#[non_exhaustive]
30#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
31pub struct DayMark {
32 pub tone: Tone,
34 pub label: Option<String>,
36}
37
38#[cfg_attr(feature = "live", ravenrs_live::expose)]
39impl DayMark {
40 pub fn new() -> Self {
42 Self::default()
43 }
44
45 pub fn tone(mut self, tone: Tone) -> Self {
47 self.tone = tone;
48 self
49 }
50
51 pub fn label(mut self, label: impl Into<String>) -> Self {
53 self.label = Some(label.into());
54 self
55 }
56}
57
58#[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 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 pub fn selected(mut self, selected: Binding<Option<Date>>) -> Self {
101 self.selected = Some(selected);
102 self
103 }
104
105 pub fn today(mut self, today: Date) -> Self {
107 self.today = Some(today);
108 self
109 }
110
111 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 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 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 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
227struct 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 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}