1use tessera_ui::{
8 CallbackWith, Color, ComputedData, Constraint, Dp, FocusRequester, LayoutResult,
9 MeasurementError, Modifier, Px, PxPosition, PxSize, State,
10 layout::{MeasureScope, layout},
11 modifier::ModifierCapabilityExt as _,
12 remember, tessera, use_context,
13};
14
15use crate::{
16 modifier::{
17 InteractionState, InteractionVisualState, ModifierExt as _, SelectableArgs, SemanticsArgs,
18 },
19 pipelines::shape::command::ShapeCommand,
20 res::I18N,
21 shape_def::{ResolvedShape, Shape},
22 surface::record_focus_indicator,
23 text::text,
24 theme::{MaterialAlpha, MaterialTheme},
25};
26
27use super::{
28 CalendarDate, CalendarRangeBand, DATE_CELL_SIZE, DATE_VISUAL_SIZE,
29 focus::CalendarFocusIndicator, format_month_name,
30};
31
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
34pub enum CalendarRangePosition {
35 #[default]
37 Isolated,
38 Start,
40 Middle,
42 End,
44}
45
46impl CalendarRangePosition {
47 fn band(self) -> CalendarRangeBand {
48 CalendarRangeBand {
49 start: matches!(self, Self::Isolated | Self::Start),
50 end: matches!(self, Self::Isolated | Self::End),
51 }
52 }
53}
54
55#[tessera]
102pub fn date_picker_day(
103 date: CalendarDate,
104 on_select: Option<CallbackWith<CalendarDate>>,
105 selected: Option<bool>,
106 is_today: Option<bool>,
107 in_range: Option<bool>,
108 outside_month: Option<bool>,
109 range_position: Option<CalendarRangePosition>,
110 enabled: Option<bool>,
111 presentation_state: Option<InteractionVisualState>,
112 modifier: Option<Modifier>,
113 focus_requester: Option<FocusRequester>,
114 label_formatter: Option<CallbackWith<CalendarDate, String>>,
115 #[prop(into)] accessibility_label: Option<String>,
116 #[prop(into)] accessibility_description: Option<String>,
117) {
118 let on_select = on_select.unwrap_or_else(|| CallbackWith::new(|_| {}));
119 let theme = use_context::<MaterialTheme>().unwrap().get();
120 let scheme = theme.color_scheme;
121 let selected = selected.unwrap_or(false);
122 let enabled = enabled.unwrap_or(true);
123 let is_today = is_today.unwrap_or(false);
124 let interaction = remember(InteractionState::new);
125 let internal_focus = remember(FocusRequester::new).get();
126 let focus = focus_requester.unwrap_or(internal_focus);
127 let color = if selected {
128 scheme.on_primary
129 } else if in_range.unwrap_or(false) {
130 scheme.on_secondary_container
131 } else if is_today {
132 scheme.primary
133 } else if outside_month.unwrap_or(false) {
134 scheme.on_surface_variant
135 } else {
136 scheme.on_surface
137 };
138 let color = if enabled {
139 color
140 } else {
141 color.with_alpha(MaterialAlpha::DISABLED_CONTENT)
142 };
143 let visual = CalendarCellVisual {
144 shape: Shape::Ellipse,
145 fill: if selected {
146 scheme.primary
147 } else {
148 Color::TRANSPARENT
149 },
150 outline: (!selected && is_today).then_some(scheme.primary),
151 content_color: color,
152 focus_color: scheme.secondary,
153 interaction,
154 presentation_state,
155 enabled,
156 disabled_focus: !enabled && focus.is_focused(),
157 };
158 let range_band = range_position.map(CalendarRangePosition::band);
159 let label =
160 label_formatter.map_or_else(|| date.day().to_string(), |formatter| formatter.call(date));
161 layout()
162 .modifier(
163 modifier
164 .unwrap_or_default()
165 .size(DATE_CELL_SIZE, DATE_CELL_SIZE)
166 .selectable_with(SelectableArgs {
167 selected,
168 on_click: tessera_ui::Callback::new(move || on_select.call(date)),
169 enabled,
170 interaction_state: Some(interaction),
171 focus_requester: Some(focus),
172 label: Some(accessibility_label.unwrap_or_else(|| {
173 I18N.format(
174 "md3-full-date",
175 [
176 ("month", format_month_name(date.month()).into()),
177 ("day", date.day().into()),
178 ("year", date.year().to_string().into()),
179 ],
180 )
181 })),
182 description: accessibility_description,
183 ..Default::default()
184 }),
185 )
186 .policy(CalendarDayLayout {
187 fractional_center: false,
188 range_band,
189 })
190 .child(move || {
191 let label = label.clone();
192 if range_band.is_some() {
193 layout().modifier(Modifier::new().background(scheme.secondary_container));
194 }
195 layout()
196 .modifier(
197 Modifier::new()
198 .size(DATE_VISUAL_SIZE, DATE_VISUAL_SIZE)
199 .push_draw(CalendarFocusIndicator {
200 visible: focus.is_focus_visible(),
201 shape: Shape::Ellipse,
202 color: scheme.secondary,
203 }),
204 )
205 .policy(CalendarDayLayout {
206 fractional_center: true,
207 range_band: None,
208 })
209 .draw(visual)
210 .child(move || {
211 text(label.clone())
212 .modifier(Modifier::new().semantics(SemanticsArgs {
213 hidden: true,
214 ..Default::default()
215 }))
216 .style(theme.typography.body_large)
217 .color(color);
218 });
219 });
220}
221
222#[tessera]
263pub fn date_picker_year(
264 year: i32,
265 on_select: Option<CallbackWith<i32>>,
266 selected: Option<bool>,
267 is_today: Option<bool>,
268 enabled: Option<bool>,
269 presentation_state: Option<InteractionVisualState>,
270 modifier: Option<Modifier>,
271 focus_requester: Option<FocusRequester>,
272 label_formatter: Option<CallbackWith<i32, String>>,
273 #[prop(into)] accessibility_label: Option<String>,
274 #[prop(into)] accessibility_description: Option<String>,
275) {
276 let on_select = on_select.unwrap_or_else(|| CallbackWith::new(|_| {}));
277 let theme = use_context::<MaterialTheme>().unwrap().get();
278 let scheme = theme.color_scheme;
279 let selected = selected.unwrap_or(false);
280 let enabled = enabled.unwrap_or(true);
281 let today = is_today.unwrap_or(false);
282 let interaction = remember(InteractionState::new);
283 let internal_focus = remember(FocusRequester::new).get();
284 let focus = focus_requester.unwrap_or(internal_focus);
285 let color = if !enabled {
286 scheme
287 .on_surface
288 .with_alpha(MaterialAlpha::DISABLED_CONTENT)
289 } else if selected {
290 scheme.on_primary
291 } else if today {
292 scheme.primary
293 } else {
294 scheme.on_surface_variant
295 };
296 let visual = CalendarCellVisual {
297 shape: Shape::CAPSULE,
298 fill: if selected {
299 scheme.primary
300 } else {
301 Color::TRANSPARENT
302 },
303 outline: (!selected && today).then_some(scheme.primary),
304 content_color: color,
305 focus_color: scheme.secondary,
306 interaction,
307 presentation_state,
308 enabled,
309 disabled_focus: !enabled && focus.is_focused(),
310 };
311 let label = label_formatter.map_or_else(|| year.to_string(), |formatter| formatter.call(year));
312 layout()
313 .modifier(
314 modifier
315 .unwrap_or_default()
316 .size(Dp(112.0), Dp(52.0))
317 .selectable_with(SelectableArgs {
318 selected,
319 on_click: tessera_ui::Callback::new(move || on_select.call(year)),
320 enabled,
321 interaction_state: Some(interaction),
322 focus_requester: Some(focus),
323 label: Some(accessibility_label.unwrap_or_else(|| year.to_string())),
324 description: accessibility_description,
325 ..Default::default()
326 }),
327 )
328 .child(move || {
329 let label = label.clone();
330 layout()
331 .modifier(
332 Modifier::new()
333 .size(Dp(72.0), Dp(36.0))
334 .offset(Dp(20.0), Dp(8.0))
335 .push_draw(CalendarFocusIndicator {
336 visible: focus.is_focus_visible(),
337 shape: Shape::CAPSULE,
338 color: scheme.secondary,
339 }),
340 )
341 .draw(visual)
342 .child(move || {
343 let label = label.clone();
344 crate::boxed::boxed()
345 .alignment(crate::alignment::Alignment::Center)
346 .modifier(Modifier::new().fill_max_size())
347 .children(move || {
348 text(label.clone())
349 .modifier(Modifier::new().semantics(SemanticsArgs {
350 hidden: true,
351 ..Default::default()
352 }))
353 .style(theme.typography.body_large)
354 .color(color);
355 });
356 });
357 });
358}
359
360#[derive(Clone, PartialEq)]
361struct CalendarDayLayout {
362 fractional_center: bool,
363 range_band: Option<CalendarRangeBand>,
364}
365
366impl tessera_ui::LayoutModifierNode for CalendarDayLayout {
367 fn measure(&self, scope: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
368 let width = scope
369 .parent_constraint()
370 .width()
371 .clamp(DATE_CELL_SIZE.to_px());
372 let height = scope
373 .parent_constraint()
374 .height()
375 .clamp(DATE_CELL_SIZE.to_px());
376 let mut result = LayoutResult::new(ComputedData { width, height });
377 for (index, child) in scope.children().into_iter().enumerate() {
378 if index == 0
379 && let Some(band) = self.range_band
380 {
381 let visual = DATE_VISUAL_SIZE.to_px().min(height);
382 let x = if band.start { width / 2 } else { Px::ZERO };
383 let end = if band.end { width / 2 } else { width };
384 child.measure(scope, &Constraint::new((end - x).max(Px::ZERO), visual))?;
385 result.place_child(child, PxPosition::new(x, (height - visual) / 2));
386 continue;
387 }
388 let size = child.measure(scope, &scope.parent_constraint().without_min())?;
389 let position = if self.fractional_center {
390 PxPosition::from_f32_xy(
391 (width - size.width).0.max(0) as f32 / 2.0,
392 (height - size.height).0.max(0) as f32 / 2.0,
393 )
394 } else {
395 PxPosition::new((width - size.width) / 2, (height - size.height) / 2)
396 };
397 result.place_child(child, position);
398 }
399 Ok(result)
400 }
401}
402
403#[derive(Clone, Copy, PartialEq)]
404struct CalendarCellVisual {
405 shape: Shape,
406 fill: Color,
407 outline: Option<Color>,
408 content_color: Color,
409 focus_color: Color,
410 interaction: State<InteractionState>,
411 presentation_state: Option<InteractionVisualState>,
412 enabled: bool,
413 disabled_focus: bool,
414}
415
416impl tessera_ui::DrawModifierNode for CalendarCellVisual {
417 fn draw(
418 &self,
419 ctx: &mut tessera_ui::DrawModifierContext<'_, '_>,
420 content: &mut dyn tessera_ui::DrawModifierContent,
421 ) {
422 let live = self.interaction.get();
423 let visual = live.with_presentation(self.presentation_state, self.enabled);
424 let alpha = visual.state_layer_alpha().max(if self.disabled_focus {
425 MaterialAlpha::FOCUSED
426 } else {
427 0.0
428 });
429 let fill = self
430 .fill
431 .blend_over(self.content_color.with_alpha(1.0), alpha);
432 if self.outline.is_some() || fill.a > 0.0 {
433 let bounds = ctx
434 .render_input
435 .metadata_mut()
436 .computed_data()
437 .expect("calendar cell bounds");
438 let command = match self
439 .shape
440 .resolve_for_size(PxSize::new(bounds.width, bounds.height))
441 {
442 ResolvedShape::Ellipse => match self.outline {
443 Some(color) if alpha > 0.0 => ShapeCommand::FilledOutlinedEllipse {
444 color: fill,
445 border_color: color,
446 border_width: Dp(1.0).to_pixels_f32(),
447 },
448 Some(color) => ShapeCommand::OutlinedEllipse {
449 color,
450 border_width: Dp(1.0).to_pixels_f32(),
451 },
452 None => ShapeCommand::Ellipse { color: fill },
453 },
454 ResolvedShape::Rounded {
455 corner_radii,
456 corner_g2,
457 } => match self.outline {
458 Some(color) => ShapeCommand::FilledOutlinedRect {
459 color: fill,
460 border_color: color,
461 corner_radii,
462 corner_g2,
463 border_width: Dp(1.0).to_pixels_f32(),
464 },
465 None => ShapeCommand::Rect {
466 color: fill,
467 corner_radii,
468 corner_g2,
469 },
470 },
471 };
472 ctx.render_input
473 .metadata_mut()
474 .fragment_mut()
475 .push_draw_command(command);
476 }
477 if visual.is_focused() && !live.is_focused() {
478 record_focus_indicator(ctx.render_input, self.shape, self.focus_color);
479 }
480 content.draw(ctx.render_input);
481 }
482}
483
484#[cfg(all(test, not(target_family = "wasm")))]
485#[path = "cell_tests.rs"]
486mod tests;