Files
punktfunk/crates/pf-console-ui/src/widgets.rs
T
enricobuehler 180ac3aa61
apple / swift (push) Successful in 1m6s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
audit / bun-audit (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
decky / build-publish (push) Successful in 25s
flatpak / build-publish (push) Successful in 4m31s
feat(console): full gamepad shell — hosts, PIN pairing, settings, OSK, screen transitions
The Skia console UI grows from the single library coverflow into a complete
couch shell (Apple gamepad-UI parity), so a Deck/gamescope session is
self-sufficient end to end:

- Home: host carousel (saved + discovered + Add Host tile) with presence
  pips, paired locks, wake & connect; A/Y/X/B per the Apple launcher.
- In-console SPAKE2 PIN pairing (previously needed the Decky plugin or a
  desktop), add-host with a controller keyboard, couch settings over the
  shared Settings store, wake-on-LAN overlay with retry, cancelable dials.
- Shell chrome: screen stack with push/pop entrance/exit choreography
  (slide + fade + recede; aurora↔form backdrop crossfade), per-pad button
  glyphs (PS shapes vs ABXY, keycaps with no pad), menu haptics, toasts,
  embedded Geist typography, in-stream start banner.
- Steam Deck: our OSK never draws — fields start SDL text input (the new
  Overlay::text_input_active hook) and Steam's keyboard types; a hint chip
  points at STEAM + X. Other Linux gets the full gamepad keyboard tray.
- punktfunk-session: bare --browse opens Home; --browse host opens that
  host's library with Home behind it (Decky launches keep working, B now
  pops instead of quitting). Service threads run discovery, 10 s probes,
  pairing, wake loops, fetches, and known-hosts persistence.
- Presenter contract: Launch actions carry the host, CancelConnect never
  engages a won race, pad kind/list ride FrameCtx, menu events flow while
  dialing so B can cancel.

Every screen renders to PNG on CPU raster for review
(PF_CONSOLE_DUMP=<dir> cargo test -p pf-console-ui -- --ignored dump).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 08:46:49 +02:00

688 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String>,
/// 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<String>, 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<String>, 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<f64>,
}
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<MenuPulse>) {
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<MenuPulse> {
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<char> = 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<Key>] {
use std::sync::OnceLock;
static ROWS: OnceLock<Vec<Vec<Key>>> = OnceLock::new();
ROWS.get_or_init(|| {
let chars = |s: &str| s.chars().map(Key::Char).collect::<Vec<_>>();
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<MenuPulse>) {
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"));
}
}