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>
355 lines
11 KiB
Rust
355 lines
11 KiB
Rust
//! Controller button glyphs and the hint bar — the "controls legend" pill every console
|
|
//! screen pins bottom-leading (the Apple client resolves real SF glyphs per pad via
|
|
//! `sfSymbolsName`; here the shapes are drawn). The style follows the ACTIVE pad:
|
|
//! PlayStation controllers read ✕/○/□/△, everything else reads ABXY letters, and with
|
|
//! no pad at all the legend swaps to keyboard keycaps — the console stays fully
|
|
//! drivable either way.
|
|
|
|
use crate::theme::{white, Fonts, W};
|
|
use punktfunk_core::config::GamepadPref;
|
|
use skia_safe::{Canvas, Color4f, Paint, Path, Point, RRect, Rect};
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub(crate) enum GlyphStyle {
|
|
/// ABXY letter badges (Xbox / Steam Deck / generic).
|
|
Letters,
|
|
/// PlayStation face shapes (DualSense / DualShock 4).
|
|
Shapes,
|
|
/// No controller — keyboard keycaps.
|
|
Keyboard,
|
|
}
|
|
|
|
impl GlyphStyle {
|
|
pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle {
|
|
match pref {
|
|
Some(GamepadPref::DualSense | GamepadPref::DualShock4) => GlyphStyle::Shapes,
|
|
Some(_) => GlyphStyle::Letters,
|
|
None => GlyphStyle::Keyboard,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What a hint's glyph depicts. `Key` renders a literal keycap chip in any style (used
|
|
/// for keyboard fallbacks and the Deck's "Steam + X" keyboard chord).
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum HintKey {
|
|
Confirm,
|
|
Back,
|
|
Secondary,
|
|
Tertiary,
|
|
Shoulders,
|
|
/// ◀ ▶ — left/right adjusts the focused value.
|
|
Adjust,
|
|
Key(&'static str),
|
|
}
|
|
|
|
pub(crate) struct Hint {
|
|
pub key: HintKey,
|
|
pub label: String,
|
|
}
|
|
|
|
impl Hint {
|
|
pub(crate) fn new(key: HintKey, label: impl Into<String>) -> Hint {
|
|
Hint {
|
|
key,
|
|
label: label.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
const LABEL_SIZE: f64 = 14.0;
|
|
const BADGE_D: f64 = 22.0; // face-button badge diameter
|
|
|
|
/// The hint bar pill, anchored at its BOTTOM-LEFT corner. Returns the pill's size.
|
|
pub(crate) fn hint_bar(
|
|
canvas: &Canvas,
|
|
fonts: &Fonts,
|
|
hints: &[Hint],
|
|
style: GlyphStyle,
|
|
x: f64,
|
|
bottom: f64,
|
|
k: f64,
|
|
) -> (f64, f64) {
|
|
if hints.is_empty() {
|
|
return (0.0, 0.0);
|
|
}
|
|
let pad = 13.0 * k;
|
|
let gap_hint = 18.0 * k;
|
|
let gap_glyph = 7.0 * k;
|
|
let widths: Vec<(f64, f64)> = hints
|
|
.iter()
|
|
.map(|h| {
|
|
(
|
|
glyph_width(fonts, h.key, style, k),
|
|
fonts.measure(&h.label, W::SemiBold, LABEL_SIZE * k) as f64,
|
|
)
|
|
})
|
|
.collect();
|
|
let content_w: f64 = widths.iter().map(|(g, l)| g + gap_glyph + l).sum::<f64>()
|
|
+ gap_hint * (hints.len() - 1) as f64;
|
|
let h = BADGE_D * k + 2.0 * pad;
|
|
let w = content_w + 2.0 * pad;
|
|
let rect = Rect::from_xywh((x) as f32, (bottom - h) as f32, w as f32, h as f32);
|
|
canvas.draw_rrect(
|
|
RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32),
|
|
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.30), None),
|
|
);
|
|
canvas.draw_rrect(
|
|
RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32),
|
|
&Paint::new(white(0.06), None),
|
|
);
|
|
let mut sp = Paint::new(white(0.12), None);
|
|
sp.set_style(skia_safe::PaintStyle::Stroke);
|
|
sp.set_stroke_width(1.0);
|
|
sp.set_anti_alias(true);
|
|
canvas.draw_rrect(
|
|
RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32),
|
|
&sp,
|
|
);
|
|
|
|
let cy = bottom - h / 2.0;
|
|
let mut pen = x + pad;
|
|
for (hint, (gw, lw)) in hints.iter().zip(&widths) {
|
|
draw_glyph(canvas, fonts, hint.key, style, pen, cy, k);
|
|
pen += gw + gap_glyph;
|
|
// Baseline centered on the badge (cap height ≈ 0.72 em for Geist).
|
|
fonts.draw(
|
|
canvas,
|
|
&hint.label,
|
|
pen,
|
|
cy + LABEL_SIZE * k * 0.36,
|
|
W::SemiBold,
|
|
LABEL_SIZE * k,
|
|
white(0.85),
|
|
);
|
|
pen += lw + gap_hint;
|
|
}
|
|
(w, h)
|
|
}
|
|
|
|
fn glyph_width(fonts: &Fonts, key: HintKey, style: GlyphStyle, k: f64) -> f64 {
|
|
match resolved(key, style) {
|
|
Resolved::Badge(_) | Resolved::Adjust => BADGE_D * k,
|
|
Resolved::Shoulders => 2.0 * shoulder_w(fonts, k) + 3.0 * k,
|
|
Resolved::Key(text) => keycap_w(fonts, text, k),
|
|
}
|
|
}
|
|
|
|
fn shoulder_w(fonts: &Fonts, k: f64) -> f64 {
|
|
fonts.measure("L1", W::SemiBold, 10.0 * k) as f64 + 10.0 * k
|
|
}
|
|
|
|
fn keycap_w(fonts: &Fonts, text: &str, k: f64) -> f64 {
|
|
fonts.measure(text, W::SemiBold, 11.0 * k) as f64 + 14.0 * k
|
|
}
|
|
|
|
/// A hint key resolved against the glyph style.
|
|
enum Resolved {
|
|
/// A face-button badge: the letter (Letters) or shape index (Shapes).
|
|
Badge(Face),
|
|
Shoulders,
|
|
Adjust,
|
|
Key(&'static str),
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum Face {
|
|
A,
|
|
B,
|
|
X,
|
|
Y,
|
|
}
|
|
|
|
fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
|
|
if style == GlyphStyle::Keyboard {
|
|
return match key {
|
|
HintKey::Confirm => Resolved::Key("Enter"),
|
|
HintKey::Back => Resolved::Key("Esc"),
|
|
HintKey::Secondary => Resolved::Key("Y"),
|
|
HintKey::Tertiary => Resolved::Key("X"),
|
|
HintKey::Shoulders => Resolved::Key("PgUp/PgDn"),
|
|
HintKey::Adjust => Resolved::Adjust,
|
|
HintKey::Key(t) => Resolved::Key(t),
|
|
};
|
|
}
|
|
match key {
|
|
HintKey::Confirm => Resolved::Badge(Face::A),
|
|
HintKey::Back => Resolved::Badge(Face::B),
|
|
HintKey::Tertiary => Resolved::Badge(Face::X),
|
|
HintKey::Secondary => Resolved::Badge(Face::Y),
|
|
HintKey::Shoulders => Resolved::Shoulders,
|
|
HintKey::Adjust => Resolved::Adjust,
|
|
HintKey::Key(t) => Resolved::Key(t),
|
|
}
|
|
}
|
|
|
|
/// Draw one glyph with its LEFT edge at `x`, vertically centered on `cy`.
|
|
fn draw_glyph(
|
|
canvas: &Canvas,
|
|
fonts: &Fonts,
|
|
key: HintKey,
|
|
style: GlyphStyle,
|
|
x: f64,
|
|
cy: f64,
|
|
k: f64,
|
|
) {
|
|
match resolved(key, style) {
|
|
Resolved::Badge(face) => {
|
|
let r = BADGE_D * k / 2.0;
|
|
let center = Point::new((x + r) as f32, cy as f32);
|
|
canvas.draw_circle(center, r as f32, &Paint::new(white(0.10), None));
|
|
let mut ring = Paint::new(white(0.32), None);
|
|
ring.set_style(skia_safe::PaintStyle::Stroke);
|
|
ring.set_stroke_width((1.2 * k) as f32);
|
|
ring.set_anti_alias(true);
|
|
canvas.draw_circle(center, r as f32, &ring);
|
|
if style == GlyphStyle::Shapes {
|
|
draw_ps_shape(canvas, face, center, (4.6 * k) as f32, (1.7 * k) as f32);
|
|
} else {
|
|
let letter = match face {
|
|
Face::A => "A",
|
|
Face::B => "B",
|
|
Face::X => "X",
|
|
Face::Y => "Y",
|
|
};
|
|
let size = 12.0 * k;
|
|
let w = fonts.measure(letter, W::SemiBold, size) as f64;
|
|
fonts.draw(
|
|
canvas,
|
|
letter,
|
|
x + r - w / 2.0,
|
|
cy + size * 0.36,
|
|
W::SemiBold,
|
|
size,
|
|
white(0.92),
|
|
);
|
|
}
|
|
}
|
|
Resolved::Shoulders => {
|
|
let mut pen = x;
|
|
for label in ["L1", "R1"] {
|
|
let w = shoulder_w(fonts, k);
|
|
let h = 15.0 * k;
|
|
let rect = Rect::from_xywh(pen as f32, (cy - h / 2.0) as f32, w as f32, h as f32);
|
|
canvas.draw_rrect(
|
|
RRect::new_rect_xy(rect, (4.0 * k) as f32, (4.0 * k) as f32),
|
|
&Paint::new(white(0.10), None),
|
|
);
|
|
let size = 10.0 * k;
|
|
let tw = fonts.measure(label, W::SemiBold, size) as f64;
|
|
fonts.draw(
|
|
canvas,
|
|
label,
|
|
pen + (w - tw) / 2.0,
|
|
cy + size * 0.36,
|
|
W::SemiBold,
|
|
size,
|
|
white(0.92),
|
|
);
|
|
pen += w + 3.0 * k;
|
|
}
|
|
}
|
|
Resolved::Adjust => {
|
|
// ◀ ▶ — two small solid triangles.
|
|
let r = BADGE_D * k / 2.0;
|
|
let (cx, cyf) = ((x + r) as f32, cy as f32);
|
|
let (tw, th) = ((4.5 * k) as f32, (5.5 * k) as f32);
|
|
let gap = (2.6 * k) as f32;
|
|
let paint = Paint::new(white(0.85), None);
|
|
let mut left = Path::new();
|
|
left.move_to((cx - gap, cyf - th));
|
|
left.line_to((cx - gap - tw, cyf));
|
|
left.line_to((cx - gap, cyf + th));
|
|
left.close();
|
|
canvas.draw_path(&left, &paint);
|
|
let mut right = Path::new();
|
|
right.move_to((cx + gap, cyf - th));
|
|
right.line_to((cx + gap + tw, cyf));
|
|
right.line_to((cx + gap, cyf + th));
|
|
right.close();
|
|
canvas.draw_path(&right, &paint);
|
|
}
|
|
Resolved::Key(text) => {
|
|
let w = keycap_w(fonts, text, k);
|
|
let h = 18.0 * k;
|
|
let rect = Rect::from_xywh(x as f32, (cy - h / 2.0) as f32, w as f32, h as f32);
|
|
canvas.draw_rrect(
|
|
RRect::new_rect_xy(rect, (5.0 * k) as f32, (5.0 * k) as f32),
|
|
&Paint::new(white(0.10), None),
|
|
);
|
|
let mut ring = Paint::new(white(0.28), None);
|
|
ring.set_style(skia_safe::PaintStyle::Stroke);
|
|
ring.set_stroke_width(1.0);
|
|
ring.set_anti_alias(true);
|
|
canvas.draw_rrect(
|
|
RRect::new_rect_xy(rect, (5.0 * k) as f32, (5.0 * k) as f32),
|
|
&ring,
|
|
);
|
|
let size = 11.0 * k;
|
|
let tw = fonts.measure(text, W::SemiBold, size) as f64;
|
|
fonts.draw(
|
|
canvas,
|
|
text,
|
|
x + (w - tw) / 2.0,
|
|
cy + size * 0.36,
|
|
W::SemiBold,
|
|
size,
|
|
white(0.92),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The PlayStation face shapes, stroked inside the badge: Confirm=✕, Back=○, X-position
|
|
/// =□, Y-position=△ (the DualSense's physical layout).
|
|
fn draw_ps_shape(canvas: &Canvas, face: Face, center: Point, r: f32, stroke: f32) {
|
|
let mut p = Paint::new(white(0.92), None);
|
|
p.set_style(skia_safe::PaintStyle::Stroke);
|
|
p.set_stroke_width(stroke);
|
|
p.set_stroke_cap(skia_safe::PaintCap::Round);
|
|
p.set_anti_alias(true);
|
|
let (cx, cy) = (center.x, center.y);
|
|
match face {
|
|
Face::A => {
|
|
// ✕
|
|
canvas.draw_line((cx - r, cy - r), (cx + r, cy + r), &p);
|
|
canvas.draw_line((cx - r, cy + r), (cx + r, cy - r), &p);
|
|
}
|
|
Face::B => {
|
|
// ○
|
|
canvas.draw_circle(center, r * 1.1, &p);
|
|
}
|
|
Face::X => {
|
|
// □
|
|
canvas.draw_rect(Rect::from_xywh(cx - r, cy - r, 2.0 * r, 2.0 * r), &p);
|
|
}
|
|
Face::Y => {
|
|
// △
|
|
let mut tri = Path::new();
|
|
tri.move_to((cx, cy - r * 1.2));
|
|
tri.line_to((cx + r * 1.15, cy + r * 0.85));
|
|
tri.line_to((cx - r * 1.15, cy + r * 0.85));
|
|
tri.close();
|
|
canvas.draw_path(&tri, &p);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn style_follows_pad_kind() {
|
|
assert_eq!(
|
|
GlyphStyle::from_pref(Some(GamepadPref::DualSense)),
|
|
GlyphStyle::Shapes
|
|
);
|
|
assert_eq!(
|
|
GlyphStyle::from_pref(Some(GamepadPref::SteamDeck)),
|
|
GlyphStyle::Letters
|
|
);
|
|
assert_eq!(GlyphStyle::from_pref(None), GlyphStyle::Keyboard);
|
|
}
|
|
}
|