use std::cell::Cell;
use std::rc::Rc;
use repose_core::prelude::{Color, FlexDirection, Modifier, View};
use repose_core::text::TextAlign;
use repose_core::{AlignItems, Dp, JustifyContent, Sp};
use repose_ui::{IntoChildren, Text, TextStyle as _, ViewExt as _, ZStack};
use crate::layout::{Scale, anchor_rect, disc, ring};
use crate::nav::{GroupId, NavDriver, NavMap, Wrap};
use crate::palette::{
self, FONT, ICON_BUTTON, ICON_GLYPH, MATERIAL_SYMBOLS, Tokens, outline_for,
};
use crate::queue::{Sink, UiAction};
#[derive(Clone)]
pub struct Kit<A: UiAction> {
scale: Rc<Cell<Scale>>,
tokens: Tokens,
sink: Sink<A>,
nav: NavMap,
wrap: Wrap,
}
impl<A: UiAction> Kit<A> {
pub fn new(scale: Scale, tokens: Tokens, sink: Sink<A>) -> Self {
Self {
scale: Rc::new(Cell::new(scale)),
tokens,
sink,
nav: NavMap::new(),
wrap: Wrap::default(),
}
}
pub fn with_map(scale: Scale, tokens: Tokens, sink: Sink<A>, nav: NavMap) -> Self {
Self {
scale: Rc::new(Cell::new(scale)),
tokens,
sink,
nav,
wrap: Wrap::default(),
}
}
pub fn set_scale(&self, scale: Scale) {
self.scale.set(scale);
}
pub fn scale(&self) -> Scale {
self.scale.get()
}
pub fn tokens(&self) -> Tokens {
self.tokens
}
pub fn with_tokens(mut self, tokens: Tokens) -> Self {
self.tokens = tokens;
self
}
pub fn with_wrap(mut self, wrap: Wrap) -> Self {
self.wrap = wrap;
self
}
pub fn sink(&self) -> &Sink<A> {
&self.sink
}
pub fn nav_map(&self) -> &NavMap {
&self.nav
}
pub fn driver(&self) -> NavDriver {
NavDriver::new(self.nav.clone()).with_wrap(self.wrap)
}
pub fn begin_frame(&self) {
self.nav.begin_frame();
}
pub fn button(&self, text: impl Into<String>, action: A) -> Button<A> {
Button {
kit: self.clone(),
text: text.into(),
glyph: None,
fill: self.tokens.cream,
text_fill: None,
enabled: true,
group: None,
key: None,
action,
}
}
pub fn icon_button(&self, glyph: impl Into<String>, action: A) -> Button<A> {
Button {
kit: self.clone(),
text: glyph.into(),
glyph: Some(()),
fill: self.tokens.cream,
text_fill: Some(self.tokens.ink),
enabled: true,
group: None,
key: None,
action,
}
}
pub fn chip(&self, text: impl Into<String>, face: Color) -> View {
let scale = self.scale.get().fit();
let tokens = self.tokens;
repose_ui::Box(palette::chip(&tokens, face, scale))
.child(self.label_plain(Modifier::new(), &text.into(), 20.0, tokens.ink))
}
pub fn panel(
&self,
viewport: [f32; 2],
anchor: [f32; 4],
offset: [f32; 4],
face: Color,
) -> View {
let scale = self.scale.get().fit();
let tokens = self.tokens;
repose_ui::Box(
anchor_rect(viewport, scale, anchor, offset)
.background(face)
.border(Dp(3.0 * scale), tokens.ink, Dp(18.0 * scale))
.shadow_with_color(Dp(0.0), Dp(6.0 * scale), tokens.button_shadow)
.flex_dir(FlexDirection::Column)
.row_gap(Dp(14.0 * scale))
.align_items(AlignItems::CENTER)
.justify_content(JustifyContent::CENTER),
)
}
pub fn scrim(&self) -> View {
let tokens = self.tokens;
ZStack(
Modifier::new()
.fill_max_size()
.background(tokens.scrim)
.input_blocker()
.focusable(false),
)
.child(repose_ui::Spacer())
}
pub fn label(&self, modifier: Modifier, text: &str, size: f32, fill: Color) -> View {
let scale = self.scale.get().fit();
let tokens = self.tokens;
Text(text.to_string())
.size(Sp(size * scale))
.text_align(TextAlign::Center)
.font_family(FONT)
.color(fill)
.outlined(0.42 * outline_for(size) / size, tokens.ink)
.single_line()
.overflow_visible()
.modifier(modifier)
}
pub fn label_plain(&self, modifier: Modifier, text: &str, size: f32, fill: Color) -> View {
let scale = self.scale.get().fit();
Text(text.to_string())
.size(Sp(size * scale))
.text_align(TextAlign::Center)
.font_family(FONT)
.color(fill)
.single_line()
.overflow_visible()
.modifier(modifier)
}
pub fn display(&self, modifier: Modifier, text: &str, size: f32, fill: Color) -> View {
let scale = self.scale.get().fit();
let tracking = (size * -0.012).clamp(-1.2, 0.0);
Text(text.to_string())
.size(Sp(size * scale))
.line_height(Sp(size * scale * 1.02))
.letter_spacing(Sp(tracking * scale))
.text_align(TextAlign::Center)
.font_family(FONT)
.color(fill)
.fill_and_stroke(0.028)
.single_line()
.overflow_visible()
.modifier(modifier)
}
pub fn icon(&self, glyph: &str, size: f32, fill: Color) -> View {
let scale = self.scale.get().fit();
Text(glyph.to_string())
.size(Sp(size * scale))
.text_align(TextAlign::Center)
.font_family(MATERIAL_SYMBOLS)
.color(fill)
.single_line()
.overflow_visible()
.modifier(Modifier::new())
}
pub fn disc(&self, viewport: [f32; 2], at: [f32; 2], radius: f32, color: Color) -> View {
disc(viewport, self.scale.get().fit(), at, radius, color)
}
pub fn ring(
&self,
viewport: [f32; 2],
at: [f32; 2],
radius: f32,
width: f32,
color: Color,
) -> View {
ring(viewport, self.scale.get().fit(), at, radius, width, color)
}
pub fn place(&self, viewport: [f32; 2], anchor: [f32; 4], offset: [f32; 4]) -> Modifier {
anchor_rect(viewport, self.scale.get().fit(), anchor, offset)
}
pub fn touch_pad(&self) -> crate::touch::TouchPad<A> {
crate::touch::TouchPad::new(self.clone())
}
}
pub struct Button<A: UiAction> {
kit: Kit<A>,
text: String,
glyph: Option<()>,
fill: Color,
text_fill: Option<Color>,
enabled: bool,
group: Option<GroupId>,
key: Option<String>,
action: A,
}
impl<A: UiAction> Button<A> {
pub fn fill(mut self, face: Color) -> Self {
self.fill = face;
self
}
pub fn text_fill(mut self, fill: Color) -> Self {
self.text_fill = Some(fill);
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.enabled = !disabled;
self
}
pub fn group(mut self, group: GroupId) -> Self {
self.group = Some(group);
self
}
pub fn keyed(mut self, key: impl Into<String>) -> Self {
self.key = Some(key.into());
self
}
pub fn view(self) -> View {
let kit = self.kit;
let scale = kit.scale.get().fit();
let tokens = kit.tokens;
let square = self.glyph.is_some();
let mut modifier = palette::chunky(
&tokens,
self.fill,
if square { 10.0 } else { 22.0 },
if square { 10.0 } else { 12.0 },
if square { 12.0 } else { 16.0 },
scale,
);
if square {
modifier = modifier.size(Dp(ICON_BUTTON * scale), Dp(ICON_BUTTON * scale));
}
if !self.enabled {
modifier = modifier.disabled();
} else {
let sink = kit.sink.clone();
let action = self.action.clone();
modifier = modifier.on_click(move || sink.push(action.clone()));
let identity = self.key.clone().unwrap_or_else(|| {
if square {
format!("icon.{}", self.text)
} else {
self.text.clone()
}
});
let requester = repose_core::remember_with_key(
format!("repame_ui.control.{identity}"),
repose_core::prelude::FocusRequester::new,
);
let nav = kit.nav.clone();
let group = self.group;
let place = (*requester).clone();
let map = nav.clone();
modifier = modifier
.focus_requester((*requester).clone())
.on_globally_positioned(move |rect| {
map.place(&place, rect, group);
});
nav.register(&requester, group);
}
let caption = match (self.glyph, self.text_fill) {
(Some(()), fill) => kit.icon(&self.text, ICON_GLYPH, fill.unwrap_or(tokens.ink)),
(None, Some(fill)) => kit.label_plain(Modifier::new(), &self.text, 24.0, fill),
(None, None) => kit.label(Modifier::new(), &self.text, 24.0, tokens.ink),
};
repose_ui::Box(modifier).child(caption)
}
}
impl<A: UiAction> IntoChildren for Button<A> {
fn into_children(self) -> Vec<View> {
vec![self.view()]
}
}
pub fn overlay_root(children: impl IntoChildren) -> View {
ZStack(Modifier::new().fill_max_size()).child(children)
}
pub const ICON_FAMILY: &str = MATERIAL_SYMBOLS;
#[cfg(test)]
mod tests {
use super::*;
use crate::queue::ActionQueue;
use crate::nav::NavDir;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Act {
Play,
Mute,
}
fn kit() -> (Kit<Act>, ActionQueue<Act>) {
let (queue, sink) = ActionQueue::pair();
let kit = Kit::new(Scale::unit(), Tokens::default(), sink);
(kit, queue)
}
#[test]
fn the_scale_is_shared_across_clones() {
let (kit, _queue) = kit();
let clone = kit.clone();
kit.set_scale(Scale::new([1920.0, 1080.0], crate::layout::DESIGN).expect("scales"));
assert!((clone.scale().fit() - 2.0).abs() < 1e-6);
assert_eq!(clone.tokens(), Tokens::default());
}
#[test]
fn a_button_pushes_its_action_and_registers_in_the_map() {
let (kit, queue) = kit();
kit.begin_frame();
let view = kit.button("Play", Act::Play).view();
let on_click = view.modifier.on_click.clone().expect("button is clickable");
on_click();
assert_eq!(queue.drain(), vec![Act::Play]);
assert!(view.modifier.focusable.unwrap_or(false), "button takes focus");
assert!(view.modifier.focus_requester.is_some(), "button can request focus");
}
#[test]
fn a_disabled_button_neither_clicks_nor_joins_traversal() {
let (kit, queue) = kit();
kit.begin_frame();
let view = kit.button("Play", Act::Play).disabled(true).view();
assert!(view.modifier.on_click.is_none());
assert!(view.modifier.focus_requester.is_none(), "no focus target either");
assert!(view.modifier.disabled, "repose skips disabled controls");
assert!(queue.is_empty());
}
#[test]
fn an_icon_button_is_square_and_captionless() {
let (kit, _queue) = kit();
kit.begin_frame();
let view = kit.button("x", Act::Mute).fill(Tokens::default().coral).view();
assert!(view.modifier.on_click.is_some());
assert_eq!(view.children.len(), 1);
let icon = kit.icon_button("pause", Act::Mute).view();
assert!(icon.modifier.on_click.is_some());
}
#[test]
fn a_button_joins_its_focus_group() {
let (kit, _queue) = kit();
kit.begin_frame();
let pause = GroupId::named("pause");
let resume = kit.button("Resume", Act::Play).group(pause).view();
let quit = kit.button("Quit", Act::Mute).group(pause).view();
assert!(kit.nav_map().is_empty(), "no id and no rectangle yet");
let resume_place = resume
.modifier
.on_globally_positioned
.clone()
.expect("buttons report their rectangle");
let quit_place = quit
.modifier
.on_globally_positioned
.clone()
.expect("buttons report their rectangle");
resume_place(repose_core::Rect { x: 200.0, y: 300.0, w: 200.0, h: 64.0 });
quit_place(repose_core::Rect { x: 200.0, y: 380.0, w: 200.0, h: 64.0 });
let resume_requester = resume
.modifier
.focus_requester
.clone()
.expect("buttons request focus");
let quit_requester = quit
.modifier
.focus_requester
.clone()
.expect("buttons request focus");
*resume_requester.target.borrow_mut() = Some(41);
*quit_requester.target.borrow_mut() = Some(42);
kit.begin_frame();
assert!(kit.nav_map().register(&resume_requester, Some(pause)));
assert!(kit.nav_map().register(&quit_requester, Some(pause)));
assert_eq!(crate::nav::chain_of(kit.nav_map()), [41, 42]);
assert!(crate::nav::same_group(kit.nav_map(), 41, 42));
assert_eq!(kit.driver().wrap(), Wrap::Cycle);
}
#[test]
fn a_button_becomes_navigable_the_frame_after_it_appears() {
let (kit, _queue) = kit();
kit.begin_frame();
let view = kit.button("Play", Act::Play).view();
let place = view
.modifier
.on_globally_positioned
.clone()
.expect("buttons report their rectangle");
let requester = view
.modifier
.focus_requester
.clone()
.expect("buttons request focus");
place(repose_core::Rect { x: 40.0, y: 300.0, w: 200.0, h: 64.0 });
assert!(kit.nav_map().is_empty(), "the rectangle waits for the view id");
*requester.target.borrow_mut() = Some(31);
kit.begin_frame();
assert!(kit.nav_map().register(&requester, None));
let entry = kit.nav_map().active().into_iter().next().expect("one control");
assert_eq!(entry.id(), 31);
assert_eq!(entry.rect().w, 200.0);
assert_eq!(
kit.driver().enter(&repose_core::prelude::Scheduler::default()),
crate::nav::NavStep::Entered(31),
"the control is the entry point of an empty focus chain"
);
}
#[test]
fn a_kits_controls_can_be_driven_by_direction() {
let (kit, _queue) = kit();
let nav = kit.nav_map();
nav.begin_frame();
for (id, x) in [(11u64, 100.0f32), (12, 400.0)] {
let requester = repose_core::prelude::FocusRequester::new();
*requester.target.borrow_mut() = Some(id);
nav.mark(id, Some(GroupId::named("pause")), requester.clone());
nav.place(&requester, repose_core::Rect { x, y: 0.0, w: 200.0, h: 80.0 }, Some(GroupId::named("pause")));
}
assert_eq!(nav.resolve(Some(11), NavDir::Right, Wrap::Stay), crate::nav::NavStep::Moved(12));
assert_eq!(nav.len(), 2);
}
#[test]
fn extra_voices_read_the_scale_and_tokens() {
let (kit, queue) = kit();
kit.set_scale(Scale::new([480.0, 270.0], crate::layout::DESIGN).expect("scales"));
let chip = kit.chip("12", Tokens::default().gold);
assert_eq!(chip.children.len(), 1);
let scrim = kit.scrim();
assert!(scrim.modifier.input_blocker, "the scrim blocks the game underneath");
assert!(!scrim.modifier.focusable.unwrap_or(true), "the scrim takes no focus");
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);
assert!(panel.modifier.offset_left.is_some(), "panels are placed absolutely");
let label = kit.label(Modifier::new(), "Play", 24.0, Tokens::default().ink);
assert_eq!(label.children.len() + 1, 1, "labels are leaves");
assert!(queue.is_empty());
}
#[test]
fn the_overlay_root_stays_out_of_the_focus_chain() {
let root = overlay_root(repose_ui::Spacer());
assert!(root.modifier.focusable.is_none(), "the root must not take focus");
assert!(root.modifier.focus_requester.is_none());
}
#[test]
fn touch_pads_share_the_kits_sink() {
let (kit, queue) = kit();
let pad = kit.touch_pad();
pad.begin_frame(crate::flow::FlowState::new(
crate::flow::Flow::Playing,
crate::flow::Transition::Idle,
crate::flow::Suspension::Live,
));
assert!(pad.enabled());
assert!(queue.is_empty());
}
}