Skip to main content

agg_gui/widgets/on_screen_keyboard/
layouts.rs

1//! Keyboard layouts — declarative tables of rows / keys per layer.
2//!
3//! Adding a new layer (e.g. a French AZERTY, an emoji picker, a search-
4//! optimised "go" button) is a data change here: define a new
5//! [`Layer`] variant and return a `Layout` from
6//! [`Layout::for_layer`]. The painter and hit-tester don't change.
7
8use crate::draw_ctx::DrawCtx;
9use crate::geometry::{Point, Rect};
10
11use super::key::{KeyAction, KeyCap, KeyGlyph, PaintedKey};
12use super::style::Style;
13
14/// Which layer of the keyboard is currently visible.
15///
16/// "Shifted" is a one-shot upper-case mode (single tap of Shift); we'll
17/// add a `CapsLocked` variant later for double-tap behavior. "Numbers"
18/// holds digits + the most-used punctuation. "Symbols" is the third
19/// page reached from inside "Numbers".
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Layer {
22    Letters,
23    Shifted,
24    Numbers,
25    Symbols,
26}
27
28/// Description of one key in a row — width is expressed in
29/// "letter-widths". A standard letter is 1.0; Shift / Backspace are
30/// usually 1.5; the spacebar is wide (e.g. 5.0 on iOS).
31#[derive(Debug, Clone)]
32struct KeySpec {
33    width_units: f64,
34    cap: KeyCap,
35    action: KeyAction,
36    kind: KeyKind,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40enum KeyKind {
41    /// Letter / digit / punctuation — uses `key_face_*` style tokens.
42    Letter,
43    /// Shift / mode-switch / backspace / dismiss — uses `util_*`
44    /// tokens.
45    Utility,
46    /// Return key — uses `return_*` tokens.
47    Return,
48}
49
50/// A laid-out layer, ready to paint. Captured so the paint and the
51/// hit-test layer-aware logic share a single source of truth.
52pub struct Layout {
53    rows: Vec<Vec<KeySpec>>,
54}
55
56impl Layout {
57    pub fn for_layer(layer: Layer) -> Self {
58        match layer {
59            Layer::Letters => letters_layer(false),
60            Layer::Shifted => letters_layer(true),
61            Layer::Numbers => numbers_layer(),
62            Layer::Symbols => symbols_layer(),
63        }
64    }
65
66    /// Compute the panel height required to render this layout at the
67    /// given viewport width, accounting for vertical padding and row
68    /// gaps.
69    pub fn compute_panel_height(&self, _viewport_width: f64, style: &Style) -> f64 {
70        let rows = self.rows.len() as f64;
71        style.panel_padding_top
72            + style.panel_padding_bottom
73            + rows * style.row_height
74            + (rows - 1.0).max(0.0) * style.key_v_gap
75    }
76
77    /// Paint every key and return the on-screen hit rects (used by the
78    /// tap dispatcher).
79    pub fn paint(
80        &self,
81        ctx: &mut dyn DrawCtx,
82        panel: Rect,
83        style: &Style,
84        active_layer: Layer,
85    ) -> Vec<PaintedKey> {
86        let mut painted = Vec::with_capacity(self.rows.iter().map(|r| r.len()).sum());
87
88        let inner_x = panel.x + style.panel_padding_horizontal;
89        let inner_w = panel.width - 2.0 * style.panel_padding_horizontal;
90
91        // Rows paint top-to-bottom visually. Y-up means the top of the
92        // panel is at panel.y + panel.height; descend by row_height +
93        // gap per row.
94        let mut row_top_y = panel.y + panel.height - style.panel_padding_top;
95
96        for (row_index, row) in self.rows.iter().enumerate() {
97            let row_bottom_y = row_top_y - style.row_height;
98            let total_units: f64 = row.iter().map(|k| k.width_units).sum();
99            let total_gaps = (row.len() as f64 - 1.0).max(0.0) * style.key_h_gap;
100            let key_unit_width = (inner_w - total_gaps) / total_units.max(0.001);
101
102            let mut cursor_x = inner_x;
103            for spec in row.iter() {
104                let kw = spec.width_units * key_unit_width;
105                let rect = Rect::new(cursor_x, row_bottom_y, kw, style.row_height);
106
107                let pressed = false; // pressed visuals come from hover state, painted later
108                paint_key(ctx, rect, spec, pressed, style, active_layer);
109
110                painted.push(PaintedKey {
111                    rect,
112                    action: spec.action,
113                    cap: spec.cap.clone(),
114                });
115                cursor_x += kw + style.key_h_gap;
116            }
117
118            if row_index + 1 < self.rows.len() {
119                row_top_y = row_bottom_y - style.key_v_gap;
120            }
121        }
122
123        painted
124    }
125}
126
127// ---------------------------------------------------------------------------
128// Layer definitions
129// ---------------------------------------------------------------------------
130
131fn letters_layer(shifted: bool) -> Layout {
132    let case = |lower: char, upper: char| if shifted { upper } else { lower };
133
134    let row_keys = |letters: &[(char, char)]| -> Vec<KeySpec> {
135        letters
136            .iter()
137            .map(|(lo, up)| {
138                let c = case(*lo, *up);
139                KeySpec {
140                    width_units: 1.0,
141                    cap: KeyCap::Text(c.to_string()),
142                    action: KeyAction::Char(c),
143                    kind: KeyKind::Letter,
144                }
145            })
146            .collect()
147    };
148
149    let mut rows: Vec<Vec<KeySpec>> = Vec::with_capacity(4);
150    rows.push(row_keys(&[
151        ('q', 'Q'),
152        ('w', 'W'),
153        ('e', 'E'),
154        ('r', 'R'),
155        ('t', 'T'),
156        ('y', 'Y'),
157        ('u', 'U'),
158        ('i', 'I'),
159        ('o', 'O'),
160        ('p', 'P'),
161    ]));
162
163    let row2 = row_keys(&[
164        ('a', 'A'),
165        ('s', 'S'),
166        ('d', 'D'),
167        ('f', 'F'),
168        ('g', 'G'),
169        ('h', 'H'),
170        ('j', 'J'),
171        ('k', 'K'),
172        ('l', 'L'),
173    ]);
174    // iOS pads row 2 with half-key gaps; emulate by adding 0.5-width
175    // invisible spacers at each end. Easier: keep row 2 9 keys wide,
176    // which means the layout engine will auto-fit. The visual offset
177    // emerges from the row-2 letter count being one less than row 1.
178    rows.push(row2);
179
180    let mut row3: Vec<KeySpec> = Vec::with_capacity(11);
181    row3.push(KeySpec {
182        width_units: 1.5,
183        cap: KeyCap::Glyph(KeyGlyph::Shift),
184        action: KeyAction::Switch(if shifted {
185            Layer::Letters
186        } else {
187            Layer::Shifted
188        }),
189        kind: KeyKind::Utility,
190    });
191    row3.extend(row_keys(&[
192        ('z', 'Z'),
193        ('x', 'X'),
194        ('c', 'C'),
195        ('v', 'V'),
196        ('b', 'B'),
197        ('n', 'N'),
198        ('m', 'M'),
199    ]));
200    row3.push(KeySpec {
201        width_units: 1.5,
202        cap: KeyCap::Glyph(KeyGlyph::Backspace),
203        action: KeyAction::Backspace,
204        kind: KeyKind::Utility,
205    });
206    rows.push(row3);
207
208    rows.push(action_row(if shifted {
209        Layer::Shifted
210    } else {
211        Layer::Letters
212    }));
213    Layout { rows }
214}
215
216fn numbers_layer() -> Layout {
217    let digit = |c: char| KeySpec {
218        width_units: 1.0,
219        cap: KeyCap::Text(c.to_string()),
220        action: KeyAction::Char(c),
221        kind: KeyKind::Letter,
222    };
223
224    let mut rows = Vec::with_capacity(4);
225    rows.push(
226        ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']
227            .iter()
228            .map(|c| digit(*c))
229            .collect(),
230    );
231    rows.push(
232        ['-', '/', ':', ';', '(', ')', '$', '&', '@', '"']
233            .iter()
234            .map(|c| digit(*c))
235            .collect(),
236    );
237
238    let mut row3 = Vec::with_capacity(9);
239    row3.push(KeySpec {
240        width_units: 1.5,
241        cap: KeyCap::Text("#+=".to_string()),
242        action: KeyAction::Switch(Layer::Symbols),
243        kind: KeyKind::Utility,
244    });
245    for c in ['.', ',', '?', '!', '\''] {
246        row3.push(digit(c));
247    }
248    row3.push(KeySpec {
249        width_units: 1.5,
250        cap: KeyCap::Glyph(KeyGlyph::Backspace),
251        action: KeyAction::Backspace,
252        kind: KeyKind::Utility,
253    });
254    rows.push(row3);
255
256    rows.push(action_row(Layer::Numbers));
257    Layout { rows }
258}
259
260fn symbols_layer() -> Layout {
261    let sym = |c: char| KeySpec {
262        width_units: 1.0,
263        cap: KeyCap::Text(c.to_string()),
264        action: KeyAction::Char(c),
265        kind: KeyKind::Letter,
266    };
267
268    let mut rows = Vec::with_capacity(4);
269    rows.push(
270        ['[', ']', '{', '}', '#', '%', '^', '*', '+', '=']
271            .iter()
272            .map(|c| sym(*c))
273            .collect(),
274    );
275    rows.push(
276        ['_', '\\', '|', '~', '<', '>', '€', '£', '¥', '·']
277            .iter()
278            .map(|c| sym(*c))
279            .collect(),
280    );
281
282    let mut row3 = Vec::with_capacity(9);
283    row3.push(KeySpec {
284        width_units: 1.5,
285        cap: KeyCap::Text("123".to_string()),
286        action: KeyAction::Switch(Layer::Numbers),
287        kind: KeyKind::Utility,
288    });
289    for c in ['.', ',', '?', '!', '\''] {
290        row3.push(sym(c));
291    }
292    row3.push(KeySpec {
293        width_units: 1.5,
294        cap: KeyCap::Glyph(KeyGlyph::Backspace),
295        action: KeyAction::Backspace,
296        kind: KeyKind::Utility,
297    });
298    rows.push(row3);
299
300    rows.push(action_row(Layer::Symbols));
301    Layout { rows }
302}
303
304/// The bottom row of every layer: mode switcher, space, return, and a
305/// dismiss key. `current` is the layer the row sits under; the mode key
306/// label / target is derived from where the user would expect to go
307/// next (letters → numbers, numbers/symbols → letters, shifted → numbers).
308fn action_row(current: Layer) -> Vec<KeySpec> {
309    let (mode_label, mode_action) = match current {
310        Layer::Letters | Layer::Shifted => ("123", KeyAction::Switch(Layer::Numbers)),
311        Layer::Numbers | Layer::Symbols => ("ABC", KeyAction::Switch(Layer::Letters)),
312    };
313    vec![
314        KeySpec {
315            width_units: 1.5,
316            cap: KeyCap::Text(mode_label.to_string()),
317            action: mode_action,
318            kind: KeyKind::Utility,
319        },
320        KeySpec {
321            width_units: 1.0,
322            cap: KeyCap::Glyph(KeyGlyph::DismissDown),
323            action: KeyAction::Dismiss,
324            kind: KeyKind::Utility,
325        },
326        KeySpec {
327            width_units: 5.0,
328            cap: KeyCap::Text("space".to_string()),
329            action: KeyAction::Space,
330            kind: KeyKind::Letter,
331        },
332        KeySpec {
333            width_units: 2.0,
334            cap: KeyCap::Glyph(KeyGlyph::Return),
335            action: KeyAction::Enter,
336            kind: KeyKind::Return,
337        },
338    ]
339}
340
341// ---------------------------------------------------------------------------
342// Key painting
343// ---------------------------------------------------------------------------
344
345fn paint_key(
346    ctx: &mut dyn DrawCtx,
347    rect: Rect,
348    spec: &KeySpec,
349    pressed: bool,
350    style: &Style,
351    _active_layer: Layer,
352) {
353    let (bg, text_color) = match (spec.kind, pressed) {
354        (KeyKind::Letter, false) => (style.key_face_bg, style.key_face_text),
355        (KeyKind::Letter, true) => (style.key_face_bg_pressed, style.key_face_text_pressed),
356        (KeyKind::Utility, false) => (style.util_key_bg, style.util_key_text),
357        (KeyKind::Utility, true) => (style.util_key_bg_pressed, style.key_face_text_pressed),
358        (KeyKind::Return, false) => (style.return_key_bg, style.return_key_text),
359        (KeyKind::Return, true) => (style.return_key_bg_pressed, style.return_key_text),
360    };
361
362    // Faux 1-pixel drop shadow (Y-up: shadow_offset_y is negative).
363    ctx.set_fill_color(style.key_shadow);
364    ctx.begin_path();
365    ctx.rounded_rect(
366        rect.x,
367        rect.y + style.key_shadow_offset_y,
368        rect.width,
369        rect.height,
370        style.key_corner_radius,
371    );
372    ctx.fill();
373
374    ctx.set_fill_color(bg);
375    ctx.begin_path();
376    ctx.rounded_rect(
377        rect.x,
378        rect.y,
379        rect.width,
380        rect.height,
381        style.key_corner_radius,
382    );
383    ctx.fill();
384
385    ctx.set_fill_color(text_color);
386    let center = Point::new(rect.x + rect.width / 2.0, rect.y + rect.height / 2.0);
387
388    match &spec.cap {
389        KeyCap::Text(text) => {
390            let font_size = if matches!(spec.kind, KeyKind::Letter) && text.chars().count() == 1 {
391                style.letter_font_size
392            } else {
393                style.utility_font_size
394            };
395            ctx.set_font_size(font_size);
396            // Approximate text width: agg-gui's `measure_text` needs an
397            // active font set, which the host installs at startup. If
398            // none is set the text falls back to GSV outlines. Either
399            // way, we just need to draw "near the center" — exact
400            // centering can come once we wire up `measure_text` for
401            // real.
402            let approx_width = text.chars().count() as f64 * font_size * 0.55;
403            ctx.fill_text(
404                text,
405                center.x - approx_width / 2.0,
406                center.y - font_size * 0.3,
407            );
408        }
409        KeyCap::Glyph(glyph) => {
410            paint_glyph(ctx, center, style, *glyph, text_color);
411        }
412    }
413}
414
415fn paint_glyph(
416    ctx: &mut dyn DrawCtx,
417    center: Point,
418    style: &Style,
419    glyph: super::key::KeyGlyph,
420    color: crate::color::Color,
421) {
422    use super::key::KeyGlyph;
423    let r = style.utility_font_size * 0.55;
424    ctx.set_stroke_color(color);
425    ctx.set_fill_color(color);
426    ctx.set_line_width(2.0);
427    match glyph {
428        KeyGlyph::Backspace => {
429            ctx.begin_path();
430            // Tag shape: rectangle with a triangular notch on the left.
431            ctx.move_to(center.x - r, center.y);
432            ctx.line_to(center.x - r * 0.4, center.y + r * 0.7);
433            ctx.line_to(center.x + r * 0.9, center.y + r * 0.7);
434            ctx.line_to(center.x + r * 0.9, center.y - r * 0.7);
435            ctx.line_to(center.x - r * 0.4, center.y - r * 0.7);
436            ctx.close_path();
437            ctx.stroke();
438            // X inside.
439            ctx.begin_path();
440            ctx.move_to(center.x - r * 0.05, center.y - r * 0.35);
441            ctx.line_to(center.x + r * 0.55, center.y + r * 0.35);
442            ctx.move_to(center.x - r * 0.05, center.y + r * 0.35);
443            ctx.line_to(center.x + r * 0.55, center.y - r * 0.35);
444            ctx.stroke();
445        }
446        KeyGlyph::Shift => {
447            ctx.begin_path();
448            ctx.move_to(center.x, center.y + r);
449            ctx.line_to(center.x - r, center.y);
450            ctx.line_to(center.x - r * 0.4, center.y);
451            ctx.line_to(center.x - r * 0.4, center.y - r * 0.6);
452            ctx.line_to(center.x + r * 0.4, center.y - r * 0.6);
453            ctx.line_to(center.x + r * 0.4, center.y);
454            ctx.line_to(center.x + r, center.y);
455            ctx.close_path();
456            ctx.stroke();
457        }
458        KeyGlyph::DismissDown => {
459            ctx.begin_path();
460            ctx.move_to(center.x - r, center.y + r * 0.3);
461            ctx.line_to(center.x, center.y - r * 0.3);
462            ctx.line_to(center.x + r, center.y + r * 0.3);
463            ctx.stroke();
464            ctx.begin_path();
465            ctx.move_to(center.x - r, center.y - r * 0.6);
466            ctx.line_to(center.x + r, center.y - r * 0.6);
467            ctx.stroke();
468        }
469        KeyGlyph::Return => {
470            ctx.begin_path();
471            ctx.move_to(center.x + r, center.y + r * 0.6);
472            ctx.line_to(center.x + r, center.y - r * 0.2);
473            ctx.line_to(center.x - r * 0.5, center.y - r * 0.2);
474            ctx.stroke();
475            ctx.begin_path();
476            ctx.move_to(center.x - r * 0.5, center.y + r * 0.3);
477            ctx.line_to(center.x - r, center.y - r * 0.2);
478            ctx.line_to(center.x - r * 0.5, center.y - r * 0.7);
479            ctx.stroke();
480        }
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    #[test]
489    fn letters_layer_has_four_rows() {
490        let l = Layout::for_layer(Layer::Letters);
491        assert_eq!(l.rows.len(), 4);
492    }
493
494    #[test]
495    fn shift_key_switches_layer() {
496        let l = Layout::for_layer(Layer::Letters);
497        let row3 = &l.rows[2];
498        let shift = &row3[0];
499        match shift.action {
500            KeyAction::Switch(Layer::Shifted) => {}
501            other => panic!("expected Switch(Shifted) on row3[0], got {other:?}"),
502        }
503    }
504
505    #[test]
506    fn shifted_layer_emits_uppercase_chars() {
507        let l = Layout::for_layer(Layer::Shifted);
508        // First row, first key: should be 'Q'.
509        let q = &l.rows[0][0];
510        match q.action {
511            KeyAction::Char('Q') => {}
512            other => panic!("expected Char('Q'), got {other:?}"),
513        }
514    }
515
516    #[test]
517    fn numbers_layer_includes_digits() {
518        let l = Layout::for_layer(Layer::Numbers);
519        let chars: Vec<char> = l.rows[0]
520            .iter()
521            .filter_map(|k| match k.action {
522                KeyAction::Char(c) => Some(c),
523                _ => None,
524            })
525            .collect();
526        for d in ['1', '2', '3', '0'] {
527            assert!(chars.contains(&d), "missing digit {d} in numbers row 1");
528        }
529    }
530}