1use std::cell::Cell;
27use std::rc::Rc;
28
29use repose_core::prelude::{Color, FlexDirection, Modifier, View};
30use repose_core::text::TextAlign;
31use repose_core::{AlignItems, Dp, JustifyContent, Sp};
32use repose_ui::{IntoChildren, Text, TextStyle as _, ViewExt as _, ZStack};
33
34use crate::layout::{Scale, anchor_rect, disc, ring};
35use crate::nav::{GroupId, NavDriver, NavMap, Wrap};
36use crate::palette::{
37 self, FONT, ICON_BUTTON, ICON_GLYPH, MATERIAL_SYMBOLS, Tokens, outline_for,
38};
39use crate::queue::{Sink, UiAction};
40
41#[derive(Clone)]
49pub struct Kit<A: UiAction> {
50 scale: Rc<Cell<Scale>>,
51 tokens: Tokens,
52 sink: Sink<A>,
53 nav: NavMap,
54 wrap: Wrap,
55}
56
57impl<A: UiAction> Kit<A> {
58 pub fn new(scale: Scale, tokens: Tokens, sink: Sink<A>) -> Self {
61 Self {
62 scale: Rc::new(Cell::new(scale)),
63 tokens,
64 sink,
65 nav: NavMap::new(),
66 wrap: Wrap::default(),
67 }
68 }
69
70 pub fn with_map(scale: Scale, tokens: Tokens, sink: Sink<A>, nav: NavMap) -> Self {
73 Self {
74 scale: Rc::new(Cell::new(scale)),
75 tokens,
76 sink,
77 nav,
78 wrap: Wrap::default(),
79 }
80 }
81
82 pub fn set_scale(&self, scale: Scale) {
84 self.scale.set(scale);
85 }
86
87 pub fn scale(&self) -> Scale {
88 self.scale.get()
89 }
90
91 pub fn tokens(&self) -> Tokens {
92 self.tokens
93 }
94
95 pub fn with_tokens(mut self, tokens: Tokens) -> Self {
97 self.tokens = tokens;
98 self
99 }
100
101 pub fn with_wrap(mut self, wrap: Wrap) -> Self {
103 self.wrap = wrap;
104 self
105 }
106
107 pub fn sink(&self) -> &Sink<A> {
108 &self.sink
109 }
110
111 pub fn nav_map(&self) -> &NavMap {
112 &self.nav
113 }
114
115 pub fn driver(&self) -> NavDriver {
117 NavDriver::new(self.nav.clone()).with_wrap(self.wrap)
118 }
119
120 pub fn begin_frame(&self) {
123 self.nav.begin_frame();
124 }
125
126 pub fn button(&self, text: impl Into<String>, action: A) -> Button<A> {
128 Button {
129 kit: self.clone(),
130 text: text.into(),
131 glyph: None,
132 fill: self.tokens.cream,
133 text_fill: None,
134 enabled: true,
135 group: None,
136 key: None,
137 action,
138 }
139 }
140
141 pub fn icon_button(&self, glyph: impl Into<String>, action: A) -> Button<A> {
143 Button {
144 kit: self.clone(),
145 text: glyph.into(),
146 glyph: Some(()),
147 fill: self.tokens.cream,
148 text_fill: Some(self.tokens.ink),
149 enabled: true,
150 group: None,
151 key: None,
152 action,
153 }
154 }
155
156 pub fn chip(&self, text: impl Into<String>, face: Color) -> View {
158 let scale = self.scale.get().fit();
159 let tokens = self.tokens;
160 repose_ui::Box(palette::chip(&tokens, face, scale))
161 .child(self.label_plain(Modifier::new(), &text.into(), 20.0, tokens.ink))
162 }
163
164 pub fn panel(
170 &self,
171 viewport: [f32; 2],
172 anchor: [f32; 4],
173 offset: [f32; 4],
174 face: Color,
175 ) -> View {
176 let scale = self.scale.get().fit();
177 let tokens = self.tokens;
178 repose_ui::Box(
179 anchor_rect(viewport, scale, anchor, offset)
180 .background(face)
181 .border(Dp(3.0 * scale), tokens.ink, Dp(18.0 * scale))
182 .shadow_with_color(Dp(0.0), Dp(6.0 * scale), tokens.button_shadow)
183 .flex_dir(FlexDirection::Column)
184 .row_gap(Dp(14.0 * scale))
185 .align_items(AlignItems::CENTER)
186 .justify_content(JustifyContent::CENTER),
187 )
188 }
189
190 pub fn scrim(&self) -> View {
194 let tokens = self.tokens;
195 ZStack(
196 Modifier::new()
197 .fill_max_size()
198 .background(tokens.scrim)
199 .input_blocker()
200 .focusable(false),
201 )
202 .child(repose_ui::Spacer())
203 }
204
205 pub fn label(&self, modifier: Modifier, text: &str, size: f32, fill: Color) -> View {
207 let scale = self.scale.get().fit();
208 let tokens = self.tokens;
209 Text(text.to_string())
210 .size(Sp(size * scale))
211 .text_align(TextAlign::Center)
212 .font_family(FONT)
213 .color(fill)
214 .outlined(0.42 * outline_for(size) / size, tokens.ink)
215 .single_line()
216 .overflow_visible()
217 .modifier(modifier)
218 }
219
220 pub fn label_plain(&self, modifier: Modifier, text: &str, size: f32, fill: Color) -> View {
222 let scale = self.scale.get().fit();
223 Text(text.to_string())
224 .size(Sp(size * scale))
225 .text_align(TextAlign::Center)
226 .font_family(FONT)
227 .color(fill)
228 .single_line()
229 .overflow_visible()
230 .modifier(modifier)
231 }
232
233 pub fn display(&self, modifier: Modifier, text: &str, size: f32, fill: Color) -> View {
235 let scale = self.scale.get().fit();
236 let tracking = (size * -0.012).clamp(-1.2, 0.0);
237 Text(text.to_string())
238 .size(Sp(size * scale))
239 .line_height(Sp(size * scale * 1.02))
240 .letter_spacing(Sp(tracking * scale))
241 .text_align(TextAlign::Center)
242 .font_family(FONT)
243 .color(fill)
244 .fill_and_stroke(0.028)
245 .single_line()
246 .overflow_visible()
247 .modifier(modifier)
248 }
249
250 pub fn icon(&self, glyph: &str, size: f32, fill: Color) -> View {
252 let scale = self.scale.get().fit();
253 Text(glyph.to_string())
254 .size(Sp(size * scale))
255 .text_align(TextAlign::Center)
256 .font_family(MATERIAL_SYMBOLS)
257 .color(fill)
258 .single_line()
259 .overflow_visible()
260 .modifier(Modifier::new())
261 }
262
263 pub fn disc(&self, viewport: [f32; 2], at: [f32; 2], radius: f32, color: Color) -> View {
265 disc(viewport, self.scale.get().fit(), at, radius, color)
266 }
267
268 pub fn ring(
270 &self,
271 viewport: [f32; 2],
272 at: [f32; 2],
273 radius: f32,
274 width: f32,
275 color: Color,
276 ) -> View {
277 ring(viewport, self.scale.get().fit(), at, radius, width, color)
278 }
279
280 pub fn place(&self, viewport: [f32; 2], anchor: [f32; 4], offset: [f32; 4]) -> Modifier {
283 anchor_rect(viewport, self.scale.get().fit(), anchor, offset)
284 }
285
286 pub fn touch_pad(&self) -> crate::touch::TouchPad<A> {
291 crate::touch::TouchPad::new(self.clone())
292 }
293}
294
295pub struct Button<A: UiAction> {
297 kit: Kit<A>,
298 text: String,
299 glyph: Option<()>,
300 fill: Color,
301 text_fill: Option<Color>,
302 enabled: bool,
303 group: Option<GroupId>,
304 key: Option<String>,
305 action: A,
306}
307
308impl<A: UiAction> Button<A> {
309 pub fn fill(mut self, face: Color) -> Self {
311 self.fill = face;
312 self
313 }
314
315 pub fn text_fill(mut self, fill: Color) -> Self {
317 self.text_fill = Some(fill);
318 self
319 }
320
321 pub fn disabled(mut self, disabled: bool) -> Self {
324 self.enabled = !disabled;
325 self
326 }
327
328 pub fn group(mut self, group: GroupId) -> Self {
331 self.group = Some(group);
332 self
333 }
334
335 pub fn keyed(mut self, key: impl Into<String>) -> Self {
342 self.key = Some(key.into());
343 self
344 }
345
346 pub fn view(self) -> View {
348 let kit = self.kit;
349 let scale = kit.scale.get().fit();
350 let tokens = kit.tokens;
351 let square = self.glyph.is_some();
352
353 let mut modifier = palette::chunky(
354 &tokens,
355 self.fill,
356 if square { 10.0 } else { 22.0 },
357 if square { 10.0 } else { 12.0 },
358 if square { 12.0 } else { 16.0 },
359 scale,
360 );
361 if square {
362 modifier = modifier.size(Dp(ICON_BUTTON * scale), Dp(ICON_BUTTON * scale));
363 }
364 if !self.enabled {
365 modifier = modifier.disabled();
366 } else {
367 let sink = kit.sink.clone();
368 let action = self.action.clone();
369 modifier = modifier.on_click(move || sink.push(action.clone()));
370
371 let identity = self.key.clone().unwrap_or_else(|| {
375 if square {
376 format!("icon.{}", self.text)
377 } else {
378 self.text.clone()
379 }
380 });
381 let requester = repose_core::remember_with_key(
382 format!("repame_ui.control.{identity}"),
383 repose_core::prelude::FocusRequester::new,
384 );
385
386 let nav = kit.nav.clone();
393 let group = self.group;
394 let place = (*requester).clone();
395 let map = nav.clone();
396 modifier = modifier
397 .focus_requester((*requester).clone())
398 .on_globally_positioned(move |rect| {
399 map.place(&place, rect, group);
400 });
401 nav.register(&requester, group);
402 }
403
404 let caption = match (self.glyph, self.text_fill) {
405 (Some(()), fill) => kit.icon(&self.text, ICON_GLYPH, fill.unwrap_or(tokens.ink)),
406 (None, Some(fill)) => kit.label_plain(Modifier::new(), &self.text, 24.0, fill),
407 (None, None) => kit.label(Modifier::new(), &self.text, 24.0, tokens.ink),
408 };
409 repose_ui::Box(modifier).child(caption)
410 }
411}
412
413impl<A: UiAction> IntoChildren for Button<A> {
414 fn into_children(self) -> Vec<View> {
415 vec![self.view()]
416 }
417}
418
419pub fn overlay_root(children: impl IntoChildren) -> View {
426 ZStack(Modifier::new().fill_max_size()).child(children)
427}
428
429pub const ICON_FAMILY: &str = MATERIAL_SYMBOLS;
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use crate::queue::ActionQueue;
437 use crate::nav::NavDir;
438
439 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
440 enum Act {
441 Play,
442 Mute,
443 }
444
445 fn kit() -> (Kit<Act>, ActionQueue<Act>) {
446 let (queue, sink) = ActionQueue::pair();
447 let kit = Kit::new(Scale::unit(), Tokens::default(), sink);
448 (kit, queue)
449 }
450
451 #[test]
452 fn the_scale_is_shared_across_clones() {
453 let (kit, _queue) = kit();
454 let clone = kit.clone();
455 kit.set_scale(Scale::new([1920.0, 1080.0], crate::layout::DESIGN).expect("scales"));
456 assert!((clone.scale().fit() - 2.0).abs() < 1e-6);
457 assert_eq!(clone.tokens(), Tokens::default());
458 }
459
460 #[test]
461 fn a_button_pushes_its_action_and_registers_in_the_map() {
462 let (kit, queue) = kit();
463 kit.begin_frame();
464 let view = kit.button("Play", Act::Play).view();
465 let on_click = view.modifier.on_click.clone().expect("button is clickable");
469 on_click();
470 assert_eq!(queue.drain(), vec![Act::Play]);
471 assert!(view.modifier.focusable.unwrap_or(false), "button takes focus");
472 assert!(view.modifier.focus_requester.is_some(), "button can request focus");
473 }
474
475 #[test]
476 fn a_disabled_button_neither_clicks_nor_joins_traversal() {
477 let (kit, queue) = kit();
478 kit.begin_frame();
479 let view = kit.button("Play", Act::Play).disabled(true).view();
480 assert!(view.modifier.on_click.is_none());
481 assert!(view.modifier.focus_requester.is_none(), "no focus target either");
482 assert!(view.modifier.disabled, "repose skips disabled controls");
483 assert!(queue.is_empty());
484 }
485
486 #[test]
487 fn an_icon_button_is_square_and_captionless() {
488 let (kit, _queue) = kit();
489 kit.begin_frame();
490 let view = kit.button("x", Act::Mute).fill(Tokens::default().coral).view();
491 assert!(view.modifier.on_click.is_some());
492 assert_eq!(view.children.len(), 1);
493 let icon = kit.icon_button("pause", Act::Mute).view();
494 assert!(icon.modifier.on_click.is_some());
495 }
496
497 #[test]
498 fn a_button_joins_its_focus_group() {
499 let (kit, _queue) = kit();
500 kit.begin_frame();
501 let pause = GroupId::named("pause");
502 let resume = kit.button("Resume", Act::Play).group(pause).view();
503 let quit = kit.button("Quit", Act::Mute).group(pause).view();
504 assert!(kit.nav_map().is_empty(), "no id and no rectangle yet");
507 let resume_place = resume
508 .modifier
509 .on_globally_positioned
510 .clone()
511 .expect("buttons report their rectangle");
512 let quit_place = quit
513 .modifier
514 .on_globally_positioned
515 .clone()
516 .expect("buttons report their rectangle");
517 resume_place(repose_core::Rect { x: 200.0, y: 300.0, w: 200.0, h: 64.0 });
518 quit_place(repose_core::Rect { x: 200.0, y: 380.0, w: 200.0, h: 64.0 });
519
520 let resume_requester = resume
523 .modifier
524 .focus_requester
525 .clone()
526 .expect("buttons request focus");
527 let quit_requester = quit
528 .modifier
529 .focus_requester
530 .clone()
531 .expect("buttons request focus");
532 *resume_requester.target.borrow_mut() = Some(41);
533 *quit_requester.target.borrow_mut() = Some(42);
534 kit.begin_frame();
535 assert!(kit.nav_map().register(&resume_requester, Some(pause)));
536 assert!(kit.nav_map().register(&quit_requester, Some(pause)));
537
538 assert_eq!(crate::nav::chain_of(kit.nav_map()), [41, 42]);
539 assert!(crate::nav::same_group(kit.nav_map(), 41, 42));
540 assert_eq!(kit.driver().wrap(), Wrap::Cycle);
541 }
542
543 #[test]
544 fn a_button_becomes_navigable_the_frame_after_it_appears() {
545 let (kit, _queue) = kit();
546 kit.begin_frame();
547 let view = kit.button("Play", Act::Play).view();
548 let place = view
549 .modifier
550 .on_globally_positioned
551 .clone()
552 .expect("buttons report their rectangle");
553 let requester = view
554 .modifier
555 .focus_requester
556 .clone()
557 .expect("buttons request focus");
558 place(repose_core::Rect { x: 40.0, y: 300.0, w: 200.0, h: 64.0 });
559 assert!(kit.nav_map().is_empty(), "the rectangle waits for the view id");
560 *requester.target.borrow_mut() = Some(31);
561
562 kit.begin_frame();
563 assert!(kit.nav_map().register(&requester, None));
564 let entry = kit.nav_map().active().into_iter().next().expect("one control");
565 assert_eq!(entry.id(), 31);
566 assert_eq!(entry.rect().w, 200.0);
567 assert_eq!(
568 kit.driver().enter(&repose_core::prelude::Scheduler::default()),
569 crate::nav::NavStep::Entered(31),
570 "the control is the entry point of an empty focus chain"
571 );
572 }
573
574 #[test]
575 fn a_kits_controls_can_be_driven_by_direction() {
576 let (kit, _queue) = kit();
577 let nav = kit.nav_map();
578 nav.begin_frame();
579 for (id, x) in [(11u64, 100.0f32), (12, 400.0)] {
581 let requester = repose_core::prelude::FocusRequester::new();
582 *requester.target.borrow_mut() = Some(id);
583 nav.mark(id, Some(GroupId::named("pause")), requester.clone());
584 nav.place(&requester, repose_core::Rect { x, y: 0.0, w: 200.0, h: 80.0 }, Some(GroupId::named("pause")));
585 }
586 assert_eq!(nav.resolve(Some(11), NavDir::Right, Wrap::Stay), crate::nav::NavStep::Moved(12));
587 assert_eq!(nav.len(), 2);
588 }
589
590 #[test]
591 fn extra_voices_read_the_scale_and_tokens() {
592 let (kit, queue) = kit();
593 kit.set_scale(Scale::new([480.0, 270.0], crate::layout::DESIGN).expect("scales"));
594 let chip = kit.chip("12", Tokens::default().gold);
595 assert_eq!(chip.children.len(), 1);
596 let scrim = kit.scrim();
597 assert!(scrim.modifier.input_blocker, "the scrim blocks the game underneath");
598 assert!(!scrim.modifier.focusable.unwrap_or(true), "the scrim takes no focus");
599 let panel = kit.panel([480.0, 270.0], [0.5, 0.5, 0.5, 0.5], [-100.0, -50.0, 100.0, 50.0], Tokens::default().cream);
600 assert!(panel.modifier.offset_left.is_some(), "panels are placed absolutely");
601 let label = kit.label(Modifier::new(), "Play", 24.0, Tokens::default().ink);
602 assert_eq!(label.children.len() + 1, 1, "labels are leaves");
603 assert!(queue.is_empty());
604 }
605
606 #[test]
607 fn the_overlay_root_stays_out_of_the_focus_chain() {
608 let root = overlay_root(repose_ui::Spacer());
609 assert!(root.modifier.focusable.is_none(), "the root must not take focus");
610 assert!(root.modifier.focus_requester.is_none());
611 }
612
613 #[test]
614 fn touch_pads_share_the_kits_sink() {
615 let (kit, queue) = kit();
616 let pad = kit.touch_pad();
617 pad.begin_frame(crate::flow::FlowState::new(
618 crate::flow::Flow::Playing,
619 crate::flow::Transition::Idle,
620 crate::flow::Suspension::Live,
621 ));
622 assert!(pad.enabled());
623 assert!(queue.is_empty());
624 }
625}