Skip to main content
← PSoXide SDK guides and examples · SDK f2fec1c301f1 · All Cargo features enabled; check feature gates before use.

psx_osk/
lib.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2//! A PS4-style on-screen keyboard: QWERTY, a Shift toggle for case, a symbols
3//! page, a wide Space bar, plus Backspace and OK keys, with a boxed selection.
4//! The D-pad moves the highlight; the caller feeds it [`Keyboard::activate`].
5//!
6//! Promoted from PSXcel, which built it for cell text entry; any game that
7//! names saves, enters high scores, or takes chat text wants the same thing
8//! (zelda3-psx hand-rolled a name-entry grid; gh-psx will want score names).
9//! Nav between rows maps by horizontal key centre so a wide key (Space)
10//! lines up sensibly with the narrow keys above it.
11//!
12//! Rendering assumes a 320-wide display and psx-font's 8x8 glyph cell; the
13//! keyboard occupies the bottom [`PANEL_HEIGHT`] pixels. Colors come from a
14//! caller-supplied [`Palette`], so any game theme drops in.
15//!
16//! ```ignore
17//! let mut kb = Keyboard::new();
18//! // per frame:
19//! if pad.repeats(button::LEFT, 14, 3) { kb.step(Direction::Left); }
20//! if pad.just_pressed(button::CROSS) {
21//!     match kb.activate() {
22//!         Action::Insert(ch) => text.push(ch),
23//!         Action::Backspace => text.pop(),
24//!         Action::Commit => return Some(text),
25//!         Action::None => {}
26//!     }
27//! }
28//! kb.draw(&font, &Palette::GRAY, "X:Type  SQ:Del  R2:OK");
29//! ```
30
31#![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
42/// What pressing the highlighted key does. Toggles (Shift/Sym) return `None`.
43pub enum Action {
44    /// Nothing observable (a page/case toggle happened internally).
45    None,
46    /// Insert this ASCII byte at the caret.
47    Insert(u8),
48    /// Delete one character before the caret.
49    Backspace,
50    /// The OK key: accept the text.
51    Commit,
52}
53
54/// D-pad direction for [`Keyboard::step`].
55#[derive(Copy, Clone, Debug, PartialEq, Eq)]
56pub enum Direction {
57    /// Move the highlight left (wraps).
58    Left,
59    /// Move the highlight right (wraps).
60    Right,
61    /// Move the highlight up (wraps, centre-aligned).
62    Up,
63    /// Move the highlight down (wraps, centre-aligned).
64    Down,
65}
66
67/// Renamed to [`Direction`].
68#[deprecated(note = "renamed to `Direction`")]
69pub type Dir = Direction;
70
71/// Colors for the keyboard panel; all `(r, g, b)`.
72#[derive(Copy, Clone, Debug)]
73pub struct Palette {
74    /// Panel background behind the keys.
75    pub panel: (u8, u8, u8),
76    /// Idle key face.
77    pub key: (u8, u8, u8),
78    /// Latched toggle key face (Shift/Sym while active).
79    pub hot: (u8, u8, u8),
80    /// Highlighted (selected) key face.
81    pub accent: (u8, u8, u8),
82    /// Label color on the highlighted key.
83    pub accent_text: (u8, u8, u8),
84    /// Label color on idle keys.
85    pub text: (u8, u8, u8),
86    /// Hint-line color.
87    pub dim: (u8, u8, u8),
88}
89
90impl Palette {
91    /// A neutral gray scheme that reads on any background.
92    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
113// Four character rows per page, ten keys each; row 4 is the function row.
114const LETTERS: [&[u8; 10]; 4] = [b"1234567890", b"QWERTYUIOP", b"ASDFGHJKL'", b"ZXCVBNM.,:"];
115const SYMBOLS: [&[u8; 10]; 4] = [b"1234567890", b"+-*/=()%^$", b".,:;!?@#&|", b"<>[]{}\"'\\`"];
116
117// Function row: (key, unit_start, unit_span) inside the 10-unit column grid.
118const 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; // unit width (10 units = 300px)
129const KH: i16 = 18; // key height
130/// Height of the hint line above the keys.
131const HINT_BAND: i16 = 14;
132/// Blank panel left under the last key row.
133const BOTTOM_MARGIN: i16 = 14;
134/// Height of the whole panel as `draw` paints it: the hint line, the key rows
135/// and the margin below them, down to the bottom of a 240-line display.
136pub const PANEL_HEIGHT: i16 = HINT_BAND + ROWS as i16 * KH + BOTTOM_MARGIN;
137/// Top edge of the panel as `draw` paints it on a 240-line display. A game
138/// keeps its own content above this line.
139pub const PANEL_TOP: i16 = 240 - PANEL_HEIGHT;
140/// Top edge of the first key row, below the hint line.
141const KEYS_TOP: i16 = PANEL_TOP + HINT_BAND;
142
143/// The top of the key rows, 14 pixels below [`PANEL_TOP`]. It was documented as
144/// the panel's top edge and callers subtract the hint line from it
145/// (`Y0 - 14`); that is `PANEL_TOP`, so use [`PANEL_TOP`] and drop the
146/// subtraction.
147#[deprecated(note = "this is the top of the key rows, 14 below `PANEL_TOP`; use `PANEL_TOP`")]
148pub const Y0: i16 = KEYS_TOP;
149
150/// The keyboard state machine: highlight position + page/case toggles.
151pub struct Keyboard {
152    row: usize,
153    col: usize,
154    shift: bool,
155    sym: bool,
156}
157
158impl Keyboard {
159    /// Fresh keyboard homed on `Q`, lowercase, letters page (PS4 default).
160    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    /// (unit_start, unit_span) of key (r, c).
178    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    /// Doubled centre column (avoids halves) for row-to-row alignment.
188    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    /// Step the highlight one cell, wrapping across every edge.
211    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    /// Activate the highlighted key.
243    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    /// Draw the panel, keys, and `hint` line with the caller's palette.
261    /// Immediate GP0 quads + psx-font text; call after the scene draw.
262    #[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    // Labels are ASCII (the key tables + the function-key words).
340    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    /// What `draw` paints: the panel covers `PANEL_TOP..PANEL_TOP + PANEL_HEIGHT`
355    /// and the keys sit inside it. PANEL_TOP used to name the first key row,
356    /// 14 pixels below the panel it said it was the top of.
357    #[test]
358    fn the_panel_constants_describe_the_painted_panel() {
359        // The painted rectangle was y = 122, 118 tall, ending at the bottom.
360        assert_eq!((PANEL_TOP, PANEL_HEIGHT), (122, 118));
361        assert_eq!(PANEL_TOP + PANEL_HEIGHT, 240);
362        // Hint line, then keys, inside the panel.
363        const {
364            // the hint text (8 px tall, drawn 2 below the top) fits above the keys
365            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        // Navigate to the function row's Shift key: home is (1,0); Up wraps
385        // to the function row (row 0 is digits, so go down 3 to row 4 col 0).
386        for _ in 0..3 {
387            kb.step(Direction::Down);
388        }
389        assert!(matches!(kb.activate(), Action::None)); // Shift on
390        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); // to function row
401        }
402        kb.step(Direction::Right); // Shift -> Sym
403        assert!(matches!(kb.activate(), Action::None));
404        kb.step(Direction::Up); // back into the grid, symbols page
405        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); // from col 0 wraps to col 9
413        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        // Function row: Shift(0-2) Sym(2-4) Space(4-7) Del(7-8) Ok(8-10)
423        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        // Park on Space (function row, wide key), then go Up: should land
436        // near the middle of the letter row above (col ~4-6), not col 2.
437        for _ in 0..3 {
438            kb.step(Direction::Down);
439        }
440        kb.step(Direction::Right);
441        kb.step(Direction::Right); // Space
442        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}