1#![no_std]
32#![deny(unsafe_op_in_unsafe_fn)]
33#![warn(missing_docs)]
34
35use psx_font::FontAtlas;
36#[expect(
37 deprecated,
38 reason = "psx-osk draws without a `psx_gpu::Gpu` until its own API takes one"
39)]
40use psx_gpu::draw_rect_flat;
41
42pub enum Action {
44 None,
46 Insert(u8),
48 Backspace,
50 Commit,
52}
53
54#[derive(Copy, Clone, Debug, PartialEq, Eq)]
56pub enum Direction {
57 Left,
59 Right,
61 Up,
63 Down,
65}
66
67#[deprecated(note = "renamed to `Direction`")]
69pub type Dir = Direction;
70
71#[derive(Copy, Clone, Debug)]
73pub struct Palette {
74 pub panel: (u8, u8, u8),
76 pub key: (u8, u8, u8),
78 pub hot: (u8, u8, u8),
80 pub accent: (u8, u8, u8),
82 pub accent_text: (u8, u8, u8),
84 pub text: (u8, u8, u8),
86 pub dim: (u8, u8, u8),
88}
89
90impl Palette {
91 pub const GRAY: Self = Self {
93 panel: (24, 24, 28),
94 key: (52, 52, 60),
95 hot: (90, 70, 30),
96 accent: (200, 200, 210),
97 accent_text: (16, 16, 20),
98 text: (220, 220, 225),
99 dim: (140, 140, 150),
100 };
101}
102
103#[derive(Copy, Clone)]
104enum Key {
105 Ch(u8),
106 Shift,
107 Sym,
108 Space,
109 Del,
110 Ok,
111}
112
113const LETTERS: [&[u8; 10]; 4] = [b"1234567890", b"QWERTYUIOP", b"ASDFGHJKL'", b"ZXCVBNM.,:"];
115const SYMBOLS: [&[u8; 10]; 4] = [b"1234567890", b"+-*/=()%^$", b".,:;!?@#&|", b"<>[]{}\"'\\`"];
116
117const FROW: [(Key, i16, i16); 5] = [
119 (Key::Shift, 0, 2),
120 (Key::Sym, 2, 2),
121 (Key::Space, 4, 3),
122 (Key::Del, 7, 1),
123 (Key::Ok, 8, 2),
124];
125
126const ROWS: usize = 5;
127const X0: i16 = 8;
128const UW: i16 = 30; const KH: i16 = 18; const HINT_BAND: i16 = 14;
132const BOTTOM_MARGIN: i16 = 14;
134pub const PANEL_HEIGHT: i16 = HINT_BAND + ROWS as i16 * KH + BOTTOM_MARGIN;
137pub const PANEL_TOP: i16 = 240 - PANEL_HEIGHT;
140const KEYS_TOP: i16 = PANEL_TOP + HINT_BAND;
142
143#[deprecated(note = "this is the top of the key rows, 14 below `PANEL_TOP`; use `PANEL_TOP`")]
148pub const Y0: i16 = KEYS_TOP;
149
150pub struct Keyboard {
152 row: usize,
153 col: usize,
154 shift: bool,
155 sym: bool,
156}
157
158impl Keyboard {
159 pub const fn new() -> Self {
161 Keyboard {
162 row: 1,
163 col: 0,
164 shift: false,
165 sym: false,
166 }
167 }
168
169 fn row_len(&self, r: usize) -> usize {
170 if r == 4 {
171 FROW.len()
172 } else {
173 10
174 }
175 }
176
177 fn span(&self, r: usize, c: usize) -> (i16, i16) {
179 if r == 4 {
180 let (_, s, w) = FROW[c];
181 (s, w)
182 } else {
183 (c as i16, 1)
184 }
185 }
186
187 fn centre2(&self, r: usize, c: usize) -> i16 {
189 let (s, w) = self.span(r, c);
190 2 * s + w
191 }
192
193 fn key_at(&self, r: usize, c: usize) -> Key {
194 if r == 4 {
195 FROW[c].0
196 } else {
197 let page = if self.sym { &SYMBOLS } else { &LETTERS };
198 Key::Ch(page[r][c])
199 }
200 }
201
202 fn cased(&self, c: u8) -> u8 {
203 if !self.shift && c.is_ascii_uppercase() {
204 c + 32
205 } else {
206 c
207 }
208 }
209
210 pub fn step(&mut self, dir: Direction) {
212 let n = self.row_len(self.row);
213 match dir {
214 Direction::Left => self.col = (self.col + n - 1) % n,
215 Direction::Right => self.col = (self.col + 1) % n,
216 Direction::Up | Direction::Down => {
217 let nr = if dir == Direction::Up {
218 (self.row + ROWS - 1) % ROWS
219 } else {
220 (self.row + 1) % ROWS
221 };
222 let want = self.centre2(self.row, self.col);
223 self.row = nr;
224 self.col = self.nearest_col(nr, want);
225 }
226 }
227 }
228
229 fn nearest_col(&self, r: usize, want2: i16) -> usize {
230 let mut best = 0;
231 let mut best_d = i16::MAX;
232 for c in 0..self.row_len(r) {
233 let d = (self.centre2(r, c) - want2).abs();
234 if d < best_d {
235 best_d = d;
236 best = c;
237 }
238 }
239 best
240 }
241
242 pub fn activate(&mut self) -> Action {
244 match self.key_at(self.row, self.col) {
245 Key::Ch(c) => Action::Insert(self.cased(c)),
246 Key::Space => Action::Insert(b' '),
247 Key::Del => Action::Backspace,
248 Key::Ok => Action::Commit,
249 Key::Shift => {
250 self.shift = !self.shift;
251 Action::None
252 }
253 Key::Sym => {
254 self.sym = !self.sym;
255 Action::None
256 }
257 }
258 }
259
260 #[expect(
263 deprecated,
264 reason = "psx-osk draws without a `psx_gpu::Gpu` until its own API takes one"
265 )]
266 pub fn draw(&self, font: &FontAtlas, p: &Palette, hint: &str) {
267 draw_rect_flat(
268 0,
269 PANEL_TOP,
270 320,
271 PANEL_HEIGHT as u16,
272 p.panel.0,
273 p.panel.1,
274 p.panel.2,
275 );
276 font.draw_text(6, PANEL_TOP + 2, hint, p.dim);
277
278 for r in 0..ROWS {
279 for c in 0..self.row_len(r) {
280 let (s, w) = self.span(r, c);
281 let x = X0 + s * UW;
282 let y = KEYS_TOP + (r as i16) * KH;
283 let kw = w * UW;
284 let sel = r == self.row && c == self.col;
285 let key = self.key_at(r, c);
286
287 let active = matches!((key, self.shift), (Key::Shift, true))
288 || matches!((key, self.sym), (Key::Sym, true));
289 let bg = if sel {
290 p.accent
291 } else if active {
292 p.hot
293 } else {
294 p.key
295 };
296 draw_rect_flat(
297 x + 1,
298 y + 1,
299 (kw - 2) as u16,
300 (KH - 2) as u16,
301 bg.0,
302 bg.1,
303 bg.2,
304 );
305
306 let mut buf = [0u8; 6];
307 let label: &[u8] = match key {
308 Key::Ch(ch) => {
309 buf[0] = self.cased(ch);
310 &buf[..1]
311 }
312 Key::Shift => b"SH",
313 Key::Sym => {
314 if self.sym {
315 b"ABC"
316 } else {
317 b"@#"
318 }
319 }
320 Key::Space => b"SPACE",
321 Key::Del => b"DEL",
322 Key::Ok => b"OK",
323 };
324 let tint = if sel { p.accent_text } else { p.text };
325 let tw = (label.len() as i16) * 8;
326 font.draw_text(x + kw / 2 - tw / 2, y + (KH - 8) / 2, str_of(label), tint);
327 }
328 }
329 }
330}
331
332impl Default for Keyboard {
333 fn default() -> Self {
334 Self::new()
335 }
336}
337
338fn str_of(b: &[u8]) -> &str {
339 core::str::from_utf8(b).unwrap_or("?")
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 fn insert_of(a: Action) -> Option<u8> {
348 match a {
349 Action::Insert(c) => Some(c),
350 _ => None,
351 }
352 }
353
354 #[test]
358 fn the_panel_constants_describe_the_painted_panel() {
359 assert_eq!((PANEL_TOP, PANEL_HEIGHT), (122, 118));
361 assert_eq!(PANEL_TOP + PANEL_HEIGHT, 240);
362 const {
364 assert!(PANEL_TOP + 2 + 8 <= KEYS_TOP);
366 assert!(KEYS_TOP + ROWS as i16 * KH <= PANEL_TOP + PANEL_HEIGHT);
367 assert!(KEYS_TOP >= PANEL_TOP);
368 }
369 #[allow(deprecated)]
370 {
371 assert_eq!(Y0, KEYS_TOP, "the old name keeps its old value");
372 }
373 }
374
375 #[test]
376 fn homes_on_q_lowercase() {
377 let mut kb = Keyboard::new();
378 assert_eq!(insert_of(kb.activate()), Some(b'q'));
379 }
380
381 #[test]
382 fn shift_uppercases_until_toggled_off() {
383 let mut kb = Keyboard::new();
384 for _ in 0..3 {
387 kb.step(Direction::Down);
388 }
389 assert!(matches!(kb.activate(), Action::None)); for _ in 0..3 {
391 kb.step(Direction::Up);
392 }
393 assert_eq!(insert_of(kb.activate()), Some(b'Q'));
394 }
395
396 #[test]
397 fn sym_page_swaps_tables() {
398 let mut kb = Keyboard::new();
399 for _ in 0..3 {
400 kb.step(Direction::Down); }
402 kb.step(Direction::Right); assert!(matches!(kb.activate(), Action::None));
404 kb.step(Direction::Up); let got = insert_of(kb.activate()).unwrap();
406 assert!(SYMBOLS.iter().any(|row| row.contains(&got)));
407 }
408
409 #[test]
410 fn horizontal_wrap() {
411 let mut kb = Keyboard::new();
412 kb.step(Direction::Left); assert_eq!(insert_of(kb.activate()), Some(b'p'));
414 }
415
416 #[test]
417 fn space_del_ok_actions() {
418 let mut kb = Keyboard::new();
419 for _ in 0..3 {
420 kb.step(Direction::Down);
421 }
422 kb.step(Direction::Right);
424 kb.step(Direction::Right);
425 assert_eq!(insert_of(kb.activate()), Some(b' '));
426 kb.step(Direction::Right);
427 assert!(matches!(kb.activate(), Action::Backspace));
428 kb.step(Direction::Right);
429 assert!(matches!(kb.activate(), Action::Commit));
430 }
431
432 #[test]
433 fn vertical_nav_is_centre_aligned() {
434 let mut kb = Keyboard::new();
435 for _ in 0..3 {
438 kb.step(Direction::Down);
439 }
440 kb.step(Direction::Right);
441 kb.step(Direction::Right); kb.step(Direction::Up);
443 let got = insert_of(kb.activate()).unwrap();
444 assert!(b"vbnm".contains(&got), "landed on {}", got as char);
445 }
446}