dotzuki_renderer/
title.rs1use crate::input::{GbButton, InputState};
18use crate::layout_engine::types::{DataContext, DataValue};
19
20#[derive(Debug, Clone)]
22pub struct TitleItem {
23 pub label: String,
24 pub enabled: bool,
27}
28
29impl TitleItem {
30 pub fn new(label: impl Into<String>) -> Self {
32 Self { label: label.into(), enabled: true }
33 }
34
35 pub fn with_enabled(label: impl Into<String>, enabled: bool) -> Self {
37 Self { label: label.into(), enabled }
38 }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum TitleEvent {
44 None,
46 Moved,
48 Selected(usize),
50}
51
52pub const DEFAULT_BLINK_PERIOD: u32 = 30;
54
55#[derive(Debug, Clone)]
57pub struct TitleMenu {
58 items: Vec<TitleItem>,
59 cursor: usize,
60 blink_period: u32,
61 blink_frame: u32,
62}
63
64impl TitleMenu {
65 pub fn new(items: Vec<TitleItem>) -> Self {
67 let mut menu = Self {
68 items,
69 cursor: 0,
70 blink_period: DEFAULT_BLINK_PERIOD,
71 blink_frame: 0,
72 };
73 if menu
75 .items
76 .first()
77 .map(|i| !i.enabled)
78 .unwrap_or(false)
79 {
80 if let Some(i) = menu.next_enabled(0, 1) {
81 menu.cursor = i;
82 }
83 }
84 menu
85 }
86
87 pub fn with_blink_period(mut self, frames: u32) -> Self {
89 self.blink_period = frames.max(1);
90 self
91 }
92
93 pub fn update(&mut self, input: &InputState) -> TitleEvent {
96 let cycle = (self.blink_period * 2).max(1);
98 self.blink_frame = (self.blink_frame + 1) % cycle;
99
100 if self.items.is_empty() {
101 return TitleEvent::None;
102 }
103
104 let up = input.is_just_pressed(GbButton::Up);
105 let down = input.is_just_pressed(GbButton::Down);
106 if up || down {
107 let dir = if up { -1 } else { 1 };
108 if let Some(next) = self.next_enabled(self.cursor, dir) {
109 if next != self.cursor {
110 self.cursor = next;
111 return TitleEvent::Moved;
112 }
113 }
114 }
115
116 if input.is_just_pressed(GbButton::A) || input.is_just_pressed(GbButton::Start) {
117 if self.items[self.cursor].enabled {
118 return TitleEvent::Selected(self.cursor);
119 }
120 }
121 TitleEvent::None
122 }
123
124 fn next_enabled(&self, from: usize, dir: i32) -> Option<usize> {
127 let n = self.items.len();
128 if n == 0 {
129 return None;
130 }
131 let mut i = from as i32;
132 for _ in 0..n {
133 i = (i + dir).rem_euclid(n as i32);
134 if self.items[i as usize].enabled {
135 return Some(i as usize);
136 }
137 }
138 None
139 }
140
141 pub fn cursor(&self) -> usize {
143 self.cursor
144 }
145
146 pub fn items(&self) -> &[TitleItem] {
148 &self.items
149 }
150
151 pub fn show_blink(&self) -> bool {
153 self.blink_frame < self.blink_period
154 }
155
156 pub fn write_context(&self, ctx: &mut DataContext) {
162 let labels: Vec<DataValue> = self
163 .items
164 .iter()
165 .map(|it| DataValue::from(it.label.as_str()))
166 .collect();
167 ctx.set("items", DataValue::List(labels));
168 ctx.set("cursor", self.cursor as i64);
169 ctx.set("show_blink", self.show_blink());
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 fn tap(menu: &mut TitleMenu, button: GbButton) -> TitleEvent {
179 let mut input = InputState::new();
180 input.begin_frame();
181 input.press(button);
182 menu.update(&input)
183 }
184
185 fn menu3() -> TitleMenu {
186 TitleMenu::new(vec![
187 TitleItem::new("新的征程"),
188 TitleItem::new("继续江湖"),
189 TitleItem::new("退出"),
190 ])
191 }
192
193 #[test]
194 fn cursor_moves_and_wraps() {
195 let mut m = menu3();
196 assert_eq!(m.cursor(), 0);
197 assert_eq!(tap(&mut m, GbButton::Down), TitleEvent::Moved);
198 assert_eq!(m.cursor(), 1);
199 tap(&mut m, GbButton::Down);
200 assert_eq!(m.cursor(), 2);
201 assert_eq!(tap(&mut m, GbButton::Down), TitleEvent::Moved);
203 assert_eq!(m.cursor(), 0);
204 tap(&mut m, GbButton::Up);
206 assert_eq!(m.cursor(), 2);
207 }
208
209 #[test]
210 fn confirm_reports_selected_index() {
211 let mut m = menu3();
212 tap(&mut m, GbButton::Down); assert_eq!(tap(&mut m, GbButton::A), TitleEvent::Selected(1));
214 assert_eq!(tap(&mut m, GbButton::Start), TitleEvent::Selected(1));
216 }
217
218 #[test]
219 fn disabled_entries_are_skipped_and_unselectable() {
220 let mut m = TitleMenu::new(vec![
223 TitleItem::new("新的征程"),
224 TitleItem::with_enabled("继续江湖", false),
225 TitleItem::new("退出"),
226 ]);
227 assert_eq!(m.cursor(), 0, "starts on the first enabled entry");
228 assert_eq!(tap(&mut m, GbButton::Down), TitleEvent::Moved);
229 assert_eq!(m.cursor(), 2, "Down skips the disabled entry");
230 assert_eq!(tap(&mut m, GbButton::A), TitleEvent::Selected(2));
233 }
234
235 #[test]
236 fn first_entry_disabled_seeks_forward() {
237 let m = TitleMenu::new(vec![
238 TitleItem::with_enabled("继续江湖", false),
239 TitleItem::new("新的征程"),
240 ]);
241 assert_eq!(m.cursor(), 1, "cursor skips a disabled first entry");
242 }
243
244 #[test]
245 fn blink_toggles_over_the_period() {
246 let mut m = TitleMenu::new(vec![TitleItem::new("开始")]).with_blink_period(2);
247 assert!(m.show_blink());
249 let idle = InputState::new();
250 m.update(&idle); assert!(m.show_blink());
252 m.update(&idle); assert!(!m.show_blink());
254 m.update(&idle); assert!(!m.show_blink());
256 m.update(&idle); assert!(m.show_blink());
258 }
259}