1use std::cell::RefCell;
26use std::rc::Rc;
27
28use glam::Vec2;
29use repose_core::prelude::{Modifier, View};
30use repose_core::input::{PointerEvent, PointerKind};
31use repose_core::{AlignItems, Dp, JustifyContent};
32use repose_ui::{Box as RBox, Spacer, ViewExt as _, ZStack};
33
34use crate::flow::FlowState;
35use crate::kit::Kit;
36use crate::nav::NavDriver;
37use crate::palette::Tokens;
38use crate::queue::UiAction;
39
40pub const STICK_RADIUS: f32 = 46.0;
42
43pub const STICK_ZONE: f32 = 0.45;
45
46const KNOB_RADIUS: f32 = 16.0;
48
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum TouchError {
52 ZoneOutOfRange,
55}
56
57impl std::fmt::Display for TouchError {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 match self {
60 TouchError::ZoneOutOfRange => {
61 f.write_str("stick zone must be a fraction in 0..=1 of the viewport width")
62 }
63 }
64 }
65}
66
67impl std::error::Error for TouchError {}
68
69pub fn drag(origin: Vec2, here: Vec2, radius: f32) -> (Vec2, Vec2) {
75 if !radius.is_finite() || radius <= 0.0 {
76 return (origin, Vec2::ZERO);
77 }
78 let delta = here - origin;
79 if delta.length() <= radius {
80 return (origin, delta);
81 }
82 let direction = delta.normalize_or_zero();
83 (here - direction * radius, direction * radius)
84}
85
86pub fn axis(delta: Vec2, radius: f32) -> Vec2 {
91 if !radius.is_finite() || radius <= 0.0 {
92 return Vec2::ZERO;
93 }
94 repame_input::apply_stick(delta / radius)
95}
96
97#[derive(Clone, Copy, PartialEq, Eq)]
98struct Owner {
99 kind: PointerKind,
100 id: u64,
101}
102
103fn owner_of(event: &PointerEvent) -> Owner {
104 Owner {
105 kind: event.kind,
106 id: event.id.0,
107 }
108}
109
110struct PadState {
111 enabled: bool,
112 scale: f32,
113 zone: f32,
114 owner: Option<Owner>,
115 active: bool,
116 origin: Vec2,
117 raw: Vec2,
118}
119
120impl PadState {
121 fn release(&mut self) {
122 self.active = false;
123 self.owner = None;
124 self.origin = Vec2::ZERO;
125 self.raw = Vec2::ZERO;
126 }
127}
128
129#[derive(Clone)]
134pub struct TouchPad<A: UiAction> {
135 kit: Kit<A>,
136 inner: Rc<RefCell<PadState>>,
137}
138
139impl<A: UiAction> TouchPad<A> {
140 pub fn new(kit: Kit<A>) -> Self {
142 Self {
143 kit,
144 inner: Rc::new(RefCell::new(PadState {
145 enabled: false,
146 scale: 1.0,
147 zone: STICK_ZONE,
148 owner: None,
149 active: false,
150 origin: Vec2::ZERO,
151 raw: Vec2::ZERO,
152 })),
153 }
154 }
155
156 pub fn with_zone(self, fraction: f32) -> Result<Self, TouchError> {
158 if !fraction.is_finite() || fraction <= 0.0 || fraction > 1.0 {
159 return Err(TouchError::ZoneOutOfRange);
160 }
161 self.inner.borrow_mut().zone = fraction;
162 Ok(self)
163 }
164
165 pub fn kit(&self) -> &Kit<A> {
166 &self.kit
167 }
168
169 pub fn enabled(&self) -> bool {
171 self.inner.borrow().enabled
172 }
173
174 pub fn active(&self) -> bool {
176 self.inner.borrow().active
177 }
178
179 pub fn stick(&self) -> Vec2 {
184 let inner = self.inner.borrow();
185 axis(inner.raw, STICK_RADIUS)
186 }
187
188 pub fn begin_frame(&self, flow: FlowState) {
194 let mut inner = self.inner.borrow_mut();
195 inner.enabled = flow.game_has_input();
196 if !inner.enabled {
197 inner.release();
198 }
199 }
200
201 pub fn begin_frame_in(&self, world: &repame_sim::bevy_ecs::prelude::World) {
206 self.begin_frame(FlowState::from_world(world).unwrap_or(FlowState::CLOSED));
207 }
208
209 pub fn release(&self) {
211 self.inner.borrow_mut().release();
212 }
213
214 pub fn view(&self, viewport: [f32; 2]) -> View {
218 {
219 let mut inner = self.inner.borrow_mut();
220 if !inner.enabled {
221 return Spacer();
222 }
223 inner.scale = self.kit.scale().fit();
226 }
227
228 let tokens = self.kit.tokens();
229 let zone_fraction = self.inner.borrow().zone;
230 let zone = RBox(
231 self.kit
232 .place(viewport, [0.0, 0.0, zone_fraction, 1.0], [0.0; 4])
233 .on_pointer_down(self.on_down())
234 .on_pointer_move(self.on_move())
235 .on_pointer_up(self.on_release())
236 .on_pointer_cancel(self.on_release()),
237 )
238 .child(Spacer());
239 ZStack(Modifier::new().fill_max_size()).child((zone, self.ring(viewport, &tokens)))
240 }
241
242 pub fn tap(
248 &self,
249 viewport: [f32; 2],
250 glyph: &str,
251 at: [f32; 2],
252 radius: f32,
253 action: A,
254 ) -> View {
255 let scale = self.kit.scale().fit();
256 let tokens = self.kit.tokens();
257 let enabled = self.inner.borrow().enabled;
258 let modifier = self
259 .kit
260 .place(
261 viewport,
262 [0.0; 4],
263 [
264 at[0] - radius,
265 at[1] - radius,
266 at[0] + radius,
267 at[1] + radius,
268 ],
269 )
270 .background(tokens.cream)
271 .border(Dp(3.0 * scale), tokens.ink, Dp(radius * scale))
272 .clip_rounded(Dp(radius * scale))
273 .shadow_with_color(Dp(0.0), Dp(4.0 * scale), tokens.button_shadow)
274 .align_items(AlignItems::CENTER)
275 .justify_content(JustifyContent::CENTER)
276 .focusable(false);
277 let modifier = if enabled {
278 let sink = self.kit.sink().clone();
279 modifier.on_click(move || sink.push(action.clone()))
280 } else {
281 modifier.disabled()
282 };
283 RBox(modifier).child(self.kit.icon(glyph, 26.0, tokens.ink))
284 }
285
286 pub fn driver(&self) -> NavDriver {
291 self.kit.driver()
292 }
293
294 fn on_down(&self) -> impl Fn(PointerEvent) + 'static {
295 let inner = self.inner.clone();
296 move |event: PointerEvent| {
297 let mut state = inner.borrow_mut();
298 if !state.enabled || state.owner.is_some() {
299 return;
300 }
301 state.owner = Some(owner_of(&event));
302 state.active = true;
303 state.raw = Vec2::ZERO;
304 state.origin = design_units(event.window_position, state.scale);
305 event.consume();
306 }
307 }
308
309 fn on_move(&self) -> impl Fn(PointerEvent) + 'static {
310 let inner = self.inner.clone();
311 move |event: PointerEvent| {
312 let mut state = inner.borrow_mut();
313 if !state.enabled || state.owner != Some(owner_of(&event)) {
314 return;
315 }
316 let here = design_units(event.window_position, state.scale);
317 let (origin, delta) = drag(state.origin, here, STICK_RADIUS);
318 state.origin = origin;
319 state.raw = delta;
320 event.consume();
321 }
322 }
323
324 fn on_release(&self) -> impl Fn(PointerEvent) + 'static {
325 let inner = self.inner.clone();
326 move |event: PointerEvent| {
327 let mut state = inner.borrow_mut();
328 if state.owner != Some(owner_of(&event)) {
329 return;
330 }
331 state.release();
332 }
333 }
334
335 fn ring(&self, viewport: [f32; 2], tokens: &Tokens) -> View {
336 let inner = self.inner.borrow();
337 if !inner.active {
338 return Spacer();
339 }
340 let origin = inner.origin.to_array();
341 let knob = (inner.origin + inner.raw).to_array();
342 let vouchers = [
343 self.kit.disc(viewport, origin, STICK_RADIUS + 13.0, tokens.touch_shadow),
344 self.kit.disc(viewport, origin, STICK_RADIUS + 3.0, tokens.touch_bg),
345 self.kit.ring(viewport, origin, STICK_RADIUS + 3.0, 3.0, tokens.touch_ring),
346 self.kit.disc(viewport, origin, 4.0, tokens.touch_center),
347 self.kit.disc(viewport, knob, KNOB_RADIUS - 3.0, tokens.touch_knob_dark),
348 self.kit.disc(viewport, knob, KNOB_RADIUS - 6.0, tokens.touch_knob),
349 ];
350 ZStack(Modifier::new().fill_max_size()).child(vouchers.to_vec())
351 }
352}
353
354fn design_units(pixels: repose_core::Vec2, scale: f32) -> Vec2 {
356 crate::layout::design_units(pixels, scale)
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use crate::flow::{Flow, Suspension, Transition};
363 use crate::layout::Scale;
364 use crate::nav::Wrap;
365 use crate::palette::Tokens;
366 use crate::queue::ActionQueue;
367 use repame_input::{GamepadState, TouchContact};
368
369 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
370 enum Act {
371 Jump,
372 Dive,
373 }
374
375 fn pad() -> (TouchPad<Act>, ActionQueue<Act>) {
376 let (queue, sink) = ActionQueue::pair();
377 let kit = Kit::new(Scale::unit(), Tokens::default(), sink);
378 (TouchPad::new(kit), queue)
379 }
380
381 fn playing() -> FlowState {
382 FlowState::new(Flow::Playing, Transition::Idle, Suspension::Live)
383 }
384
385 fn paused() -> FlowState {
386 FlowState::new(Flow::Paused, Transition::Idle, Suspension::Live)
387 }
388
389 #[test]
390 fn drag_within_the_radius_keeps_the_origin() {
391 let origin = Vec2::new(100.0, 100.0);
392 let (kept, delta) = drag(origin, Vec2::new(120.0, 100.0), 46.0);
393 assert_eq!(kept, origin);
394 assert_eq!(delta, Vec2::new(20.0, 0.0));
395 }
396
397 #[test]
398 fn drag_past_the_radius_pulls_the_origin_along() {
399 let origin = Vec2::new(100.0, 100.0);
400 let (moved, delta) = drag(origin, Vec2::new(200.0, 100.0), 46.0);
401 assert_eq!(moved, Vec2::new(154.0, 100.0));
402 assert_eq!(delta, Vec2::new(46.0, 0.0));
403 assert!((delta.length() - 46.0).abs() < 1e-6);
404 }
405
406 #[test]
407 fn a_degenerate_radius_reads_as_rest() {
408 let origin = Vec2::new(10.0, 10.0);
409 assert_eq!(drag(origin, Vec2::new(50.0, 10.0), 0.0), (origin, Vec2::ZERO));
410 assert_eq!(axis(Vec2::new(46.0, 0.0), 0.0), Vec2::ZERO);
411 }
412
413 #[test]
414 fn axis_kills_the_dead_zone_and_reaches_full_deflection() {
415 assert_eq!(axis(Vec2::ZERO, STICK_RADIUS), Vec2::ZERO);
416 assert_eq!(
417 axis(Vec2::new(8.0, 0.0), STICK_RADIUS),
418 Vec2::ZERO,
419 "0.17 of the radius is inside repame-input's 0.22 dead zone"
420 );
421 let half = axis(Vec2::new(23.0, 0.0), STICK_RADIUS);
422 assert!(half.x > 0.0 && half.x < 0.5, "half deflection is partial, got {half:?}");
423 let full = axis(Vec2::new(46.0, 0.0), STICK_RADIUS);
424 assert!((full.x - 1.0).abs() < 1e-6, "full deflection is unit, got {full:?}");
425 let beyond = axis(Vec2::new(200.0, 0.0), STICK_RADIUS);
426 assert!((beyond.length() - 1.0).abs() < 1e-6, "clamped to the unit disc");
427 assert_eq!(beyond.y, 0.0);
428 }
429
430 #[test]
431 fn axis_keeps_the_direction() {
432 let up_left = axis(Vec2::new(-46.0, -46.0), STICK_RADIUS);
433 assert!(up_left.x < 0.0 && up_left.y < 0.0);
434 assert!((up_left.length() - 1.0).abs() < 1e-6);
435 assert!((up_left.x - up_left.y).abs() < 1e-6, "the diagonal stays symmetric");
436 }
437
438 #[test]
439 fn the_gate_follows_the_flow_predicates() {
440 let (pad, _queue) = pad();
441 assert!(!pad.enabled(), "closed until a frame says otherwise");
442 pad.begin_frame(playing());
443 assert!(pad.enabled());
444 pad.begin_frame(paused());
445 assert!(!pad.enabled(), "a pause panel closes the gate");
446 pad.begin_frame(FlowState::new(Flow::Playing, Transition::Running, Suspension::Live));
447 assert!(!pad.enabled(), "a transition closes the gate");
448 pad.begin_frame(FlowState::new(Flow::Playing, Transition::Idle, Suspension::Suspended));
449 assert!(!pad.enabled(), "a suspend closes the gate");
450 pad.begin_frame(playing());
451 assert!(pad.enabled());
452 }
453
454 #[test]
455 fn the_gate_can_be_read_from_a_sim_world() {
456 use repame_sim::Sim;
457
458 let (pad, _queue) = pad();
459 let mut sim = Sim::with_default_step();
460 pad.begin_frame_in(&sim.world);
462 assert!(!pad.enabled());
463
464 crate::flow::install(&mut sim, Flow::Playing);
465 pad.begin_frame_in(&sim.world);
466 assert!(pad.enabled());
467
468 sim.set_state(Flow::Modal);
469 sim.tick();
470 pad.begin_frame_in(&sim.world);
471 assert!(!pad.enabled(), "a modal freezes the overlay");
472 }
473
474 #[test]
475 fn the_closed_flow_state_runs_nothing() {
476 assert!(!FlowState::CLOSED.run_live());
477 assert!(FlowState::CLOSED.menu());
478 assert!(FlowState::CLOSED.panel_owns_input());
479 }
480
481 #[test]
482 fn closing_the_gate_releases_the_stick() {
483 let (pad, _queue) = pad();
484 pad.begin_frame(playing());
485 {
486 let mut inner = pad.inner.borrow_mut();
487 inner.active = true;
488 inner.raw = Vec2::new(46.0, 0.0);
489 }
490 assert!(pad.active());
491 assert!((pad.stick().length() - 1.0).abs() < 1e-6);
492 pad.begin_frame(paused());
493 assert!(!pad.active());
494 assert_eq!(pad.stick(), Vec2::ZERO);
495 }
496
497 #[test]
498 fn the_stick_is_dead_zoned_in_design_units() {
499 let (pad, _queue) = pad();
500 pad.begin_frame(playing());
501 {
502 let mut inner = pad.inner.borrow_mut();
503 inner.active = true;
504 inner.raw = Vec2::new(46.0, 0.0);
505 }
506 let stick = pad.stick();
507 assert!((stick.x - 1.0).abs() < 1e-6);
508 {
509 let mut inner = pad.inner.borrow_mut();
510 inner.raw = Vec2::new(5.0, 0.0);
511 }
512 assert_eq!(pad.stick(), Vec2::ZERO, "inside the dead zone");
513 }
514
515 #[test]
516 fn the_view_is_empty_while_the_gate_is_closed() {
517 let (pad, _queue) = pad();
518 pad.begin_frame(paused());
519 let view = pad.view([960.0, 540.0]);
520 assert!(view.children.is_empty(), "no stick under a modal");
521 pad.begin_frame(playing());
522 let view = pad.view([960.0, 540.0]);
523 assert_eq!(view.children.len(), 2, "the touch zone and the ring layer");
524 }
525
526 #[test]
527 fn a_tap_button_pushes_into_the_shared_queue() {
528 let (pad, queue) = pad();
529 pad.begin_frame(playing());
530 let tap = pad.tap([960.0, 540.0], "arrow_upward", [820.0, 400.0], 48.0, Act::Jump);
531 let on_click = tap.modifier.on_click.clone().expect("enabled taps click");
532 on_click();
533 let dive = pad.tap([960.0, 540.0], "arrow_downward", [820.0, 480.0], 48.0, Act::Dive);
534 dive.modifier.on_click.clone().expect("enabled taps click")();
535 assert_eq!(queue.drain(), vec![Act::Jump, Act::Dive]);
536 assert!(!tap.modifier.focusable.unwrap_or(true), "taps stay out of keyboard traversal");
537 }
538
539 #[test]
540 fn a_tap_button_is_disabled_with_the_gate() {
541 let (pad, queue) = pad();
542 pad.begin_frame(paused());
543 let tap = pad.tap([960.0, 540.0], "arrow_upward", [820.0, 400.0], 48.0, Act::Jump);
544 assert!(tap.modifier.on_click.is_none());
545 assert!(tap.modifier.disabled);
546 assert!(queue.is_empty());
547 }
548
549 #[test]
550 fn a_zone_fraction_outside_the_unit_interval_is_rejected() {
551 let (pad, _queue) = pad();
552 assert_eq!(TouchPad::new(pad.kit().clone()).with_zone(0.0).err(), Some(TouchError::ZoneOutOfRange));
553 assert_eq!(TouchPad::new(pad.kit().clone()).with_zone(1.5).err(), Some(TouchError::ZoneOutOfRange));
554 assert_eq!(TouchPad::new(pad.kit().clone()).with_zone(f32::NAN).err(), Some(TouchError::ZoneOutOfRange));
555 let half = TouchPad::new(pad.kit().clone()).with_zone(0.5).expect("half the width");
556 assert!(!half.enabled(), "a fresh pad is closed until a frame opens it");
557 }
558
559 #[test]
560 fn the_pads_own_driver_is_the_kits_driver() {
561 let (pad, _queue) = pad();
562 assert_eq!(pad.driver().wrap(), Wrap::Cycle);
563 }
564
565 #[test]
566 fn a_contact_snapshot_can_seed_the_stick_on_the_sim_side() {
567 let contact = TouchContact {
570 id: 3,
571 start: Vec2::new(80.0, 400.0),
572 pos: Vec2::new(126.0, 400.0),
573 just_pressed: true,
574 };
575 let (_, delta) = drag(contact.start, contact.pos, STICK_RADIUS);
576 let value = axis(delta, STICK_RADIUS);
577 assert!((value.x - 1.0).abs() < 1e-6, "a full drag reads as full deflection");
578 assert_eq!(value.y, 0.0);
579 }
580
581 #[test]
582 fn pad_state_does_not_move_the_stick_by_itself() {
583 let pad_state = GamepadState::default();
586 let (pad, _queue) = pad();
587 pad.begin_frame(playing());
588 assert_eq!(pad.stick(), Vec2::ZERO);
589 assert_eq!(pad_state.left_stick, Vec2::ZERO);
590 }
591}