Skip to main content

repame_ui/
kit.rs

1//! The kit: palette tokens, one scale, one action sink and one nav map, handed
2//! to every control a panel is built from.
3//!
4//! A template game's `ui/` module builds the same six things by hand — a chunky
5//! button, a square glyph button, an information chip, a card, a label, a
6//! scrim — and each of them repeats the palette blending and the layout anchor
7//! arithmetic. [`Kit`] is that set once: the controls read [`Tokens`] and the
8//! frame's [`Scale`], push into the shared [`Sink`], and register themselves in
9//! the [`NavMap`] so arrows, Tab, the D-pad and an analog stick can reach them.
10//!
11//! The scale is shared behind a cell, so a game builds the kit once and writes
12//! the new viewport scale each frame (`kit.set_scale(scale)`) without rebuilding
13//! the kit or the closures it handed out.
14//!
15//! ```ignore
16//! let kit = Kit::new(Scale::unit(), Tokens::default(), sink);
17//! // once per frame, before composing:
18//! kit.set_scale(Scale::new(viewport, layout::DESIGN)?);
19//! kit.begin_frame();
20//! // then, per panel:
21//! kit.button("Play", UiAction::Play).view()
22//! ```
23//!
24//! [`Tokens`]: crate::palette::Tokens
25
26use 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/// The scale, tokens, sink and nav map a panel's controls are built from.
42///
43/// Build one per game and clone it into whatever composes: the scale cell, the
44/// sink and the nav map are shared behind `Rc`/`Arc`, so every clone sees the
45/// same frame. A kit rebuilt every frame would start from an empty nav map,
46/// whose one-frame rectangle binding would never complete and whose controls
47/// could never be reached.
48#[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    /// A kit drawing with `tokens`, pushing into `sink` and registering in a
59    /// fresh nav map.
60    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    /// A kit wrapping an existing nav map, so a game that keeps its own map
71    /// still shares it with the controls.
72    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    /// This frame's viewport scale. All clones of the kit see it.
83    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    /// Replace the palette. Returns the re-skinned kit.
96    pub fn with_tokens(mut self, tokens: Tokens) -> Self {
97        self.tokens = tokens;
98        self
99    }
100
101    /// Edge behaviour for directional traversal.
102    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    /// Traversal over this kit's controls.
116    pub fn driver(&self) -> NavDriver {
117        NavDriver::new(self.nav.clone()).with_wrap(self.wrap)
118    }
119
120    /// Retire the controls that are no longer composed: call once per frame,
121    /// before composing panels.
122    pub fn begin_frame(&self) {
123        self.nav.begin_frame();
124    }
125
126    /// A chunky text button that pushes `action` and takes focus.
127    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    /// A square glyph button (play, pause, home) that pushes `action`.
142    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    /// An information chip: a HUD counter, score or timer face.
157    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    /// A card: the panel body, placed by viewport anchor and design-unit
165    /// offset, stacking its children in a column with the default gap.
166    ///
167    /// Add children to the returned view; they are centred as a column, which is
168    /// what every template menu is.
169    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    /// The dim behind a modal panel: full-bleed, and it blocks pointer input so
191    /// a click cannot reach the game underneath. It stays out of the focus
192    /// chain.
193    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    /// Outlined text: labels that must read over game art.
206    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    /// Plain text: numbers and button captions, no outline.
221    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    /// Display text: tighter tracking and a heavier faux-bold, for titles.
234    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    /// A Material Symbols glyph.
251    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    /// A filled circle in design space.
264    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    /// An unfilled circle in design space.
269    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    /// The `rect(vp, s, anchor, offset)` placement helper, with this kit's
281    /// scale.
282    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    /// The virtual stick and tap buttons, pushing into the same sink.
287    ///
288    /// Build the pad once and keep it in the app, like the game's own input
289    /// state: a pad built per frame forgets the finger that is down.
290    pub fn touch_pad(&self) -> crate::touch::TouchPad<A> {
291        crate::touch::TouchPad::new(self.clone())
292    }
293}
294
295/// A button being configured. Add it to a parent view, which builds it.
296pub 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    /// Face colour. Defaults to the palette's cream.
310    pub fn fill(mut self, face: Color) -> Self {
311        self.fill = face;
312        self
313    }
314
315    /// Caption colour. Defaults to the palette's ink.
316    pub fn text_fill(mut self, fill: Color) -> Self {
317        self.text_fill = Some(fill);
318        self
319    }
320
321    /// A disabled button keeps its place but takes neither pointer nor
322    /// keyboard activation.
323    pub fn disabled(mut self, disabled: bool) -> Self {
324        self.enabled = !disabled;
325        self
326    }
327
328    /// Put this control in a focus group, so traversal cannot leave the panel
329    /// it belongs to.
330    pub fn group(mut self, group: GroupId) -> Self {
331        self.group = Some(group);
332        self
333    }
334
335    /// Identify this control to focus memory.
336    ///
337    /// The default identity is the caption (or the glyph), which is unique
338    /// inside a panel in every template game. Two controls that share a caption
339    /// inside one composition scope — two "OK" buttons in one dialog — must be
340    /// given distinct keys, or they share one remembered focus target.
341    pub fn keyed(mut self, key: impl Into<String>) -> Self {
342        self.key = Some(key.into());
343        self
344    }
345
346    /// Build the view.
347    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            // The focus requester has to outlive the frame: a fresh one every
372            // frame would never learn its view id, so it is remembered per
373            // control key.
374            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            // Register with the nav map: the layout pass reports the rectangle,
387            // and from the control's second frame on it is registered while
388            // composing, so a closed panel drops out of traversal in the frame
389            // it disappears. A disabled control is not registered at all:
390            // repose leaves it out of the focus chain, and a map entry the chain
391            // does not have would make a press onto it go nowhere.
392            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
419/// The overlay root: full-screen, and deliberately not focusable.
420///
421/// The template games wrap their overlay in a focusable root that requests focus
422/// for itself every frame, which is why no button in those games can be reached
423/// with a keyboard or a pad. A root that stays out of the focus chain leaves
424/// focus to the kit's controls.
425pub fn overlay_root(children: impl IntoChildren) -> View {
426    ZStack(Modifier::new().fill_max_size()).child(children)
427}
428
429/// Whether a glyph-font family is registered: the kit's `icon` helpers need
430/// `Material Symbols Outlined` to have been registered by the game.
431pub 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        // Composition happened; the modifier is what carries the click and the
466        // focus request, so the closure is exercised by calling the modifier's
467        // handler directly.
468        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        // The map needs a layout pass to place the controls: compose-time
505        // registration only learns the view id from the previous frame.
506        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        // The paint pass assigns view ids at the end of the frame; the next
521        // frame's registration binds the rectangles to them.
522        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        // Two laid-out controls, marked the way the button builder marks them.
580        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}