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
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>
688 lines
25 KiB
Rust
688 lines
25 KiB
Rust
//! 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"));
|
||
}
|
||
}
|