Files
punktfunk/crates/pf-console-ui/src/theme.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-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
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (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

436 lines
15 KiB
Rust

//! The console shell's shared look: the brand palette, the embedded Geist typography
//! (the same OFL faces the Apple client bundles — one brand voice on every platform),
//! the dark-glass panel primitive standing in for Liquid Glass (translucent fill +
//! hairline stroke; the backdrops here are soft gradients, so a real backdrop blur
//! would be indistinguishable and costs Deck GPU), and the quiet form backdrop the
//! settings/add-host/pair screens sit on.
use anyhow::{anyhow, Result};
use skia_safe::textlayout::{
FontCollection, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle, TypefaceFontProvider,
};
use skia_safe::{
gradient_shader, Canvas, Color4f, Font, FontMgr, FontStyle, MaskFilter, Paint, PathEffect,
Point, RRect, Rect, TileMode, Typeface,
};
// --- Palette -----------------------------------------------------------------------------
/// The punktfunk brand violet — the DARK-appearance value (#8678F5); the console UI is
/// always dark. (Light surfaces use #6656F2; nothing here is light.)
pub(crate) const BRAND: Color4f = Color4f::new(0.525, 0.471, 0.961, 1.0);
pub(crate) const WHITE: Color4f = Color4f::new(1.0, 1.0, 1.0, 1.0);
pub(crate) const DIM: Color4f = Color4f::new(1.0, 1.0, 1.0, 0.55);
pub(crate) const FAINT: Color4f = Color4f::new(1.0, 1.0, 1.0, 0.35);
/// The error/status red (the GTK client's #ff938a).
pub(crate) const ERROR: Color4f = Color4f::new(1.0, 0.576, 0.541, 1.0);
pub(crate) const ONLINE_GREEN: Color4f = Color4f::new(0.20, 0.84, 0.29, 1.0);
pub(crate) fn white(alpha: f32) -> Color4f {
Color4f::new(1.0, 1.0, 1.0, alpha)
}
pub(crate) fn brand(alpha: f32) -> Color4f {
Color4f::new(BRAND.r, BRAND.g, BRAND.b, alpha)
}
/// The dark-glass base fill every panel starts from.
const GLASS_BASE: Color4f = Color4f::new(0.086, 0.086, 0.125, 0.62);
// --- Panels (the Liquid Glass stand-in) --------------------------------------------------
pub(crate) enum PanelStroke {
/// Hairline white at this alpha (rows, pills).
Plain(f32),
/// White .22 → .04 top→bottom (the host tiles' gradient edge).
Gradient,
/// The gradient edge dashed `[6,5]` (discovered / Add-Host tiles).
GradientDashed,
/// Brand-colored hairline (the actively edited field).
Brand(f32),
}
/// One glass panel: base fill, optional tint wash, hairline stroke. `corner` and the
/// dash pattern are DESIGN units — the caller's `k` scales them.
pub(crate) fn panel(
canvas: &Canvas,
rect: Rect,
corner: f32,
tint: Option<Color4f>,
stroke: PanelStroke,
k: f32,
) {
let rr = RRect::new_rect_xy(rect, corner * k, corner * k);
canvas.draw_rrect(rr, &Paint::new(GLASS_BASE, None));
if let Some(tint) = tint {
canvas.draw_rrect(rr, &Paint::new(tint, None));
}
let mut sp = Paint::default();
sp.set_style(skia_safe::PaintStyle::Stroke);
sp.set_stroke_width(1.0);
sp.set_anti_alias(true);
match stroke {
PanelStroke::Plain(alpha) => {
sp.set_color4f(white(alpha), None);
}
PanelStroke::Brand(alpha) => {
sp.set_color4f(brand(alpha), None);
}
PanelStroke::Gradient | PanelStroke::GradientDashed => {
sp.set_shader(gradient_shader::linear(
(
Point::new(rect.left, rect.top),
Point::new(rect.left, rect.bottom),
),
gradient_shader::GradientShaderColors::Colors(&[
white(0.22).to_color(),
white(0.04).to_color(),
]),
None,
TileMode::Clamp,
None,
None,
));
if matches!(stroke, PanelStroke::GradientDashed) {
sp.set_path_effect(PathEffect::dash(&[6.0 * k, 5.0 * k], 0.0));
}
}
}
canvas.draw_rrect(rr, &sp);
}
/// The soft drop shadow under a focused tile — a blurred black round-rect behind it.
pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alpha: f32) {
let mut p = Paint::new(Color4f::new(0.0, 0.0, 0.0, alpha), None);
p.set_mask_filter(MaskFilter::blur(
skia_safe::BlurStyle::Normal,
10.0 * k,
None,
));
let shifted = rect.with_offset((0.0, 10.0 * k));
canvas.draw_rrect(RRect::new_rect_xy(shifted, corner * k, corner * k), &p);
}
// --- The form backdrop (settings / add-host / pair) --------------------------------------
/// The calm backdrop for the form screens — NOT the launcher's aurora (this stays still
/// and quiet), and deliberately not near-black: a deep indigo base plus two soft static
/// glows give the glass rows real color to sit on. A light top/bottom scrim grounds the
/// pinned title and hint bar (the Swift build blurs a tray instead; same job).
pub(crate) fn draw_form_background(canvas: &Canvas, w: f64, h: f64) {
let (wf, hf) = (w as f32, h as f32);
canvas.draw_rect(
Rect::from_wh(wf, hf),
&Paint::new(Color4f::new(0.075, 0.062, 0.150, 1.0), None),
);
// Violet lift top-leading, cooler indigo bottom-trailing — elliptical (window
// aspect) via a unit-radius radial gradient under a scale.
for (cx, cy, color, alpha) in [
(0.26, 0.14, Color4f::new(0.40, 0.31, 0.68, 1.0), 0.9f32),
(0.82, 0.90, Color4f::new(0.20, 0.24, 0.58, 1.0), 0.75),
] {
let mut paint = Paint::default();
let c = Color4f::new(color.r, color.g, color.b, alpha);
paint.set_shader(gradient_shader::radial(
Point::new(0.0, 0.0),
0.78,
gradient_shader::GradientShaderColors::Colors(&[
c.to_color(),
Color4f::new(color.r, color.g, color.b, 0.0).to_color(),
]),
None,
TileMode::Clamp,
None,
None,
));
canvas.save();
canvas.translate((wf * cx, hf * cy));
canvas.scale((wf, hf));
canvas.draw_rect(Rect::from_ltrb(-1.0, -1.0, 1.0, 1.0), &paint);
canvas.restore();
}
let mut scrim = Paint::default();
scrim.set_shader(gradient_shader::linear(
(Point::new(0.0, 0.0), Point::new(0.0, hf)),
gradient_shader::GradientShaderColors::Colors(&[
Color4f::new(0.0, 0.0, 0.0, 0.30).to_color(),
Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(),
Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(),
Color4f::new(0.0, 0.0, 0.0, 0.32).to_color(),
]),
Some(&[0.0, 0.22, 0.74, 1.0][..]),
TileMode::Clamp,
None,
None,
));
canvas.draw_rect(Rect::from_wh(wf, hf), &scrim);
}
/// The loading/connecting spinner: a rotating 270° arc driven by the shell clock.
pub(crate) fn spinner(canvas: &Canvas, cx: f64, cy: f64, r: f64, t: f64) {
let start = (t * 300.0) % 360.0;
let mut paint = Paint::new(white(0.85), None);
paint.set_style(skia_safe::PaintStyle::Stroke);
paint.set_stroke_width((r / 5.0) as f32);
paint.set_stroke_cap(skia_safe::PaintCap::Round);
paint.set_anti_alias(true);
canvas.draw_arc(
Rect::from_xywh(
(cx - r) as f32,
(cy - r) as f32,
2.0 * r as f32,
2.0 * r as f32,
),
start as f32,
270.0,
false,
&paint,
);
}
// --- Typography ---------------------------------------------------------------------------
/// Geist weights the console uses (matching the Apple client's `.geist(size, weight)`).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum W {
Regular,
Medium,
SemiBold,
Bold,
}
/// The text toolkit shared by every screen: the four embedded Geist faces for direct
/// `draw_str` work, plus a paragraph collection ("Geist" with system fallback — game
/// titles can be CJK; `draw_str` can't shape those).
pub(crate) struct Fonts {
regular: Typeface,
medium: Typeface,
semibold: Typeface,
bold: Typeface,
collection: FontCollection,
}
/// The Geist faces ride in the binary — the console must look right on a bare gamescope
/// session with no font packages to lean on (fontconfig still serves CJK fallback where
/// it exists).
const GEIST_REGULAR: &[u8] = include_bytes!("../assets/fonts/Geist-Regular.otf");
const GEIST_MEDIUM: &[u8] = include_bytes!("../assets/fonts/Geist-Medium.otf");
const GEIST_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/Geist-SemiBold.otf");
const GEIST_BOLD: &[u8] = include_bytes!("../assets/fonts/Geist-Bold.otf");
pub(crate) fn build_fonts() -> Result<Fonts> {
let mgr = FontMgr::new();
let load = |bytes: &[u8], which: &str| {
mgr.new_from_data(bytes, None)
.ok_or_else(|| anyhow!("embedded Geist face rejected: {which}"))
};
let regular = load(GEIST_REGULAR, "Regular")?;
let medium = load(GEIST_MEDIUM, "Medium")?;
let semibold = load(GEIST_SEMIBOLD, "SemiBold")?;
let bold = load(GEIST_BOLD, "Bold")?;
// Paragraphs resolve "Geist" from the asset provider first (all four weights under
// one alias — style matching picks the face), then fall back to the system manager.
let mut provider = TypefaceFontProvider::new();
for tf in [&regular, &medium, &semibold, &bold] {
provider.register_typeface(tf.clone(), Some("Geist"));
}
let mut collection = FontCollection::new();
collection.set_asset_font_manager(Some(provider.into()));
collection.set_default_font_manager(FontMgr::new(), None);
Ok(Fonts {
regular,
medium,
semibold,
bold,
collection,
})
}
impl Fonts {
pub(crate) fn font(&self, w: W, size: f64) -> Font {
let tf = match w {
W::Regular => &self.regular,
W::Medium => &self.medium,
W::SemiBold => &self.semibold,
W::Bold => &self.bold,
};
let mut f = Font::new(tf.clone(), size as f32);
f.set_subpixel(true);
f
}
pub(crate) fn measure(&self, text: &str, w: W, size: f64) -> f32 {
self.font(w, size).measure_str(text, None).0
}
/// `draw_str` at a BASELINE. Returns the advance so callers can chain runs.
#[allow(clippy::too_many_arguments)]
pub(crate) fn draw(
&self,
canvas: &Canvas,
text: &str,
x: f64,
baseline: f64,
w: W,
size: f64,
color: Color4f,
) -> f32 {
let font = self.font(w, size);
canvas.draw_str(
text,
Point::new(x as f32, baseline as f32),
&font,
&Paint::new(color, None),
);
font.measure_str(text, None).0
}
/// Letter-spaced small caps (the section headers' `tracking(1.4)` look) — Skia's
/// `draw_str` has no tracking, so the run is placed char by char.
#[allow(clippy::too_many_arguments)]
pub(crate) fn draw_tracked(
&self,
canvas: &Canvas,
text: &str,
x: f64,
baseline: f64,
w: W,
size: f64,
tracking: f64,
color: Color4f,
) {
let font = self.font(w, size);
let paint = Paint::new(color, None);
let mut pen = x as f32;
let mut buf = [0u8; 4];
for ch in text.chars() {
let s = ch.encode_utf8(&mut buf);
canvas.draw_str(&*s, Point::new(pen, baseline as f32), &font, &paint);
pen += font.measure_str(&*s, None).0 + tracking as f32;
}
}
fn paragraph(
&self,
text: &str,
w: W,
size: f64,
color: Color4f,
align: TextAlign,
max_w: f64,
) -> skia_safe::textlayout::Paragraph {
let mut style = ParagraphStyle::new();
style.set_text_align(align);
let mut ts = TextStyle::new();
ts.set_font_families(&["Geist"]);
ts.set_font_size(size as f32);
ts.set_color(color.to_color());
ts.set_font_style(match w {
W::Regular => FontStyle::normal(),
W::Medium => FontStyle::new(
skia_safe::font_style::Weight::MEDIUM,
skia_safe::font_style::Width::NORMAL,
skia_safe::font_style::Slant::Upright,
),
W::SemiBold => FontStyle::new(
skia_safe::font_style::Weight::SEMI_BOLD,
skia_safe::font_style::Width::NORMAL,
skia_safe::font_style::Slant::Upright,
),
W::Bold => FontStyle::bold(),
});
style.set_text_style(&ts);
let mut b = ParagraphBuilder::new(&style, self.collection.clone());
b.add_text(text);
let mut p = b.build();
p.layout(max_w as f32);
p
}
/// Centered, wrapping paragraph with `y` as its TOP edge (shaping + CJK fallback).
#[allow(clippy::too_many_arguments)]
pub(crate) fn centered(
&self,
canvas: &Canvas,
text: &str,
w: W,
size: f64,
color: Color4f,
cx: f64,
y: f64,
max_w: f64,
) {
let p = self.paragraph(text, w, size, color, TextAlign::Center, max_w);
p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32));
}
/// A single shaped line, middle-ellipsized to `max_w`, drawn at a baseline. For
/// host/game titles that may exceed their tile.
#[allow(clippy::too_many_arguments)]
pub(crate) fn draw_clipped(
&self,
canvas: &Canvas,
text: &str,
x: f64,
baseline: f64,
w: W,
size: f64,
color: Color4f,
max_w: f64,
) {
let font = self.font(w, size);
if font.measure_str(text, None).0 <= max_w as f32 {
self.draw(canvas, text, x, baseline, w, size, color);
return;
}
let ell = "…";
let ell_w = font.measure_str(ell, None).0;
let mut fitted = String::new();
let mut used = 0.0f32;
for ch in text.chars() {
let cw = font.measure_str(ch.to_string().as_str(), None).0;
if used + cw + ell_w > max_w as f32 {
break;
}
fitted.push(ch);
used += cw;
}
fitted.push_str(ell);
self.draw(canvas, &fitted, x, baseline, w, size, color);
}
}
/// Resolve the first available family. Generic aliases ("sans-serif", "monospace")
/// resolve through fontconfig on Linux; Windows' DirectWrite-backed FontMgr has no
/// generic aliases, so the list falls through to concrete family names there.
pub(crate) fn match_first_family(
mgr: &FontMgr,
families: &[&str],
style: FontStyle,
) -> Option<Typeface> {
families
.iter()
.find_map(|f| mgr.match_family_style(f, style))
}
#[cfg(test)]
mod tests {
use super::*;
/// The embedded Geist faces must all parse — a bad asset would take out every
/// console screen at init.
#[test]
fn embedded_fonts_load() {
let fonts = build_fonts().expect("Geist faces load");
for w in [W::Regular, W::Medium, W::SemiBold, W::Bold] {
assert!(fonts.measure("Punktfunk", w, 16.0) > 0.0);
}
// Heavier faces render wider at the same size — proves four distinct faces.
assert!(
fonts.measure("Punktfunk", W::Bold, 16.0)
> fonts.measure("Punktfunk", W::Regular, 16.0)
);
}
}