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
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>
436 lines
15 KiB
Rust
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 [®ular, &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)
|
|
);
|
|
}
|
|
}
|