//! The console's focusable widgets: the vertical menu list (settings / add-host / pair //! screens) and the controller-driven on-screen keyboard — ports of the Swift //! `GamepadMenuList` / `GamepadKeyboard`, immediate-mode: the WIDGET owns cursor, //! springs and scroll, the SCREEN owns the domain (row content and what an activation //! means), and every frame the screen hands the widget fresh row specs. use crate::anim::{approach, Spring, TRAY_C, TRAY_K}; use crate::library::{BUMP_C, BUMP_K}; use crate::theme::{brand, white, Fonts, PanelStroke, BRAND, FAINT, W, WHITE}; use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse}; use skia_safe::{Canvas, Paint, Path, RRect, Rect}; // --- Menu list ----------------------------------------------------------------------------- /// What a menu-list consumed event means for the owning screen. #[derive(Debug, PartialEq, Eq)] pub(crate) enum ListMsg { None, /// Left/right on the focused row (the screen returns whether the value changed). Adjust(i32), /// A on the focused row. Activate, } /// One row's look, rebuilt by the screen each frame (never stale). pub(crate) struct RowSpec { /// Section header drawn above this row (the first row of each group carries it). pub header: Option<&'static str>, pub label: String, /// `None` = an action row (its label draws centered, brand-tinted). pub value: Option, /// Placeholder styling for an empty field's value. pub value_dim: bool, /// The live-edit caret after the value (this row is what the keyboard types into). pub caret: bool, /// Show ‹ › chevrons while focused (left/right steps the value). pub adjustable: bool, /// Action rows render dimmed when not yet actionable. pub enabled: bool, } impl RowSpec { pub(crate) fn field(label: impl Into, value: String, placeholder: &str) -> RowSpec { let empty = value.is_empty(); RowSpec { header: None, label: label.into(), value: Some(if empty { placeholder.to_string() } else { value }), value_dim: empty, caret: false, adjustable: false, enabled: true, } } pub(crate) fn action(label: impl Into, enabled: bool) -> RowSpec { RowSpec { header: None, label: label.into(), value: None, value_dim: false, caret: false, adjustable: false, enabled, } } } pub(crate) const ROW_H: f64 = 50.0; const ROW_GAP: f64 = 6.0; const HEADER_H: f64 = 34.0; pub(crate) const ROW_MAX_W: f64 = 620.0; /// The focus list: authoritative cursor, spring recoil at the ends, a scroll offset /// that chases the focused row, and a per-row focus amount for the scale/tint ease. pub(crate) struct MenuList { pub cursor: usize, bump: Spring, scroll: f64, focus: Vec, } impl MenuList { pub(crate) fn new() -> MenuList { MenuList { cursor: 0, bump: Spring::rest(0.0), scroll: 0.0, focus: Vec::new(), } } /// Route a menu event. Up/down move focus (Boundary = recoil), left/right become /// [`ListMsg::Adjust`], A becomes [`ListMsg::Activate`]. B is the SCREEN's. pub(crate) fn menu(&mut self, ev: MenuEvent, len: usize) -> (ListMsg, Option) { match ev { MenuEvent::Move(MenuDir::Up) => (ListMsg::None, self.step(-1, len)), MenuEvent::Move(MenuDir::Down) => (ListMsg::None, self.step(1, len)), MenuEvent::Move(MenuDir::Left) => (ListMsg::Adjust(-1), None), MenuEvent::Move(MenuDir::Right) => (ListMsg::Adjust(1), None), MenuEvent::Confirm => (ListMsg::Activate, Some(MenuPulse::Confirm)), _ => (ListMsg::None, None), } } fn step(&mut self, delta: i32, len: usize) -> Option { let target = self.cursor as i32 + delta; if len == 0 || target < 0 || target >= len as i32 { // Refused at an end: the dull thud plus a short vertical recoil. self.bump = Spring { pos: -14.0 * f64::from(delta.signum()), vel: 0.0, }; return Some(MenuPulse::Boundary); } self.cursor = target as usize; Some(MenuPulse::Move) } /// Render the rows into `rect`. `active` = this list owns focus (a keyboard tray on /// top parks it — rows keep their look but the focus ring rests). #[allow(clippy::too_many_arguments)] pub(crate) fn render( &mut self, canvas: &Canvas, rect: Rect, rows: &[RowSpec], fonts: &Fonts, k: f64, dt: f64, active: bool, ) { self.focus.resize(rows.len(), 0.0); for (i, f) in self.focus.iter_mut().enumerate() { let target = if active && i == self.cursor { 1.0 } else { 0.0 }; *f = approach(*f, target, dt, 0.06); } self.bump.step(0.0, BUMP_K, BUMP_C, dt); self.bump.settle(0.0, 0.3, 4.0); // Row tops (design units) incl. headers, so scroll math and drawing agree. let mut tops = Vec::with_capacity(rows.len()); let mut y = 0.0; for row in rows { if row.header.is_some() { y += HEADER_H; } tops.push(y); y += ROW_H + ROW_GAP; } let content_h = (y - ROW_GAP).max(0.0) * k; let view_h = f64::from(rect.height()); // The scroll chases the focused row into the middle band, clamped to content. let focused_center = tops.get(self.cursor).map_or(0.0, |t| (t + ROW_H / 2.0) * k); let target = (focused_center - view_h / 2.0).clamp(0.0, (content_h - view_h).max(0.0)); self.scroll = approach(self.scroll, target, dt, 0.08); let row_w = (ROW_MAX_W * k).min(f64::from(rect.width()) - 48.0 * k); let x0 = f64::from(rect.left) + (f64::from(rect.width()) - row_w) / 2.0; canvas.save(); canvas.clip_rect(rect, None, true); for (i, row) in rows.iter().enumerate() { let f = self.focus[i]; let top = f64::from(rect.top) + tops[i] * k - self.scroll + self.bump.pos * k; if top + ROW_H * k < f64::from(rect.top) - 8.0 * k || top > f64::from(rect.bottom) + 8.0 * k { continue; } if let Some(header) = row.header { fonts.draw_tracked( canvas, &header.to_uppercase(), x0 + 16.0 * k, top - 12.0 * k, W::SemiBold, 12.0 * k, 1.4 * k, white(0.45), ); } // Focus scale eases 0.98 → 1.0 about the row center. let scale = 0.98 + 0.02 * f; let (cx, cy) = (x0 + row_w / 2.0, top + ROW_H * k / 2.0); canvas.save(); canvas.translate((cx as f32, cy as f32)); canvas.scale((scale as f32, scale as f32)); canvas.translate((-cx as f32, -cy as f32)); let r = Rect::from_xywh(x0 as f32, top as f32, row_w as f32, (ROW_H * k) as f32); let stroke = if row.caret { PanelStroke::Brand(0.7) } else { PanelStroke::Plain(0.06 + 0.22 * f as f32) }; let tint = if row.caret { Some(brand(0.30)) } else if f > 0.01 { Some(brand(0.30 * f as f32)) } else { None }; crate::theme::panel(canvas, r, 14.0, tint, stroke, k as f32); let baseline = cy + 16.0 * k * 0.36; if row.value.is_none() { // Action row: centered label, brand when actionable. let color = if row.enabled { BRAND } else { FAINT }; let tw = fonts.measure(&row.label, W::SemiBold, 16.0 * k) as f64; fonts.draw( canvas, &row.label, cx - tw / 2.0, baseline, W::SemiBold, 16.0 * k, color, ); } else { fonts.draw( canvas, &row.label, x0 + 16.0 * k, baseline, W::SemiBold, 16.0 * k, WHITE, ); let value = row.value.as_deref().unwrap_or_default(); let vcolor = if row.value_dim { FAINT } else if f > 0.5 { WHITE } else { white(0.6 + 0.4 * f as f32) }; let chevron_w = if row.adjustable { 18.0 * k } else { 0.0 }; let caret_w = if row.caret { 8.0 * k } else { 0.0 }; let vmax = row_w * 0.55; let vw = (fonts.measure(value, W::Medium, 15.0 * k) as f64).min(vmax); let vx = x0 + row_w - 16.0 * k - chevron_w - caret_w - vw; // Head-truncate: keep the END of a long address visible while typing. let shown = truncate_head(fonts, value, W::Medium, 15.0 * k, vmax); fonts.draw(canvas, &shown, vx, baseline, W::Medium, 15.0 * k, vcolor); if row.caret { canvas.draw_rect( Rect::from_xywh( (vx + vw + 3.0 * k) as f32, (cy - 9.0 * k) as f32, (2.0 * k) as f32, (18.0 * k) as f32, ), &Paint::new(BRAND, None), ); } if row.adjustable && f > 0.01 { let alpha = 0.6 * f as f32; chevron(canvas, vx - 11.0 * k, cy, 4.0 * k, true, alpha); chevron(canvas, x0 + row_w - 16.0 * k, cy, 4.0 * k, false, alpha); } } canvas.restore(); } canvas.restore(); } } /// Middle-of-nowhere helper: drop chars from the FRONT until the tail fits. fn truncate_head(fonts: &Fonts, text: &str, w: W, size: f64, max_w: f64) -> String { if f64::from(fonts.measure(text, w, size)) <= max_w { return text.to_string(); } let mut s: Vec = text.chars().collect(); while s.len() > 1 { s.remove(0); let candidate: String = std::iter::once('…').chain(s.iter().copied()).collect(); if f64::from(fonts.measure(&candidate, w, size)) <= max_w { return candidate; } } "…".into() } fn chevron(canvas: &Canvas, x: f64, cy: f64, r: f64, left: bool, alpha: f32) { let dir = if left { -1.0 } else { 1.0 }; let mut p = Paint::new(white(alpha), None); p.set_style(skia_safe::PaintStyle::Stroke); p.set_stroke_width((1.8 * r / 4.0) as f32); p.set_stroke_cap(skia_safe::PaintCap::Round); p.set_anti_alias(true); let mut path = Path::new(); path.move_to(((x - dir * r / 2.0) as f32, (cy - r) as f32)); path.line_to(((x + dir * r / 2.0) as f32, cy as f32)); path.line_to(((x - dir * r / 2.0) as f32, (cy + r) as f32)); canvas.draw_path(&path, &p); } // --- On-screen keyboard ---------------------------------------------------------------------- /// What the keyboard may type per field (backspace always works). #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum Charset { Free, /// Addresses/hostnames — everything but whitespace. Hostname, Digits, } pub(crate) fn permits(charset: Charset, ch: char) -> bool { match charset { Charset::Free => true, Charset::Hostname => !ch.is_whitespace(), Charset::Digits => ch.is_ascii_digit(), } } #[derive(Debug, PartialEq, Eq)] pub(crate) enum KeyMsg { None, Type(char), Backspace, Done, } #[derive(Clone, Copy, PartialEq, Eq)] enum Key { Char(char), Space, Backspace, Done, } /// Digits first (addresses/ports), then letters; the last char column carries the /// hostname/address punctuation — the Swift grid, verbatim. fn key_rows() -> &'static [Vec] { use std::sync::OnceLock; static ROWS: OnceLock>> = OnceLock::new(); ROWS.get_or_init(|| { let chars = |s: &str| s.chars().map(Key::Char).collect::>(); vec![ chars("1234567890"), chars("qwertyuiop"), chars("asdfghjkl-"), chars("zxcvbnm._:"), vec![Key::Space, Key::Backspace, Key::Done], ] }) } /// The controller keyboard: a fixed key grid in a bottom tray. Dpad/stick moves the key /// cursor, A types, X backspaces, B/Y/Done confirms. Edits apply live — closing IS done. pub(crate) struct Keyboard { row: usize, col: usize, /// Tray slide-in (0 hidden → 1 seated), the Swift `.spring(0.32, 0.86)`. tray: Spring, key_flash: f64, } impl Keyboard { pub(crate) fn new() -> Keyboard { Keyboard { row: 1, // opens on "q" col: 0, tray: Spring::rest(0.0), key_flash: 0.0, } } /// Route a menu event; the SCREEN applies `Type`/`Backspace` to its field (charset /// checks included — a refusal comes back as a boundary pulse from the screen). pub(crate) fn menu(&mut self, ev: MenuEvent) -> (KeyMsg, Option) { let rows = key_rows(); match ev { MenuEvent::Move(dir) => { let (mut row, mut col) = (self.row as i32, self.col as i32); match dir { MenuDir::Left => col -= 1, MenuDir::Right => col += 1, MenuDir::Up | MenuDir::Down => { let next = row + if dir == MenuDir::Down { 1 } else { -1 }; if next < 0 || next >= rows.len() as i32 { return (KeyMsg::None, Some(MenuPulse::Boundary)); } // Map the column proportionally between rows of different // widths (Done goes up to the rightmost letters, not "e"). let from = (rows[row as usize].len() - 1).max(1) as f64; let to = (rows[next as usize].len() - 1) as f64; col = (col as f64 * to / from).round() as i32; row = next; } } if row < 0 || row >= rows.len() as i32 || col < 0 || col >= rows[row as usize].len() as i32 { return (KeyMsg::None, Some(MenuPulse::Boundary)); } self.row = row as usize; self.col = col as usize; (KeyMsg::None, Some(MenuPulse::Move)) } MenuEvent::Confirm => { self.key_flash = 1.0; match rows[self.row][self.col] { Key::Char(c) => (KeyMsg::Type(c), None), Key::Space => (KeyMsg::Type(' '), None), Key::Backspace => (KeyMsg::Backspace, None), Key::Done => (KeyMsg::Done, Some(MenuPulse::Confirm)), } } MenuEvent::Tertiary => (KeyMsg::Backspace, None), MenuEvent::Secondary | MenuEvent::Back => (KeyMsg::Done, Some(MenuPulse::Confirm)), _ => (KeyMsg::None, None), } } /// Advance the tray spring toward shown/hidden. Returns the current seat 0..1 — /// at exactly 0 while hidden, so the caller can skip rendering. pub(crate) fn seat(&mut self, shown: bool, dt: f64) -> f64 { self.tray .step(if shown { 1.0 } else { 0.0 }, TRAY_K, TRAY_C, dt); self.tray.settle(if shown { 1.0 } else { 0.0 }, 0.001, 0.01); self.key_flash = approach(self.key_flash, 0.0, dt, 0.10); self.tray.pos.clamp(0.0, 1.2) } /// The tray's design height (pre-`k`), for layout above it. pub(crate) fn tray_height() -> f64 { 5.0 * 42.0 + 4.0 * 7.0 + 2.0 * 14.0 } /// Render the tray with its bottom edge at `bottom`, horizontally centered, slid by /// `seat` (0..1). The caller clips nothing — the tray rises from below the screen. #[allow(clippy::too_many_arguments)] pub(crate) fn render( &self, canvas: &Canvas, fonts: &Fonts, w: f64, bottom: f64, seat: f64, k: f64, ) { let rows = key_rows(); let tray_w = (560.0 * k).min(w - 32.0 * k); let tray_h = Self::tray_height() * k; let x0 = (w - tray_w) / 2.0; let y0 = bottom - tray_h * seat; let rect = Rect::from_xywh(x0 as f32, y0 as f32, tray_w as f32, tray_h as f32); crate::theme::panel( canvas, rect, 22.0, Some(skia_safe::Color4f::new(0.05, 0.045, 0.09, 0.55)), PanelStroke::Plain(0.12), k as f32, ); let pad = 14.0 * k; let gap = 7.0 * k; let key_h = 42.0 * k; for (r, row) in rows.iter().enumerate() { let n = row.len() as f64; let key_w = (tray_w - 2.0 * pad - (n - 1.0) * gap) / n; let y = y0 + pad + r as f64 * (key_h + gap); for (c, key) in row.iter().enumerate() { let x = x0 + pad + c as f64 * (key_w + gap); let focused = r == self.row && c == self.col; let kr = Rect::from_xywh(x as f32, y as f32, key_w as f32, key_h as f32); let fill = if focused { let mut b = BRAND; if self.key_flash > 0.02 { // A just-typed key flashes brighter, then eases back. let f = self.key_flash as f32; b = skia_safe::Color4f::new( b.r + (1.0 - b.r) * 0.5 * f, b.g + (1.0 - b.g) * 0.5 * f, b.b, 1.0, ); } b } else { white(0.08) }; canvas.draw_rrect( RRect::new_rect_xy(kr, (9.0 * k) as f32, (9.0 * k) as f32), &Paint::new(fill, None), ); let ink = if focused { skia_safe::Color4f::new(0.0, 0.0, 0.0, 1.0) } else { WHITE }; let (cx, cy) = (x + key_w / 2.0, y + key_h / 2.0); match key { Key::Char(ch) => { let s = ch.to_string(); let size = 18.0 * k; let tw = fonts.measure(&s, W::Medium, size) as f64; fonts.draw( canvas, &s, cx - tw / 2.0, cy + size * 0.36, W::Medium, size, ink, ); } Key::Space => draw_space_icon(canvas, cx, cy, k, ink), Key::Backspace => draw_backspace_icon(canvas, cx, cy, k, ink), Key::Done => { let size = 15.0 * k; let label = "Done"; let tw = fonts.measure(label, W::SemiBold, size) as f64; let check_w = 14.0 * k; let total = check_w + 6.0 * k + tw; draw_check(canvas, cx - total / 2.0 + check_w / 2.0, cy, k, ink); fonts.draw( canvas, label, cx - total / 2.0 + check_w + 6.0 * k, cy + size * 0.36, W::SemiBold, size, ink, ); } } } } } } fn stroke_paint(ink: skia_safe::Color4f, width: f32) -> Paint { let mut p = Paint::new(ink, None); p.set_style(skia_safe::PaintStyle::Stroke); p.set_stroke_width(width); p.set_stroke_cap(skia_safe::PaintCap::Round); p.set_stroke_join(skia_safe::PaintJoin::Round); p.set_anti_alias(true); p } fn draw_space_icon(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Color4f) { // ⎵ — an underline bracket. let (w, h) = (16.0 * k, 5.0 * k); let p = stroke_paint(ink, (1.6 * k) as f32); let mut path = Path::new(); path.move_to(((cx - w / 2.0) as f32, (cy - h / 2.0) as f32)); path.line_to(((cx - w / 2.0) as f32, (cy + h / 2.0) as f32)); path.line_to(((cx + w / 2.0) as f32, (cy + h / 2.0) as f32)); path.line_to(((cx + w / 2.0) as f32, (cy - h / 2.0) as f32)); canvas.draw_path(&path, &p); } fn draw_backspace_icon(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Color4f) { // ⌫ — the left-pointing cap with an ✕ inside. let (w, h) = (18.0 * k, 12.0 * k); let nose = 6.0 * k; let p = stroke_paint(ink, (1.6 * k) as f32); let (l, r, t, b) = (cx - w / 2.0, cx + w / 2.0, cy - h / 2.0, cy + h / 2.0); let mut path = Path::new(); path.move_to(((l + nose) as f32, t as f32)); path.line_to((r as f32, t as f32)); path.line_to((r as f32, b as f32)); path.line_to(((l + nose) as f32, b as f32)); path.line_to((l as f32, cy as f32)); path.close(); canvas.draw_path(&path, &p); let (xc, xr) = (cx + nose / 2.0, 2.6 * k); canvas.draw_line( ((xc - xr) as f32, (cy - xr) as f32), ((xc + xr) as f32, (cy + xr) as f32), &p, ); canvas.draw_line( ((xc - xr) as f32, (cy + xr) as f32), ((xc + xr) as f32, (cy - xr) as f32), &p, ); } fn draw_check(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Color4f) { let p = stroke_paint(ink, (1.8 * k) as f32); let r = 5.0 * k; let mut path = Path::new(); path.move_to(((cx - r) as f32, cy as f32)); path.line_to(((cx - r * 0.25) as f32, (cy + r * 0.7) as f32)); path.line_to(((cx + r) as f32, (cy - r * 0.7) as f32)); canvas.draw_path(&path, &p); } #[cfg(test)] mod tests { use super::*; fn kb() -> Keyboard { Keyboard::new() } #[test] fn keyboard_opens_on_q_and_types() { let mut k = kb(); let (msg, _) = k.menu(MenuEvent::Confirm); assert_eq!(msg, KeyMsg::Type('q')); } #[test] fn keyboard_proportional_column_mapping() { let mut k = kb(); // Walk to the digits row's far right ("0", col 9), then down to the bottom row: // col must land on Done (rightmost of 3), not clamp to some middle key. for _ in 0..9 { k.menu(MenuEvent::Move(MenuDir::Right)); } k.menu(MenuEvent::Move(MenuDir::Up)); // digits row assert_eq!((k.row, k.col), (0, 9)); for _ in 0..4 { k.menu(MenuEvent::Move(MenuDir::Down)); } assert_eq!(k.row, 4); assert_eq!(k.col, 2, "rightmost column maps onto Done"); let (msg, _) = k.menu(MenuEvent::Confirm); assert_eq!(msg, KeyMsg::Done); } #[test] fn keyboard_edges_refuse() { let mut k = kb(); let (_, pulse) = k.menu(MenuEvent::Move(MenuDir::Left)); assert!(matches!(pulse, Some(MenuPulse::Boundary))); // X backspaces, B/Y are done — from anywhere. assert_eq!(k.menu(MenuEvent::Tertiary).0, KeyMsg::Backspace); assert_eq!(k.menu(MenuEvent::Secondary).0, KeyMsg::Done); assert_eq!(k.menu(MenuEvent::Back).0, KeyMsg::Done); } #[test] fn charsets() { assert!(permits(Charset::Digits, '7')); assert!(!permits(Charset::Digits, 'a')); assert!(permits(Charset::Hostname, '-')); assert!(!permits(Charset::Hostname, ' ')); assert!(permits(Charset::Free, ' ')); } #[test] fn menu_list_moves_and_recoils() { let mut l = MenuList::new(); assert!(matches!( l.menu(MenuEvent::Move(MenuDir::Down), 3).1, Some(MenuPulse::Move) )); assert_eq!(l.cursor, 1); assert!(matches!( l.menu(MenuEvent::Move(MenuDir::Up), 3).1, Some(MenuPulse::Move) )); assert!(matches!( l.menu(MenuEvent::Move(MenuDir::Up), 3).1, Some(MenuPulse::Boundary) )); assert!(l.bump.pos.abs() > 1.0, "recoil engaged"); assert_eq!( l.menu(MenuEvent::Move(MenuDir::Right), 3).0, ListMsg::Adjust(1) ); assert_eq!(l.menu(MenuEvent::Confirm, 3).0, ListMsg::Activate); } #[test] fn head_truncation_keeps_the_tail() { let fonts = crate::theme::build_fonts().unwrap(); let t = truncate_head(&fonts, "verylonghostname.local", W::Medium, 15.0, 60.0); assert!(t.starts_with('…')); assert!(t.ends_with("local")); } }