Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bf64e07bf | ||
|
|
1198522931 | ||
|
|
79982060c3 | ||
|
|
fc6060f274 | ||
|
|
4399664217 | ||
|
|
e684b3e4bd | ||
|
|
8c4b1b8c62 | ||
|
|
b64ac3cb32 | ||
|
|
f92b093f92 | ||
|
|
b9adcc4897 |
@@ -102,7 +102,7 @@ installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
|--------|---------|-------|
|
||||
| **Ubuntu 26.04+ / Debian 13+** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu](https://docs.punktfunk.unom.io/docs/ubuntu) · [Debian](https://docs.punktfunk.unom.io/docs/debian) · [packaging/debian](packaging/debian/README.md) |
|
||||
| **Bazzite / Fedora Atomic** (systemd-sysext) | `curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh && sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Fedora** (dnf) | `sudo dnf install punktfunk` *(after adding the repo; the console comes with it)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
|
||||
| **Fedora** (dnf) | `sudo dnf install punktfunk punktfunk-web punktfunk-scripting` *(after adding the repo)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
|
||||
| **Arch / CachyOS** (pacman) | `sudo pacman -Syu punktfunk-host` *(binary repo — always a full `-Syu`)* | [Arch Linux](https://docs.punktfunk.unom.io/docs/arch) · [packaging/arch](packaging/arch/README.md) |
|
||||
| **SteamOS / Steam Deck** (on-device build) | `bash ~/punktfunk/scripts/steamdeck/install.sh` *(after cloning this repo to `~/punktfunk`)* | [SteamOS (Host)](https://docs.punktfunk.unom.io/docs/steamos-host) |
|
||||
| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) |
|
||||
|
||||
@@ -16,7 +16,8 @@ use ndk::native_window::NativeWindow;
|
||||
use pf_client_core::console::{OverlayAction, PointerInput, SessionPhase};
|
||||
use pf_client_core::menu_nav::{MenuEvent, MenuNav, MenuPulse, MenuSample, PadInfo};
|
||||
use pf_console_ui::{
|
||||
Console, ConsoleEntry, ConsoleHandles, ConsoleOptions, Insets, Key, SnapshotStore, Viewport,
|
||||
Console, ConsoleEntry, ConsoleHandles, ConsoleOptions, InputSource, Insets, Key, SnapshotStore,
|
||||
Viewport,
|
||||
};
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
use std::collections::VecDeque;
|
||||
@@ -346,7 +347,11 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
}
|
||||
Cmd::Menu(ev) => {
|
||||
last_input = Instant::now();
|
||||
if let Some(p) = console.menu(ev) {
|
||||
// Discrete events are the remote/keyboard path (Kotlin routes pad
|
||||
// buttons through PadSample) — with one wrinkle: a pad's SELECT also
|
||||
// arrives here (SkiaConsoleShell's ▲-on-Home shortcut), briefly
|
||||
// reading as keys. The next real pad press corrects the legend.
|
||||
if let Some(p) = console.menu(ev, InputSource::Keys) {
|
||||
shared.emit(HostEvent::Pulse(p));
|
||||
}
|
||||
}
|
||||
@@ -454,7 +459,7 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
menu_out.clear();
|
||||
nav.poll(&sample, Instant::now(), &mut menu_out);
|
||||
for ev in menu_out.drain(..) {
|
||||
if let Some(p) = console.menu(ev) {
|
||||
if let Some(p) = console.menu(ev, InputSource::Pad) {
|
||||
shared.emit(HostEvent::Pulse(p));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,17 @@ use skia_safe::Canvas;
|
||||
|
||||
pub use crate::input::Key;
|
||||
|
||||
/// What produced a menu event — the device family the hint legend should speak in.
|
||||
/// Pointer input carries no source on purpose: a tap says nothing about which buttons
|
||||
/// the user's OTHER hand holds, so it leaves the legend as it was.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum InputSource {
|
||||
/// A gamepad (the glyphs follow the active pad's family).
|
||||
Pad,
|
||||
/// Keys: a TV remote's D-pad on Android, a keyboard on the desktop.
|
||||
Keys,
|
||||
}
|
||||
|
||||
/// Where the console starts.
|
||||
pub enum ConsoleEntry {
|
||||
/// The host list (the session binary's bare `--browse`; the Android console's Home).
|
||||
@@ -122,8 +133,12 @@ impl Console {
|
||||
.render_in(canvas, viewport, &self.fonts, pad, pad_pref, pads);
|
||||
}
|
||||
|
||||
/// A controller menu event. The pulse, if any, is what the pad should feel.
|
||||
pub fn menu(&mut self, event: MenuEvent) -> Option<MenuPulse> {
|
||||
/// A menu event, with WHERE it came from — a controller, or keys (a TV remote's
|
||||
/// D-pad, a keyboard). The source is what keeps the hint legend speaking the language
|
||||
/// of the device actually in the user's hand; the pulse, if any, is what a pad should
|
||||
/// feel.
|
||||
pub fn menu(&mut self, event: MenuEvent, source: InputSource) -> Option<MenuPulse> {
|
||||
self.shell.note_input_source(source);
|
||||
self.shell.handle_menu(event)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! 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.
|
||||
//! `sfSymbolsName`; here the shapes are drawn). The style follows WHAT IS DRIVING the
|
||||
//! console (see `Shell::glyph_style`): PlayStation controllers read ✕/○/□/△, Nintendo
|
||||
//! pads read their own letter positions, everything else reads ABXY letters — and when
|
||||
//! the last input came from keys, the legend swaps to keyboard keycaps on the desktop or
|
||||
//! to TV-remote marks (OK, the back arrow, the D-pad) on Android, where key-driven input
|
||||
//! IS a remote. The console stays fully drivable in every one of them.
|
||||
|
||||
use crate::theme::{fg, fill, stroke, Fonts, W};
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
@@ -15,16 +17,30 @@ pub(crate) enum GlyphStyle {
|
||||
Letters,
|
||||
/// PlayStation face shapes (DualSense / DualShock 4).
|
||||
Shapes,
|
||||
/// No controller — keyboard keycaps.
|
||||
/// Nintendo letter badges: the same positional buttons, labelled the way the pad in
|
||||
/// the user's hands is — south reads B, east A, west Y, north X. Without this a
|
||||
/// Switch pad's legend says "A Select" over the button engraved B.
|
||||
Nintendo,
|
||||
/// Keys drive, on a desktop — keyboard keycaps.
|
||||
Keyboard,
|
||||
/// Keys drive, on Android — a TV remote: OK, the back arrow, and the D-pad. A remote
|
||||
/// has no Y/X and no shoulders, so hints that need them resolve to nothing and the
|
||||
/// section hint points at the D-pad path instead.
|
||||
Remote,
|
||||
}
|
||||
|
||||
impl GlyphStyle {
|
||||
/// The style a PAD speaks in, from the family its `Auto` virtual pad resolves to
|
||||
/// ([`PadInfo::pref`](pf_client_core::menu_nav::PadInfo) — DualSense stays DualSense,
|
||||
/// Switch Pro stays Switch Pro, everything else lands on an Xbox class). The keys-drive
|
||||
/// styles are picked by the shell, which knows the platform; `None` (no pad) falls to
|
||||
/// keycaps as the neutral default.
|
||||
pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle {
|
||||
match pref {
|
||||
Some(GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4) => {
|
||||
GlyphStyle::Shapes
|
||||
}
|
||||
Some(GamepadPref::SwitchPro) => GlyphStyle::Nintendo,
|
||||
Some(_) => GlyphStyle::Letters,
|
||||
None => GlyphStyle::Keyboard,
|
||||
}
|
||||
@@ -56,6 +72,30 @@ pub(crate) fn pad_mark(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if style == GlyphStyle::Remote {
|
||||
// A remote: a slim upright wand with its select ring near the top. Outlined like
|
||||
// the keycap — the filled marks are for things with a body to fill.
|
||||
let rw = w * 0.42;
|
||||
let rh = w * 0.98;
|
||||
let body = Rect::from_xywh(
|
||||
(x + (w - rw) / 2.0) as f32,
|
||||
(cy - rh / 2.0) as f32,
|
||||
rw as f32,
|
||||
rh as f32,
|
||||
);
|
||||
p.set_style(skia_safe::PaintStyle::Stroke);
|
||||
p.set_stroke_width((1.3 * k) as f32);
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(body, (rw / 2.2) as f32, (rw / 2.2) as f32),
|
||||
&p,
|
||||
);
|
||||
canvas.draw_circle(
|
||||
((x + w / 2.0) as f32, (cy - rh * 0.22) as f32),
|
||||
(rw * 0.30) as f32,
|
||||
&p,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A gamepad: a wide rounded body with a grip under each end. Detail beyond the
|
||||
// silhouette is invisible at 15 dp, so there is none — the outline IS the glyph.
|
||||
let h = w * 0.52;
|
||||
@@ -201,17 +241,29 @@ pub(crate) fn hint_bar(
|
||||
let pad = 13.0 * k;
|
||||
let gap_hint = 18.0 * k;
|
||||
let gap_glyph = 7.0 * k;
|
||||
let widths: Vec<(f64, f64)> = hints
|
||||
// Hints with no honest glyph in this style (a remote's missing Y/X) are dropped
|
||||
// here, before layout — they take no width, draw nothing and get no hit box.
|
||||
let shown: Vec<&Hint> = hints
|
||||
.iter()
|
||||
.filter(|h| resolved(h.key, style).is_some())
|
||||
.collect();
|
||||
if shown.is_empty() {
|
||||
return HintBar {
|
||||
size: (0.0, 0.0),
|
||||
rects: Vec::new(),
|
||||
};
|
||||
}
|
||||
let widths: Vec<(f64, f64)> = shown
|
||||
.iter()
|
||||
.map(|h| {
|
||||
(
|
||||
glyph_width(fonts, h.key, style, k),
|
||||
glyph_width(fonts, h.key, style, k).expect("filtered to resolvable"),
|
||||
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;
|
||||
+ gap_hint * (shown.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);
|
||||
@@ -239,8 +291,8 @@ pub(crate) fn hint_bar(
|
||||
|
||||
let cy = bottom - h / 2.0;
|
||||
let mut pen = x + pad;
|
||||
let mut rects = Vec::with_capacity(hints.len());
|
||||
for (hint, (gw, lw)) in hints.iter().zip(&widths) {
|
||||
let mut rects = Vec::with_capacity(shown.len());
|
||||
for (hint, (gw, lw)) in shown.iter().zip(&widths) {
|
||||
// Glyph + label + half the gap to the next hint, full pill height: a comfortable
|
||||
// target without stealing the neighbour's.
|
||||
rects.push((
|
||||
@@ -272,13 +324,14 @@ pub(crate) fn hint_bar(
|
||||
}
|
||||
}
|
||||
|
||||
fn glyph_width(fonts: &Fonts, key: HintKey, style: GlyphStyle, k: f64) -> f64 {
|
||||
match resolved(key, style) {
|
||||
/// `None` = the hint resolves to nothing in this style and takes no space (see [`resolved`]).
|
||||
fn glyph_width(fonts: &Fonts, key: HintKey, style: GlyphStyle, k: f64) -> Option<f64> {
|
||||
Some(match resolved(key, style)? {
|
||||
Resolved::Badge(_) | Resolved::Adjust => BADGE_D * k,
|
||||
Resolved::Shoulders => 2.0 * shoulder_w(fonts, k) + 3.0 * k,
|
||||
Resolved::Up | Resolved::Down => BADGE_D * k,
|
||||
Resolved::Up | Resolved::Down | Resolved::Ok | Resolved::BackArrow => BADGE_D * k,
|
||||
Resolved::Key(text) => keycap_w(fonts, text, k),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn shoulder_w(fonts: &Fonts, k: f64) -> f64 {
|
||||
@@ -291,7 +344,7 @@ fn keycap_w(fonts: &Fonts, text: &str, k: f64) -> f64 {
|
||||
|
||||
/// A hint key resolved against the glyph style.
|
||||
enum Resolved {
|
||||
/// A face-button badge: the letter (Letters) or shape index (Shapes).
|
||||
/// A face-button badge: the letter (Letters/Nintendo) or shape (Shapes).
|
||||
Badge(Face),
|
||||
Shoulders,
|
||||
Adjust,
|
||||
@@ -301,6 +354,10 @@ enum Resolved {
|
||||
/// The d-pad's down — the same triangle stood on its head, and style-free for the
|
||||
/// same reason [`Resolved::Up`] is.
|
||||
Down,
|
||||
/// A TV remote's select — a round badge that simply says OK.
|
||||
Ok,
|
||||
/// A TV remote's back — the ↩ return arrow in a badge.
|
||||
BackArrow,
|
||||
Key(&'static str),
|
||||
}
|
||||
|
||||
@@ -312,9 +369,14 @@ enum Face {
|
||||
Y,
|
||||
}
|
||||
|
||||
fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
|
||||
/// `None` = this hint has no honest glyph in this style and is not drawn at all: a TV
|
||||
/// remote has no Y/X, and advertising a button the device cannot press is worse than
|
||||
/// silence. (The touch path loses those two bar buttons in Remote style with it —
|
||||
/// acceptable: Remote only rules while KEYS drove last, and every such action still has
|
||||
/// an on-screen path.)
|
||||
fn resolved(key: HintKey, style: GlyphStyle) -> Option<Resolved> {
|
||||
if style == GlyphStyle::Keyboard {
|
||||
return match key {
|
||||
return Some(match key {
|
||||
HintKey::Confirm => Resolved::Key("Enter"),
|
||||
HintKey::Back => Resolved::Key("Esc"),
|
||||
HintKey::Secondary => Resolved::Key("Y"),
|
||||
@@ -326,9 +388,26 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
|
||||
HintKey::Up => Resolved::Up,
|
||||
HintKey::Down => Resolved::Down,
|
||||
HintKey::Key(t) => Resolved::Key(t),
|
||||
});
|
||||
}
|
||||
if style == GlyphStyle::Remote {
|
||||
return match key {
|
||||
HintKey::Confirm => Some(Resolved::Ok),
|
||||
HintKey::Back => Some(Resolved::BackArrow),
|
||||
// A remote has no Y and no X. The screens' Y/X features stay reachable the
|
||||
// ways their screens already provide; the legend just stops naming buttons
|
||||
// that are not in the user's hand.
|
||||
HintKey::Secondary | HintKey::Tertiary => None,
|
||||
// No shoulders either — the D-pad path to the strip (Up from the top row) is
|
||||
// the section switcher a remote actually has, so the hint points up.
|
||||
HintKey::Shoulders => Some(Resolved::Up),
|
||||
HintKey::Adjust => Some(Resolved::Adjust),
|
||||
HintKey::Up => Some(Resolved::Up),
|
||||
HintKey::Down => Some(Resolved::Down),
|
||||
HintKey::Key(t) => Some(Resolved::Key(t)),
|
||||
};
|
||||
}
|
||||
match key {
|
||||
Some(match key {
|
||||
HintKey::Confirm => Resolved::Badge(Face::A),
|
||||
HintKey::Back => Resolved::Badge(Face::B),
|
||||
HintKey::Tertiary => Resolved::Badge(Face::X),
|
||||
@@ -338,6 +417,21 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
|
||||
HintKey::Down => Resolved::Down,
|
||||
HintKey::Up => Resolved::Up,
|
||||
HintKey::Key(t) => Resolved::Key(t),
|
||||
})
|
||||
}
|
||||
|
||||
/// The letter a face badge shows: positional buttons, labelled the way the ACTIVE pad
|
||||
/// is engraved. Nintendo swaps both pairs — its south is B and its east is A.
|
||||
fn face_letter(face: Face, style: GlyphStyle) -> &'static str {
|
||||
match (style, face) {
|
||||
(GlyphStyle::Nintendo, Face::A) => "B",
|
||||
(GlyphStyle::Nintendo, Face::B) => "A",
|
||||
(GlyphStyle::Nintendo, Face::X) => "Y",
|
||||
(GlyphStyle::Nintendo, Face::Y) => "X",
|
||||
(_, Face::A) => "A",
|
||||
(_, Face::B) => "B",
|
||||
(_, Face::X) => "X",
|
||||
(_, Face::Y) => "Y",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +445,10 @@ fn draw_glyph(
|
||||
cy: f64,
|
||||
k: f64,
|
||||
) {
|
||||
match resolved(key, style) {
|
||||
let Some(resolved) = resolved(key, style) else {
|
||||
return;
|
||||
};
|
||||
match resolved {
|
||||
Resolved::Badge(face) => {
|
||||
let r = BADGE_D * k / 2.0;
|
||||
let center = Point::new((x + r) as f32, cy as f32);
|
||||
@@ -360,12 +457,7 @@ fn draw_glyph(
|
||||
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 letter = face_letter(face, style);
|
||||
let size = 12.0 * k;
|
||||
let w = fonts.measure(letter, W::SemiBold, size) as f64;
|
||||
fonts.draw(
|
||||
@@ -379,6 +471,50 @@ fn draw_glyph(
|
||||
);
|
||||
}
|
||||
}
|
||||
Resolved::Ok => {
|
||||
// The remote's select: the same badge as a face button, saying OK — the word
|
||||
// printed on the remote itself.
|
||||
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, &fill(fg(0.10)));
|
||||
canvas.draw_circle(center, r as f32, &stroke(fg(0.32), (1.2 * k) as f32));
|
||||
let size = 9.5 * k;
|
||||
let w = fonts.measure("OK", W::SemiBold, size) as f64;
|
||||
fonts.draw(
|
||||
canvas,
|
||||
"OK",
|
||||
x + r - w / 2.0,
|
||||
cy + size * 0.36,
|
||||
W::SemiBold,
|
||||
size,
|
||||
fg(0.92),
|
||||
);
|
||||
}
|
||||
Resolved::BackArrow => {
|
||||
// The remote's back: the ↩ return arrow in the same badge — a shaft curving
|
||||
// home with an arrowhead at its left end.
|
||||
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, &fill(fg(0.10)));
|
||||
canvas.draw_circle(center, r as f32, &stroke(fg(0.32), (1.2 * k) as f32));
|
||||
let (cx, cyf) = (center.x, center.y);
|
||||
let (half_w, rise) = ((4.6 * k) as f32, (3.2 * k) as f32);
|
||||
let mut p = stroke(fg(0.92), (1.7 * k) as f32);
|
||||
p.set_stroke_cap(skia_safe::PaintCap::Round);
|
||||
p.set_stroke_join(skia_safe::PaintJoin::Round);
|
||||
let mut path = PathBuilder::new();
|
||||
path.move_to((cx + half_w, cyf - rise)); // the hook, up on the right…
|
||||
path.line_to((cx + half_w, cyf + rise * 0.2)); // …dropping to the shaft…
|
||||
path.line_to((cx - half_w, cyf + rise * 0.2)); // …running left toward the head.
|
||||
canvas.draw_path(&path.detach(), &p);
|
||||
let head = (2.6 * k) as f32;
|
||||
let tip = cx - half_w;
|
||||
let mut arrow = PathBuilder::new();
|
||||
arrow.move_to((tip + head, cyf + rise * 0.2 - head));
|
||||
arrow.line_to((tip, cyf + rise * 0.2));
|
||||
arrow.line_to((tip + head, cyf + rise * 0.2 + head));
|
||||
canvas.draw_path(&arrow.detach(), &p);
|
||||
}
|
||||
Resolved::Shoulders => {
|
||||
let mut pen = x;
|
||||
for label in ["L1", "R1"] {
|
||||
@@ -514,6 +650,62 @@ mod tests {
|
||||
GlyphStyle::from_pref(Some(GamepadPref::SteamDeck)),
|
||||
GlyphStyle::Letters
|
||||
);
|
||||
assert_eq!(
|
||||
GlyphStyle::from_pref(Some(GamepadPref::SwitchPro)),
|
||||
GlyphStyle::Nintendo
|
||||
);
|
||||
assert_eq!(GlyphStyle::from_pref(None), GlyphStyle::Keyboard);
|
||||
}
|
||||
|
||||
/// Nintendo's badges carry the pad's OWN engravings: the positional confirm (south) is
|
||||
/// the button a Switch pad labels B. Everything non-Nintendo keeps the Xbox letters.
|
||||
#[test]
|
||||
fn nintendo_badges_read_the_pads_own_letters() {
|
||||
assert_eq!(face_letter(Face::A, GlyphStyle::Nintendo), "B");
|
||||
assert_eq!(face_letter(Face::B, GlyphStyle::Nintendo), "A");
|
||||
assert_eq!(face_letter(Face::X, GlyphStyle::Nintendo), "Y");
|
||||
assert_eq!(face_letter(Face::Y, GlyphStyle::Nintendo), "X");
|
||||
assert_eq!(face_letter(Face::A, GlyphStyle::Letters), "A");
|
||||
}
|
||||
|
||||
/// A remote has no Y/X, so those hints resolve to nothing — the legend must not
|
||||
/// advertise a button the device in the user's hand cannot press. Confirm and Back
|
||||
/// resolve to the remote's own marks, and the section hint points at the D-pad path.
|
||||
#[test]
|
||||
fn remote_hides_the_buttons_a_remote_does_not_have() {
|
||||
assert!(resolved(HintKey::Secondary, GlyphStyle::Remote).is_none());
|
||||
assert!(resolved(HintKey::Tertiary, GlyphStyle::Remote).is_none());
|
||||
assert!(matches!(
|
||||
resolved(HintKey::Confirm, GlyphStyle::Remote),
|
||||
Some(Resolved::Ok)
|
||||
));
|
||||
assert!(matches!(
|
||||
resolved(HintKey::Back, GlyphStyle::Remote),
|
||||
Some(Resolved::BackArrow)
|
||||
));
|
||||
assert!(matches!(
|
||||
resolved(HintKey::Shoulders, GlyphStyle::Remote),
|
||||
Some(Resolved::Up)
|
||||
));
|
||||
// Every other style resolves every hint — nothing else went silent.
|
||||
for style in [
|
||||
GlyphStyle::Letters,
|
||||
GlyphStyle::Shapes,
|
||||
GlyphStyle::Nintendo,
|
||||
GlyphStyle::Keyboard,
|
||||
] {
|
||||
for key in [
|
||||
HintKey::Confirm,
|
||||
HintKey::Back,
|
||||
HintKey::Secondary,
|
||||
HintKey::Tertiary,
|
||||
HintKey::Shoulders,
|
||||
HintKey::Adjust,
|
||||
HintKey::Up,
|
||||
HintKey::Down,
|
||||
] {
|
||||
assert!(resolved(key, style).is_some(), "{style:?} lost a hint");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ mod theme;
|
||||
mod widgets;
|
||||
|
||||
#[cfg(any(target_os = "linux", windows, target_os = "android"))]
|
||||
pub use console::{Console, ConsoleEntry, ConsoleHandles, Insets, Viewport};
|
||||
pub use console::{Console, ConsoleEntry, ConsoleHandles, InputSource, Insets, Viewport};
|
||||
#[cfg(any(target_os = "linux", windows, target_os = "android"))]
|
||||
pub use input::Key;
|
||||
#[cfg(any(target_os = "linux", windows, target_os = "android"))]
|
||||
|
||||
@@ -388,6 +388,7 @@ impl CollectionsScreen {
|
||||
Rect::from_ltrb(pills as f32, strip.top, strip.right, strip.bottom),
|
||||
&labels,
|
||||
selected,
|
||||
false,
|
||||
fonts,
|
||||
k,
|
||||
dt,
|
||||
|
||||
@@ -1641,6 +1641,7 @@ impl LibraryScreen {
|
||||
),
|
||||
&sorts,
|
||||
sort_at,
|
||||
false,
|
||||
fonts,
|
||||
k,
|
||||
dt,
|
||||
@@ -1660,6 +1661,7 @@ impl LibraryScreen {
|
||||
),
|
||||
&views,
|
||||
view_at,
|
||||
false,
|
||||
fonts,
|
||||
k,
|
||||
dt,
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::widgets::{
|
||||
permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H,
|
||||
};
|
||||
use pf_client_core::audio_format::{AUDIO_FORMATS, AUDIO_FORMAT_OPUS};
|
||||
use pf_client_core::menu_nav::{MenuEvent, MenuPulse};
|
||||
use pf_client_core::menu_nav::{MenuDir, MenuEvent, MenuPulse};
|
||||
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
|
||||
@@ -384,6 +384,11 @@ pub(crate) struct SettingsScreen {
|
||||
/// can't create profiles (design §5.4: the desktop app does), so the list is stable
|
||||
/// for the screen's lifetime.
|
||||
profiles: Vec<(String, String)>,
|
||||
/// The tab strip holds the D-pad focus (Up from the list's top row steps onto it;
|
||||
/// Down/A step back off). This is how a device with ONLY a D-pad — a Chromecast/Google
|
||||
/// TV remote — switches tabs at all: the shoulder ring (L1/R1) and the Tab/PgUp/PgDn
|
||||
/// keys don't exist there, and a field report lost every tab but the first to that.
|
||||
strip_focus: bool,
|
||||
/// The Bitrate row's typed rate in Mbps while Y has the field open — `None` the rest of
|
||||
/// the time. Every other row on this screen is a list of options, and a ladder is the
|
||||
/// right shape for a list; a bitrate is a NUMBER, and the one a link actually carries is
|
||||
@@ -409,6 +414,7 @@ impl SettingsScreen {
|
||||
tab: 0,
|
||||
tab_cursors: [0; TABS.len()],
|
||||
profiles,
|
||||
strip_focus: false,
|
||||
custom_bitrate: None,
|
||||
keyboard: Keyboard::new(),
|
||||
}
|
||||
@@ -609,6 +615,11 @@ impl SettingsScreen {
|
||||
self.show_tab(tab, ctx);
|
||||
return true;
|
||||
}
|
||||
// A pointer press on the rows takes the focus back from the strip — direct
|
||||
// manipulation names its own target.
|
||||
if p.press() {
|
||||
self.strip_focus = false;
|
||||
}
|
||||
let ids = self.row_ids(ctx);
|
||||
self.clamp_cursor(ids.len());
|
||||
let (msg, pulse) = self.list.pointer(p, ids.len());
|
||||
@@ -628,6 +639,26 @@ impl SettingsScreen {
|
||||
if self.custom_bitrate.is_some() {
|
||||
return self.custom_menu(ev, ctx);
|
||||
}
|
||||
if self.strip_focus {
|
||||
// The strip holds the D-pad focus: left/right travel the ring, down/A drop back
|
||||
// to the rows, B still leaves the screen. This is the only tab path a device
|
||||
// with no shoulders and no Tab key has (a TV remote).
|
||||
return match ev {
|
||||
MenuEvent::Back => {
|
||||
fx.pop();
|
||||
None
|
||||
}
|
||||
MenuEvent::Move(MenuDir::Left) | MenuEvent::JumpBack => self.switch_tab(-1, ctx),
|
||||
MenuEvent::Move(MenuDir::Right) | MenuEvent::JumpForward => self.switch_tab(1, ctx),
|
||||
MenuEvent::Move(MenuDir::Down) | MenuEvent::Confirm => {
|
||||
self.strip_focus = false;
|
||||
Some(MenuPulse::Move)
|
||||
}
|
||||
// The top of the screen — the same recoil the list's ends answer with.
|
||||
MenuEvent::Move(MenuDir::Up) => Some(MenuPulse::Boundary),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
match ev {
|
||||
MenuEvent::Back => {
|
||||
fx.pop();
|
||||
@@ -635,6 +666,12 @@ impl SettingsScreen {
|
||||
}
|
||||
MenuEvent::JumpBack => return self.switch_tab(-1, ctx),
|
||||
MenuEvent::JumpForward => return self.switch_tab(1, ctx),
|
||||
// Up from the top row steps onto the tab strip instead of recoiling — the
|
||||
// D-pad-only path to the other tabs.
|
||||
MenuEvent::Move(MenuDir::Up) if self.list.cursor == 0 => {
|
||||
self.strip_focus = true;
|
||||
return Some(MenuPulse::Move);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let ids = self.row_ids(ctx);
|
||||
@@ -765,6 +802,15 @@ impl SettingsScreen {
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
];
|
||||
}
|
||||
// The strip has the focus (a D-pad-only remote's tab path): say what the D-pad does
|
||||
// up here, not what the rows would do.
|
||||
if self.strip_focus {
|
||||
return vec![
|
||||
Hint::new(HintKey::Adjust, "Section"),
|
||||
Hint::new(HintKey::Confirm, "Rows"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
];
|
||||
}
|
||||
let ids = self.row_ids(ctx);
|
||||
// The shoulders always change section, so that hint leads on every row.
|
||||
let mut hints = vec![Hint::new(HintKey::Shoulders, "Section")];
|
||||
@@ -812,6 +858,7 @@ impl SettingsScreen {
|
||||
Rect::from_ltrb(rect.left, rect.top, rect.right, rect.top + strip_h as f32),
|
||||
&labels,
|
||||
self.tab,
|
||||
self.strip_focus,
|
||||
fonts,
|
||||
k,
|
||||
dt,
|
||||
@@ -857,7 +904,8 @@ impl SettingsScreen {
|
||||
fonts,
|
||||
k,
|
||||
dt,
|
||||
self.custom_bitrate.is_none(),
|
||||
// The rows rest their focus ring while the keyboard tray or the strip holds it.
|
||||
self.custom_bitrate.is_none() && !self.strip_focus,
|
||||
);
|
||||
let detail = ids
|
||||
.get(self.list.cursor)
|
||||
@@ -2718,6 +2766,50 @@ pub(super) mod tests {
|
||||
assert!(fx.nav.is_none() && fx.cmds.is_empty());
|
||||
}
|
||||
|
||||
/// A D-pad alone reaches every tab: Up from the top row steps onto the strip,
|
||||
/// left/right travel it, Down drops back into the rows. This is the only tab path a
|
||||
/// Chromecast/Google TV remote has — no shoulders, no Tab key — and it regressed to
|
||||
/// "first tab only" when the rows were split across tabs.
|
||||
#[test]
|
||||
fn dpad_alone_reaches_every_tab() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform: crate::platform::Platform::Desktop,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
fallback_ui: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
let mut fx = Outbox::default();
|
||||
// Up from the top row focuses the strip instead of recoiling…
|
||||
assert_eq!(s.list.cursor, 0);
|
||||
s.menu(MenuEvent::Move(MenuDir::Up), &mut ctx, &mut fx);
|
||||
assert!(s.strip_focus, "Up from the top row lands on the strip");
|
||||
// …right travels the ring…
|
||||
s.menu(MenuEvent::Move(MenuDir::Right), &mut ctx, &mut fx);
|
||||
assert_eq!(s.tab, 1);
|
||||
assert!(s.strip_focus, "switching keeps the strip focused");
|
||||
s.menu(MenuEvent::Move(MenuDir::Left), &mut ctx, &mut fx);
|
||||
s.menu(MenuEvent::Move(MenuDir::Left), &mut ctx, &mut fx);
|
||||
assert_eq!(s.tab, PROFILES_TAB, "the strip wraps like the shoulders do");
|
||||
// …and Down returns to the rows of the tab that's showing.
|
||||
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
|
||||
assert!(!s.strip_focus, "Down drops back into the list");
|
||||
// While the list has focus, Left/Right still adjust rows — only the top row's Up
|
||||
// reaches the strip, so a value row's chevrons keep meaning what they say.
|
||||
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
|
||||
assert!(!s.strip_focus);
|
||||
// Focusing the strip is navigation, never a settings write.
|
||||
assert!(fx.nav.is_none() && fx.cmds.is_empty());
|
||||
}
|
||||
|
||||
/// The lossless opt-in: it ships OFF, steps the cross-client table verbatim, sits directly
|
||||
/// under the channel count, and follows it — dim and inert under 5.1/7.1, because this
|
||||
/// client's session refuses to ASK for lossless surround (see the `enabled` note in
|
||||
|
||||
@@ -289,6 +289,12 @@ pub(crate) struct Shell {
|
||||
/// (or out of) calm alongside the screen transition.
|
||||
bg_mix: f64,
|
||||
glyphs: GlyphStyle,
|
||||
/// What drove the console LAST — a pad or keys — noted at the input seams
|
||||
/// ([`Shell::note_input_source`], [`Shell::key`]) and read by the per-frame glyph
|
||||
/// resolution, so the legend speaks the language of the device actually in use.
|
||||
/// `None` until anything drives: the style then follows the connected pad, or the
|
||||
/// platform's key device where there is none.
|
||||
input_source: Option<crate::console::InputSource>,
|
||||
chip: Option<String>,
|
||||
pads: Vec<PadInfo>,
|
||||
/// The settled top screen's hint-bar hit boxes, republished every frame by
|
||||
@@ -373,6 +379,7 @@ impl Shell {
|
||||
ink,
|
||||
bg_mix,
|
||||
glyphs: GlyphStyle::Keyboard,
|
||||
input_source: None,
|
||||
chip: None,
|
||||
pads: Vec::new(),
|
||||
hint_rects: Vec::new(),
|
||||
@@ -997,6 +1004,13 @@ impl Shell {
|
||||
consumed
|
||||
}
|
||||
|
||||
/// Note what produced the menu events now arriving — the hint legend follows it.
|
||||
/// Called by [`crate::console::Console::menu`] (which is told by its host) and the
|
||||
/// overlay's pad path; the keyboard path notes itself in [`Shell::key`].
|
||||
pub(crate) fn note_input_source(&mut self, source: crate::console::InputSource) {
|
||||
self.input_source = Some(source);
|
||||
}
|
||||
|
||||
/// The keyboard fallback — the console is fully drivable with no pad. Arrows and
|
||||
/// Enter/Esc map onto menu events; Y/X mirror the pad's Secondary/Tertiary
|
||||
/// (suppressed while editing, where letters are text).
|
||||
@@ -1004,6 +1018,7 @@ impl Shell {
|
||||
/// `shift` only matters for Tab, whose two directions are one key.
|
||||
pub(crate) fn key(&mut self, key: crate::input::Key, shift: bool, repeat: bool) -> bool {
|
||||
use crate::input::Key as S;
|
||||
self.input_source = Some(crate::console::InputSource::Keys);
|
||||
if self.editing() {
|
||||
let mut ctx = Ctx {
|
||||
hosts: &self.hosts,
|
||||
|
||||
@@ -81,12 +81,16 @@ impl Shell {
|
||||
let reduce = self.settings.reduce_motion;
|
||||
crate::theme::set_reduce_motion(reduce);
|
||||
self.pads = pads.to_vec();
|
||||
self.glyphs = GlyphStyle::from_pref(pad_pref);
|
||||
self.glyphs = glyph_style(self.input_source, pad_pref, self.platform);
|
||||
// Compared before it is rebuilt: this string changes when someone plugs a controller
|
||||
// in, and was being re-allocated 60 times a second to say so. (`pads` above is left
|
||||
// alone — it is at most a handful of small structs, and `PadInfo` would have to grow a
|
||||
// `PartialEq` in another crate to be worth the same treatment.)
|
||||
let chip = pad.unwrap_or("No controller — keyboard works too");
|
||||
let chip = pad.unwrap_or(if self.glyphs == GlyphStyle::Remote {
|
||||
"TV remote — a controller works too"
|
||||
} else {
|
||||
"No controller — keyboard works too"
|
||||
});
|
||||
if self.chip.as_deref() != Some(chip) {
|
||||
self.chip = Some(chip.to_owned());
|
||||
}
|
||||
@@ -427,3 +431,86 @@ impl LayerEnv<'_> {
|
||||
rects
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyph style for this frame: the last input source rules — keys speak the
|
||||
/// platform's key device (a TV remote on Android, a keyboard on the desktop), a pad
|
||||
/// speaks its own family ([`GlyphStyle::from_pref`]). Before anything has driven, the
|
||||
/// connected pad's family shows if there is one (a pad in hand is what a fresh console
|
||||
/// will most likely be driven by), else the platform's key device.
|
||||
fn glyph_style(
|
||||
source: Option<crate::console::InputSource>,
|
||||
pad_pref: Option<punktfunk_core::config::GamepadPref>,
|
||||
platform: crate::platform::Platform,
|
||||
) -> GlyphStyle {
|
||||
let keys = || match platform {
|
||||
crate::platform::Platform::Android => GlyphStyle::Remote,
|
||||
crate::platform::Platform::Desktop => GlyphStyle::Keyboard,
|
||||
};
|
||||
match (source, pad_pref) {
|
||||
(Some(crate::console::InputSource::Keys), _) => keys(),
|
||||
(_, Some(p)) => GlyphStyle::from_pref(Some(p)),
|
||||
(_, None) => keys(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod glyph_style_tests {
|
||||
use super::*;
|
||||
use crate::console::InputSource;
|
||||
use crate::platform::Platform;
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
|
||||
/// The matrix the field report walked: a Chromecast (Android, no pad) used to show
|
||||
/// keyboard keycaps — Enter/Esc/Tab, none of which its remote has. Keys on Android
|
||||
/// now read as the remote, keys on the desktop as the keyboard, a driving pad as its
|
||||
/// own family — and a pad that vanishes mid-session falls back to the platform's key
|
||||
/// device rather than freezing on the departed pad's letters.
|
||||
#[test]
|
||||
fn the_legend_follows_what_drives() {
|
||||
let xbox = Some(GamepadPref::Xbox360);
|
||||
// Untouched console: the connected pad's family, else the platform's key device.
|
||||
assert_eq!(
|
||||
glyph_style(None, xbox, Platform::Android),
|
||||
GlyphStyle::Letters
|
||||
);
|
||||
assert_eq!(
|
||||
glyph_style(None, None, Platform::Android),
|
||||
GlyphStyle::Remote
|
||||
);
|
||||
assert_eq!(
|
||||
glyph_style(None, None, Platform::Desktop),
|
||||
GlyphStyle::Keyboard
|
||||
);
|
||||
// Keys drove last: the key device, even with a pad still connected.
|
||||
assert_eq!(
|
||||
glyph_style(Some(InputSource::Keys), xbox, Platform::Android),
|
||||
GlyphStyle::Remote
|
||||
);
|
||||
assert_eq!(
|
||||
glyph_style(Some(InputSource::Keys), xbox, Platform::Desktop),
|
||||
GlyphStyle::Keyboard
|
||||
);
|
||||
// A pad drove last: its family — and Nintendo reads Nintendo.
|
||||
assert_eq!(
|
||||
glyph_style(
|
||||
Some(InputSource::Pad),
|
||||
Some(GamepadPref::SwitchPro),
|
||||
Platform::Desktop
|
||||
),
|
||||
GlyphStyle::Nintendo
|
||||
);
|
||||
assert_eq!(
|
||||
glyph_style(
|
||||
Some(InputSource::Pad),
|
||||
Some(GamepadPref::DualSense),
|
||||
Platform::Android
|
||||
),
|
||||
GlyphStyle::Shapes
|
||||
);
|
||||
// The pad drove, then unplugged: back to the platform's key device.
|
||||
assert_eq!(
|
||||
glyph_style(Some(InputSource::Pad), None, Platform::Android),
|
||||
GlyphStyle::Remote
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1348,6 +1348,17 @@ fn dump_console_screens() {
|
||||
s.set_connecting(None);
|
||||
s.session_failed("Connection timed out");
|
||||
dump(&mut s, 10, 8, "10-toast", true);
|
||||
|
||||
// The TV-remote legend (Android platform, keys driving): the OK and ↩ badges, the
|
||||
// ▲ section pointer, the hidden Y/X hints, and the remote chip mark — Home and the
|
||||
// hint-dense Settings. The platform flip is legends-only for these two frames; the
|
||||
// stack was built desktop, so only the glyphs and the Android row set differ.
|
||||
dump(&mut s, 30, 8, "_remote-settle", true);
|
||||
s.platform = crate::platform::Platform::Android;
|
||||
s.note_input_source(crate::console::InputSource::Keys);
|
||||
dump(&mut s, 40, 8, "11-home-remote", false);
|
||||
s.handle_menu(MenuEvent::Tertiary);
|
||||
dump(&mut s, 40, 8, "11b-settings-remote", false);
|
||||
}
|
||||
|
||||
/// A 2:3 poster, PNG-encoded, in a colour derived from `seed`.
|
||||
|
||||
@@ -301,7 +301,12 @@ impl Overlay for SkiaOverlay {
|
||||
|
||||
fn handle_menu(&mut self, event: MenuEvent) -> Option<MenuPulse> {
|
||||
if self.console_visible() {
|
||||
self.shell.as_mut().and_then(|s| s.handle_menu(event))
|
||||
self.shell.as_mut().and_then(|s| {
|
||||
// The presenter's menu_rx carries pad events only (its keyboard goes
|
||||
// through `key`), so this seam IS the pad source note.
|
||||
s.note_input_source(crate::console::InputSource::Pad);
|
||||
s.handle_menu(event)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -708,6 +708,10 @@ impl TabStrip {
|
||||
/// Draw the pills along the leading edge of `rect`'s top band, at the same
|
||||
/// [`EDGE_INSET`] the heading above them uses. Returns nothing — the caller already
|
||||
/// knows the band is [`TAB_STRIP_H`] tall.
|
||||
///
|
||||
/// `focused` = the strip itself holds the D-pad focus (a remote with no shoulder
|
||||
/// buttons steps onto it from the list's top row): the highlight brightens and grows
|
||||
/// ‹ › chevrons, the same affordance a focused value row shows for left/right.
|
||||
#[allow(clippy::too_many_arguments)] // the crate's render signature, same as MenuList's
|
||||
pub(crate) fn render(
|
||||
&mut self,
|
||||
@@ -715,6 +719,7 @@ impl TabStrip {
|
||||
rect: Rect,
|
||||
labels: &[&str],
|
||||
selected: usize,
|
||||
focused: bool,
|
||||
fonts: &Fonts,
|
||||
k: f64,
|
||||
dt: f64,
|
||||
@@ -770,10 +775,16 @@ impl TabStrip {
|
||||
canvas,
|
||||
Rect::from_xywh(ix as f32, top as f32, iw as f32, pill_h as f32),
|
||||
(pill_h / 2.0 / k) as f32,
|
||||
Some(accent(0.85)),
|
||||
PanelStroke::Plain(0.22),
|
||||
Some(accent(if focused { 1.0 } else { 0.85 })),
|
||||
PanelStroke::Plain(if focused { 0.5 } else { 0.22 }),
|
||||
k as f32,
|
||||
);
|
||||
if focused {
|
||||
// The focused value row's ‹ › affordance, on the strip: left/right travel here.
|
||||
let cy = top + pill_h / 2.0;
|
||||
chevron(canvas, ix - 9.0 * k, cy, 4.0 * k, true, 0.9);
|
||||
chevron(canvas, ix + iw + 9.0 * k, cy, 4.0 * k, false, 0.9);
|
||||
}
|
||||
|
||||
let baseline = top + pill_h / 2.0 + size * 0.36;
|
||||
self.pills.clear();
|
||||
@@ -1277,18 +1288,18 @@ mod tests {
|
||||
let dt = 1.0 / 60.0;
|
||||
// Seat on the first tab, then a 5-step burst at one press per frame — far faster
|
||||
// than the spring can settle, which is the whole point.
|
||||
strip.render(surface.canvas(), rect, &TABS, 0, &fonts, 1.0, dt);
|
||||
strip.render(surface.canvas(), rect, &TABS, 0, false, &fonts, 1.0, dt);
|
||||
let mut worst_left = f64::MAX;
|
||||
let mut worst_right = f64::MIN;
|
||||
for sel in 1..=5 {
|
||||
strip.render(surface.canvas(), rect, &TABS, sel, &fonts, 1.0, dt);
|
||||
strip.render(surface.canvas(), rect, &TABS, sel, false, &fonts, 1.0, dt);
|
||||
let (ix, iw) = strip.indicator.map(|(x, w)| (x.pos, w.pos)).unwrap();
|
||||
worst_left = worst_left.min(ix);
|
||||
worst_right = worst_right.max(ix + iw);
|
||||
}
|
||||
// Then let it land.
|
||||
for _ in 0..240 {
|
||||
strip.render(surface.canvas(), rect, &TABS, 5, &fonts, 1.0, dt);
|
||||
strip.render(surface.canvas(), rect, &TABS, 5, false, &fonts, 1.0, dt);
|
||||
let (ix, iw) = strip.indicator.map(|(x, w)| (x.pos, w.pos)).unwrap();
|
||||
worst_left = worst_left.min(ix);
|
||||
worst_right = worst_right.max(ix + iw);
|
||||
@@ -1324,7 +1335,7 @@ mod tests {
|
||||
let mut run = |w: f32, k: f64| {
|
||||
let rect = Rect::from_xywh(0.0, 0.0, w, (TAB_STRIP_H * k) as f32);
|
||||
let mut strip = TabStrip::new();
|
||||
strip.render(surface.canvas(), rect, &TABS, 0, &fonts, k, dt);
|
||||
strip.render(surface.canvas(), rect, &TABS, 0, false, &fonts, k, dt);
|
||||
let first = strip.pill(0).expect("the first section was drawn");
|
||||
let last = strip
|
||||
.pill(TABS.len() - 1)
|
||||
|
||||
@@ -137,11 +137,22 @@ impl KwinDisplay {
|
||||
/// absent. Records the output's UUID (in-process) or kscreen address (fallback) for
|
||||
/// [`apply_position`](VirtualDisplay::apply_position), and returns the disabled outputs (each
|
||||
/// `(name, "WxH@Hz")`) for the group teardown restore. `Extend`/`Auto` disable nothing.
|
||||
///
|
||||
/// `pre_enabled` is the non-managed outputs that were enabled BEFORE the virtual output was
|
||||
/// created ([`enabled_physicals`], captured in `create`). It exists because KWin reacts to our
|
||||
/// output appearing by applying its stored setup for the NEW monitor set
|
||||
/// (`kwinoutputconfig.json`), and a set that ever ran `exclusive` has "physicals disabled"
|
||||
/// stored — so the physicals can be OFF by the time any post-create enumeration runs. A field
|
||||
/// report (Bazzite, triple-monitor) showed exactly that: `also_disabled=[]` on an exclusive
|
||||
/// apply, so teardown restored nothing and the desk stayed dark. The snapshot is the only read
|
||||
/// KWin's reaction cannot have polluted: under `Exclusive` it joins the restore list, under
|
||||
/// every other topology [`reenable_stranded`] puts the stored-config casualties back on.
|
||||
fn apply_topology(
|
||||
&mut self,
|
||||
name: &str,
|
||||
our_prefix: &str,
|
||||
dims: (u32, u32),
|
||||
pre_enabled: &[(String, String)],
|
||||
) -> Vec<(String, String)> {
|
||||
use crate::kwin_output_mgmt::TopologyKind;
|
||||
use crate::policy::Topology;
|
||||
@@ -155,6 +166,10 @@ impl KwinDisplay {
|
||||
// (stable) output name for whatever monitor set it was saved under. Applies only if
|
||||
// it really is mirroring; nothing else about the user's arrangement is touched.
|
||||
crate::kwin_output_mgmt::clear_replication_source(our_prefix, dims.0, dims.1);
|
||||
// ...and the same goes for the stored ENABLED state: these topologies promise the
|
||||
// user's screens stay untouched, so KWin switching them off in reaction to our
|
||||
// output appearing is undone, not honored.
|
||||
reenable_stranded(pre_enabled.to_vec());
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
@@ -162,7 +177,13 @@ impl KwinDisplay {
|
||||
let outcome = crate::kwin_output_mgmt::apply_topology(our_prefix, dims.0, dims.1, kind);
|
||||
if outcome.handled {
|
||||
self.our_uuid = outcome.our_uuid;
|
||||
return outcome.disabled;
|
||||
if kind == TopologyKind::Primary {
|
||||
// `Primary` keeps the physicals enabled by contract — undo KWin's stored-config
|
||||
// disable exactly as the Extend arm does. Nothing to restore at teardown.
|
||||
reenable_stranded(pre_enabled.to_vec());
|
||||
return outcome.disabled;
|
||||
}
|
||||
return union_restore(outcome.disabled, pre_enabled);
|
||||
}
|
||||
// Fallback: kscreen-doctor — resolve our address the old way, then shell out the topology.
|
||||
tracing::info!(
|
||||
@@ -171,9 +192,10 @@ impl KwinDisplay {
|
||||
let addr = resolve_kscreen_addr(name, dims.0, dims.1);
|
||||
self.last_name = Some(addr.clone());
|
||||
match topology {
|
||||
Topology::Exclusive => apply_virtual_primary(&addr),
|
||||
Topology::Exclusive => union_restore(apply_virtual_primary(&addr), pre_enabled),
|
||||
Topology::Primary => {
|
||||
apply_virtual_primary_only(&addr);
|
||||
reenable_stranded(pre_enabled.to_vec());
|
||||
Vec::new()
|
||||
}
|
||||
Topology::Extend | Topology::Auto => Vec::new(),
|
||||
@@ -324,6 +346,10 @@ impl VirtualDisplay for KwinDisplay {
|
||||
// install — the output is born at the real size and 60 Hz is the offer anyway.
|
||||
let want_high = mode.refresh_hz > 60;
|
||||
let birth_h = if want_high { height + 16 } else { height };
|
||||
// Snapshot the enabled physicals BEFORE the virtual output exists: creating it changes the
|
||||
// monitor set, and KWin may apply a stored setup for the new set that disables them (see
|
||||
// `apply_topology`). Everything read after this point can already be polluted by that.
|
||||
let pre_enabled = enabled_physicals();
|
||||
let (mut node_id, mut stop) = spawn_vout(width, birth_h)?;
|
||||
// `requested_*`, NOT `width`/`height`: `spawn_vout` hands back a node id, never a size, so
|
||||
// every number on this line is what we ASKED for. Logged as `width=… height=…` it read like
|
||||
@@ -534,7 +560,7 @@ impl VirtualDisplay for KwinDisplay {
|
||||
// bootstrap output. Applied over kde_output_management_v2 in-process (immune to a wedged
|
||||
// kscreen-doctor backend; see `apply_topology`), with a kscreen-doctor fallback. `disabled`
|
||||
// is the physical/bootstrap outputs, each `(name, "WxH@Hz")`, to restore on teardown.
|
||||
let disabled = self.apply_topology(&name, &our_prefix, final_dims);
|
||||
let disabled = self.apply_topology(&name, &our_prefix, final_dims, &pre_enabled);
|
||||
// `last_name` is already the best address we have: `Virtual-<name>` from the top of this
|
||||
// function, upgraded in place to the RESOLVED numeric kscreen id by whichever of the
|
||||
// `want_high` fallback or `apply_topology`'s fallback actually ran a resolve. Nothing to
|
||||
@@ -561,6 +587,21 @@ impl VirtualDisplay for KwinDisplay {
|
||||
if !crate::kwin_output_mgmt::reenable_outputs(&disabled) {
|
||||
reenable_outputs_kscreen(&disabled);
|
||||
}
|
||||
// This ran BEFORE our output is reclaimed (§6.1 ordering, so KWin never sees zero
|
||||
// outputs) — which means it applied under the WITH-us monitor set. Reclaiming the
|
||||
// output then flips KWin to the without-us set, whose stored setup can put the
|
||||
// physicals straight back to disabled (how the field report's desk ended up dark
|
||||
// after every stream). One delayed re-assert lands after the reclaim, under THAT
|
||||
// set — and being a user-applied config, KWin persists it there, healing the
|
||||
// stored setup instead of re-fighting it next session. One shot, never a loop.
|
||||
let verify = disabled.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-kwin-restore-verify".into())
|
||||
.spawn(move || {
|
||||
std::thread::sleep(STRAND_RECHECK_DELAY);
|
||||
reenable_pass(&verify, "post-teardown", true);
|
||||
})
|
||||
.ok();
|
||||
}) as Box<dyn FnOnce() + Send>
|
||||
});
|
||||
// Layout position (§6.2) is applied by the registry via `apply_position` right after create
|
||||
@@ -575,6 +616,95 @@ impl VirtualDisplay for KwinDisplay {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long after a create/teardown to re-check for outputs KWin's stored setup switched off.
|
||||
/// KWin applies the stored setup for a changed monitor set promptly, but not synchronously with
|
||||
/// our reads — the immediate pass catches the common case, this delayed one the late apply.
|
||||
const STRAND_RECHECK_DELAY: Duration = Duration::from_millis(2000);
|
||||
|
||||
/// The non-managed outputs currently enabled, each `(name, "WxH@Hz")` — the same spec shape the
|
||||
/// restore path stores, so the two lists interchange. Empty when output management is unavailable
|
||||
/// (the callers then simply keep today's behavior).
|
||||
fn enabled_physicals() -> Vec<(String, String)> {
|
||||
crate::kwin_output_mgmt::list_monitors()
|
||||
.map(|ms| {
|
||||
ms.into_iter()
|
||||
.filter(|m| m.enabled && !m.managed && m.width > 0 && m.height > 0)
|
||||
.map(|m| {
|
||||
let hz = ((m.refresh_mhz as f64) / 1000.0).round() as u32;
|
||||
(m.connector, format!("{}x{}@{hz}", m.width, m.height))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Undo KWin's stored-config reaction to our output appearing: any output in `pre_enabled` (lit
|
||||
/// BEFORE the virtual output was created) that is disabled now was switched off by KWin's persisted
|
||||
/// setup for the new monitor set — the enabled-state sibling of the stored `replicationSource`
|
||||
/// that [`crate::kwin_output_mgmt::clear_replication_source`] clears. Two passes: one now, one
|
||||
/// after [`STRAND_RECHECK_DELAY`] (KWin can apply the stored setup after our first read). Each is
|
||||
/// one shot, so a user's own later disable stays honored.
|
||||
fn reenable_stranded(pre_enabled: Vec<(String, String)>) {
|
||||
if pre_enabled.is_empty() {
|
||||
return;
|
||||
}
|
||||
reenable_pass(&pre_enabled, "immediate", false);
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-kwin-reenable".into())
|
||||
.spawn(move || {
|
||||
std::thread::sleep(STRAND_RECHECK_DELAY);
|
||||
reenable_pass(&pre_enabled, "delayed", false);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// One re-enable pass: re-read the outputs, re-enable `expected ∩ now-disabled`. `abort_if_managed`
|
||||
/// is the post-teardown guard: a managed output present by then means a NEW session already owns
|
||||
/// the topology (a quick reconnect), and lighting the physicals under its `exclusive` would undo
|
||||
/// it — that session's own restore covers them instead.
|
||||
fn reenable_pass(expected: &[(String, String)], wave: &'static str, abort_if_managed: bool) {
|
||||
let Ok(now) = crate::kwin_output_mgmt::list_monitors() else {
|
||||
return;
|
||||
};
|
||||
if abort_if_managed && now.iter().any(|m| m.managed) {
|
||||
return;
|
||||
}
|
||||
let dark: Vec<(String, String)> = expected
|
||||
.iter()
|
||||
.filter(|(name, _)| now.iter().any(|m| &m.connector == name && !m.enabled))
|
||||
.cloned()
|
||||
.collect();
|
||||
if dark.is_empty() {
|
||||
return;
|
||||
}
|
||||
tracing::warn!(
|
||||
outputs = ?dark,
|
||||
wave,
|
||||
"KWin's stored output setup (kwinoutputconfig.json) left physical output(s) disabled that \
|
||||
the current topology says stay enabled — re-enabling them"
|
||||
);
|
||||
if !crate::kwin_output_mgmt::reenable_outputs(&dark) {
|
||||
reenable_outputs_kscreen(&dark);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `exclusive` restore list: what this apply disabled, plus every `pre_enabled` output not
|
||||
/// already in it — an output KWin's stored setup disabled in the create window was invisible to
|
||||
/// the apply's own enumeration, and dropping it from the list is how a teardown restored nothing
|
||||
/// on a triple-monitor box (`also_disabled=[]`). Re-enabling an output that was never actually
|
||||
/// disabled is a documented no-op, so the union errs on the side of the desk lighting up.
|
||||
fn union_restore(
|
||||
mut disabled: Vec<(String, String)>,
|
||||
pre_enabled: &[(String, String)],
|
||||
) -> Vec<(String, String)> {
|
||||
for (name, spec) in pre_enabled {
|
||||
if !disabled.iter().any(|(n, _)| n == name) {
|
||||
disabled.push((name.clone(), spec.clone()));
|
||||
}
|
||||
}
|
||||
disabled
|
||||
}
|
||||
|
||||
/// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical) via `kscreen-doctor`
|
||||
/// — the fallback for the in-process [`crate::kwin_output_mgmt::reenable_outputs`], run by the restore
|
||||
/// closure only when the in-process path reports the compositor didn't answer. Called by the registry
|
||||
@@ -2044,10 +2174,34 @@ fn await_created(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
mode_satisfies, modes_from_json, monitors_from_kscreen_json, pick_custom_mode, KModeRow,
|
||||
MANAGED_PREFIX,
|
||||
mode_satisfies, modes_from_json, monitors_from_kscreen_json, pick_custom_mode,
|
||||
union_restore, KModeRow, MANAGED_PREFIX,
|
||||
};
|
||||
|
||||
/// The field failure the union guards: KWin's stored setup for the with-us monitor set
|
||||
/// disabled the physicals in the window between our output's creation and the exclusive
|
||||
/// apply's enumeration, so the apply saw nothing enabled (`also_disabled=[]`) and teardown
|
||||
/// restored nothing — a triple-monitor desk stranded dark. The pre-create snapshot must
|
||||
/// reach the restore list; what the apply itself disabled keeps its (fresher) entry.
|
||||
#[test]
|
||||
fn the_restore_list_covers_outputs_kwin_disabled_before_the_apply_saw_them() {
|
||||
let pre = vec![
|
||||
("DP-1".to_string(), "2560x1440@144".to_string()),
|
||||
("DP-2".to_string(), "2560x1440@60".to_string()),
|
||||
("DP-3".to_string(), "1920x1080@60".to_string()),
|
||||
];
|
||||
// The log's case: the apply enumerated nothing enabled.
|
||||
assert_eq!(union_restore(Vec::new(), &pre), pre);
|
||||
// The healthy case: the apply's own capture wins for the outputs it saw (its spec is
|
||||
// the fresher read), and the snapshot only fills the gaps.
|
||||
let seen = vec![("DP-1".to_string(), "2560x1440@120".to_string())];
|
||||
let merged = union_restore(seen, &pre);
|
||||
assert_eq!(merged.len(), 3);
|
||||
assert_eq!(merged[0], ("DP-1".to_string(), "2560x1440@120".to_string()));
|
||||
assert!(merged.contains(&("DP-2".to_string(), "2560x1440@60".to_string())));
|
||||
assert!(merged.contains(&("DP-3".to_string(), "1920x1080@60".to_string())));
|
||||
}
|
||||
|
||||
/// The field failure this predicate now guards, in the shape the log reported it: a client
|
||||
/// negotiated 3840x2160, KWin restored a stored 1920x1080 for the output name, and nothing
|
||||
/// compared the two — so the session captured 1080p, encoded 1080p, and shipped it to a client
|
||||
|
||||
@@ -552,10 +552,27 @@ impl BitrateController {
|
||||
}
|
||||
|
||||
/// Teach the controller what this session's mode and codec could plausibly use (see
|
||||
/// [`stream_ceiling_kbps`]). Applied to LEARNED ceilings only, at the same funnel as the
|
||||
/// [`stream_ceiling_kbps`]). Bounds future LEARNED ceilings at the same funnel as the
|
||||
/// operator's env cap.
|
||||
///
|
||||
/// The FIRST set is the session's negotiated shape and keeps the founding semantics — a
|
||||
/// negotiated start rate above it stands, the host resolved that number (pinned by
|
||||
/// `the_stream_bound_clamps_a_learned_ceiling_only`). A RE-set is a mode switch, and
|
||||
/// there a DROP in pixel rate also rebinds the already-standing ceiling: `set_ceiling`
|
||||
/// clamps only at learn time and deliberately never lowers, so a 4K-learned ceiling
|
||||
/// would otherwise stand over a 720p stream with only the reactive loss/decode signals
|
||||
/// to bound the climb (review §2.1).
|
||||
pub(crate) fn set_stream_cap(&mut self, kbps: u32) {
|
||||
let mode_switch = self.stream_cap_kbps.is_some();
|
||||
self.stream_cap_kbps = Some(kbps);
|
||||
if mode_switch && self.enabled && self.ceiling_kbps > kbps {
|
||||
tracing::info!(
|
||||
ceiling_kbps = self.ceiling_kbps,
|
||||
stream_cap_kbps = kbps,
|
||||
"adaptive bitrate: ceiling rebound to the switched mode's stream shape"
|
||||
);
|
||||
self.ceiling_kbps = kbps;
|
||||
}
|
||||
}
|
||||
|
||||
/// Teach the controller this session's refresh rate, so the encode thresholds can be sized in
|
||||
@@ -1551,6 +1568,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Review §2.1: the stream-shape cap was computed once from the Welcome mode and never
|
||||
/// again — 1080p→4K kept a 1080p-sized climb ceiling, 4K→720p left an oversized one
|
||||
/// standing. A mode switch now re-teaches the cap: an upswitch opens room for the probe's
|
||||
/// measurement to authorize more, a downswitch rebinds the already-learned ceiling.
|
||||
#[test]
|
||||
fn a_mode_switch_reteaches_the_stream_cap_both_ways() {
|
||||
// 1080p session, probe measured a fat link: ceiling bound at the 1080p shape.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
c.set_stream_cap(100_000);
|
||||
c.set_ceiling(657_000);
|
||||
assert_eq!(c.ceiling_kbps, 100_000);
|
||||
|
||||
// Switch UP to 4K: the new shape allows more, and the probe's measurement (already
|
||||
// taken this session) may re-authorize up to it.
|
||||
c.on_mode_switch();
|
||||
c.set_stream_cap(400_000);
|
||||
assert_eq!(
|
||||
c.ceiling_kbps, 100_000,
|
||||
"an upswitch alone raises nothing — authority still needs a measurement"
|
||||
);
|
||||
c.set_ceiling(657_000);
|
||||
assert_eq!(
|
||||
c.ceiling_kbps, 400_000,
|
||||
"the 4K shape no longer pins the session to the 1080p bound"
|
||||
);
|
||||
|
||||
// Switch DOWN to 720p: the learned 4K ceiling must not stand over the small stream —
|
||||
// `set_ceiling` never lowers, so the re-taught cap is what rebinds it.
|
||||
c.on_mode_switch();
|
||||
c.set_stream_cap(42_000);
|
||||
assert_eq!(
|
||||
c.ceiling_kbps, 42_000,
|
||||
"a downswitch rebinds the already-learned ceiling"
|
||||
);
|
||||
|
||||
// A disabled controller (explicit bitrate) is untouched by all of it.
|
||||
let mut d = BitrateController::new(0);
|
||||
d.set_stream_cap(100_000);
|
||||
d.set_stream_cap(42_000);
|
||||
assert_eq!(d.ceiling_kbps, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owd_rise_alone_is_a_congestion_signal() {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
|
||||
@@ -86,6 +86,10 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
let clock_rtt_ns = negotiated.clock_rtt_ns;
|
||||
let resolved_bitrate_kbps = negotiated.bitrate_kbps;
|
||||
let negotiated_codec = negotiated.codec;
|
||||
// Session constants a mode switch does not change — the pump recomputes the stream-shape
|
||||
// cap from them for the switched geometry (review §2.1).
|
||||
let bit_depth = negotiated.bit_depth;
|
||||
let chroma_format = negotiated.chroma_format;
|
||||
// What this session's mode + codec could plausibly use — the bound the ABR holds its
|
||||
// probe-measured link ceiling to. Computed here because this is where the Welcome-resolved
|
||||
// geometry lives; the data pump stays codec-agnostic.
|
||||
@@ -164,9 +168,14 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
}
|
||||
});
|
||||
|
||||
// Adaptive bitrate ack slot: the control task parks the latest BitrateChanged here; the
|
||||
// pump's controller drains it on its report tick (`take()` — an ack is consumed once).
|
||||
let bitrate_ack: Arc<Mutex<Option<u32>>> = Arc::new(Mutex::new(None));
|
||||
// Adaptive bitrate ack queue: the control task pushes every BitrateChanged; the pump's
|
||||
// controller drains them in arrival order on its report tick. A QUEUE, not a latest-wins
|
||||
// slot (review §2.4): a full resolve ack plus a corrective short retarget in the same
|
||||
// 750 ms window used to collapse to whichever arrived last, and host-cap learning needs
|
||||
// two CONSECUTIVE short acks — losing one delayed or prevented the cap and could
|
||||
// reintroduce the encoder-overdrive sawtooth.
|
||||
let bitrate_ack: Arc<Mutex<std::collections::VecDeque<u32>>> =
|
||||
Arc::new(Mutex::new(std::collections::VecDeque::new()));
|
||||
// Decode-recovery keyframe asks (the ABR recovery signal): the control task counts every
|
||||
// outbound `CtrlRequest::Keyframe` — the one choke point all emitters funnel through — and
|
||||
// the pump drains the count per report window.
|
||||
@@ -279,6 +288,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
bitrate_kbps,
|
||||
resolved_bitrate_kbps,
|
||||
negotiated_codec,
|
||||
bit_depth,
|
||||
chroma_format,
|
||||
stream_cap_kbps,
|
||||
refresh_hz,
|
||||
mode_slot: mode_slot_pump,
|
||||
|
||||
@@ -15,7 +15,7 @@ pub(super) struct ControlTask {
|
||||
pub(super) mode_slot: Arc<Mutex<Mode>>,
|
||||
pub(super) probe: Arc<Mutex<ProbeState>>,
|
||||
/// The latest host `BitrateChanged` ack, drained by the pump's ABR on its report tick.
|
||||
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
|
||||
pub(super) bitrate_ack: Arc<Mutex<std::collections::VecDeque<u32>>>,
|
||||
/// The live encoder-target mirror ([`NativeClient::current_bitrate_kbps`]): unlike the
|
||||
/// drain-once ack slot above, this one always holds the latest acked rate for stats HUDs.
|
||||
pub(super) live_bitrate: Arc<AtomicU32>,
|
||||
@@ -200,7 +200,7 @@ impl ControlTask {
|
||||
if ack.bitrate_kbps > 0 {
|
||||
live_bitrate.store(ack.bitrate_kbps, Ordering::Relaxed);
|
||||
}
|
||||
*bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps);
|
||||
bitrate_ack.lock().unwrap().push_back(ack.bitrate_kbps);
|
||||
} else if let Ok(gap) = crate::quic::PipelineGap::decode(&msg) {
|
||||
// The host rebuilt its capture ring + encoder in place and nothing flowed
|
||||
// while it did. Park it for the pump, which discards the report window in
|
||||
|
||||
@@ -27,7 +27,10 @@ pub(super) struct DataPump {
|
||||
pub(super) mode_gen: Arc<AtomicU32>,
|
||||
pub(super) frames_dropped: Arc<std::sync::atomic::AtomicU64>,
|
||||
pub(super) fec_recovered: Arc<std::sync::atomic::AtomicU64>,
|
||||
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
|
||||
/// Host `BitrateChanged` acks since the last report tick, drained in arrival order — a
|
||||
/// queue so a corrective short retarget can't be clobbered by a full resolve ack in the
|
||||
/// same window (review §2.4; host-cap learning needs two CONSECUTIVE short acks).
|
||||
pub(super) bitrate_ack: Arc<Mutex<std::collections::VecDeque<u32>>>,
|
||||
/// Outbound decode-recovery keyframe asks, counted by the control task at its send choke
|
||||
/// point; drained per report window as the ABR's recovery signal.
|
||||
pub(super) recovery_kf: Arc<AtomicU32>,
|
||||
@@ -40,9 +43,15 @@ pub(super) struct DataPump {
|
||||
/// The rate the host actually configured (echoed in Welcome).
|
||||
pub(super) resolved_bitrate_kbps: u32,
|
||||
pub(super) negotiated_codec: u8,
|
||||
/// The negotiated encode bit depth and chroma wire byte — session constants a mode switch
|
||||
/// does NOT change, carried so the stream-shape cap can be recomputed for a new geometry
|
||||
/// (review §2.1).
|
||||
pub(super) bit_depth: u8,
|
||||
pub(super) chroma_format: u8,
|
||||
/// What this session's mode + codec could plausibly use (see
|
||||
/// [`crate::abr::stream_ceiling_kbps`]) — the bound the probe-measured link ceiling is held
|
||||
/// to. Computed where the negotiated geometry lives, so this module stays codec-agnostic.
|
||||
/// to. Computed where the negotiated geometry lives; recomputed here on an accepted mode
|
||||
/// switch (review §2.1).
|
||||
pub(super) stream_cap_kbps: u32,
|
||||
/// The negotiated refresh, which sets the frame budget the ABR sizes its host-encode
|
||||
/// thresholds against (see [`crate::abr::BitrateController::set_frame_budget`]).
|
||||
@@ -74,6 +83,8 @@ impl DataPump {
|
||||
bitrate_kbps,
|
||||
resolved_bitrate_kbps,
|
||||
negotiated_codec,
|
||||
bit_depth,
|
||||
chroma_format,
|
||||
stream_cap_kbps,
|
||||
refresh_hz,
|
||||
mode_slot: pump_mode_slot,
|
||||
@@ -550,12 +561,26 @@ impl DataPump {
|
||||
if mg != seen_mode_gen {
|
||||
seen_mode_gen = mg;
|
||||
abr.on_mode_switch();
|
||||
let m = *pump_mode_slot.lock().unwrap();
|
||||
// The frame budget is a property of the MODE: a switch that changes the
|
||||
// refresh changes what one frame of encode time costs, and the encode
|
||||
// thresholds are sized in those.
|
||||
abr.set_frame_budget(pump_mode_slot.lock().unwrap().refresh_hz);
|
||||
abr.set_frame_budget(m.refresh_hz);
|
||||
// So is the stream-shape cap (review §2.1): computed once from the
|
||||
// Welcome mode, 1080p→4K kept a 1080p-sized climb ceiling (under-running
|
||||
// quality on a fat link) and 4K→720p left an oversized cap standing with
|
||||
// only the reactive loss/decode signals to bound the climb.
|
||||
// `set_stream_cap` also rebinds an already-learned ceiling downward.
|
||||
abr.set_stream_cap(crate::abr::stream_ceiling_kbps(
|
||||
m.width,
|
||||
m.height,
|
||||
m.refresh_hz,
|
||||
negotiated_codec,
|
||||
bit_depth,
|
||||
chroma_format,
|
||||
));
|
||||
}
|
||||
if let Some(acked) = bitrate_ack.lock().unwrap().take() {
|
||||
for acked in bitrate_ack.lock().unwrap().drain(..) {
|
||||
abr.on_ack(acked);
|
||||
}
|
||||
let owd_mean_us =
|
||||
@@ -1028,7 +1053,7 @@ mod tests {
|
||||
refresh_hz: 60,
|
||||
})),
|
||||
probe: Arc::new(Mutex::new(ProbeState::default())),
|
||||
bitrate_ack: Arc::new(Mutex::new(None)),
|
||||
bitrate_ack: Arc::new(Mutex::new(std::collections::VecDeque::new())),
|
||||
live_bitrate: Arc::new(AtomicU32::new(0)),
|
||||
recovery_kf: Arc::new(AtomicU32::new(0)),
|
||||
pipeline_gap: pipeline_gap.clone(),
|
||||
@@ -1064,12 +1089,14 @@ mod tests {
|
||||
mode_gen: Arc::new(AtomicU32::new(0)),
|
||||
frames_dropped: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
fec_recovered: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
bitrate_ack: Arc::new(Mutex::new(None)),
|
||||
bitrate_ack: Arc::new(Mutex::new(std::collections::VecDeque::new())),
|
||||
recovery_kf: Arc::new(AtomicU32::new(0)),
|
||||
pipeline_gap: pipeline_gap.clone(),
|
||||
bitrate_kbps: 20_000,
|
||||
resolved_bitrate_kbps: 20_000,
|
||||
negotiated_codec: crate::quic::CODEC_HEVC,
|
||||
bit_depth: 8,
|
||||
chroma_format: 0,
|
||||
stream_cap_kbps: 100_000,
|
||||
refresh_hz: 60,
|
||||
mode_slot: Arc::new(Mutex::new(crate::config::Mode {
|
||||
|
||||
@@ -643,9 +643,11 @@ impl Reassembler {
|
||||
// Slice frames have no uniform shape to demand — the invariant is positional:
|
||||
// every sentinel block must sit strictly below the final block's base
|
||||
// (`total_data − data_shards`; the firewall already proved the subtraction
|
||||
// safe) and be a non-final index. Sentinel-vs-sentinel overlap is not policed —
|
||||
// the sender is AEAD-authenticated, so a lying base can only corrupt this
|
||||
// frame's own pixels, never memory (placement stays in-bounds by these checks).
|
||||
// safe) and be a non-final index. Sentinel-vs-sentinel overlap is not policed
|
||||
// HERE — placement stays in-bounds by these checks, so a lying base can only
|
||||
// corrupt this frame's own bytes, never memory — but the completion tiling
|
||||
// check below refuses to deliver such a frame (black-band corruption from a
|
||||
// buggy, AEAD-authenticated sender would otherwise ship as `complete`).
|
||||
let final_base = total_data - data_shards;
|
||||
frame.blocks.iter().any(|(&bi, b)| {
|
||||
let bi = bi as usize;
|
||||
@@ -881,6 +883,31 @@ impl Reassembler {
|
||||
reconstructed_shards(&done.blocks, lim.max_data_shards),
|
||||
);
|
||||
*in_flight_bytes -= frame_cost(&done); // buffer + block state, before the truncate below
|
||||
// Slice-streamed frames: every base was bounds-checked on arrival (in range,
|
||||
// below the final block) but nothing yet proved the blocks TILE the AU. A base
|
||||
// that lies WITHIN bounds leaves a zero gap and an overlap — wrong bytes in a
|
||||
// frame stamped `complete`, which the decoder paints as garbage rectangles and
|
||||
// no loss counter ever moves. Refuse to deliver: the index is already in
|
||||
// `completed` (stragglers can't resurrect it), so just count the loss — the
|
||||
// `frames_dropped` climb is what fires the client's recovery request.
|
||||
if done.user_flags & crate::packet::USER_FLAG_SLICE_STREAM != 0 {
|
||||
let total_data = done.frame_bytes.div_ceil(done.shard_bytes).max(1);
|
||||
let mut next = 0usize;
|
||||
let tiled = (0..block_count).all(|bi| match done.blocks.get(&(bi as u16)) {
|
||||
Some(b) if b.base_shard == next => {
|
||||
next += b.data_shards;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}) && next == total_data;
|
||||
if !tiled {
|
||||
if !is_probe {
|
||||
StatsCounters::add(&stats.frames_dropped, 1);
|
||||
}
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
done.buf.truncate(done.frame_bytes); // trim trailing-shard zero padding
|
||||
// Slice-progressive consumers already hold the delivered prefix — the completing
|
||||
// packet hands up only the SUFFIX (with `last`), or the degenerate whole-AU part
|
||||
@@ -925,6 +952,12 @@ impl Reassembler {
|
||||
if block_count != 0 && (*next_part_block as usize) + 1 >= block_count {
|
||||
break;
|
||||
}
|
||||
// A prefix is only a prefix if this block starts where the last one ended —
|
||||
// a slice block whose wire base lies within bounds must not extend it (the
|
||||
// frame then dies at the completion tiling check above).
|
||||
if b.base_shard != *delivered_shards {
|
||||
break;
|
||||
}
|
||||
*delivered_shards = b.base_shard + b.data_shards;
|
||||
*next_part_block += 1;
|
||||
}
|
||||
|
||||
@@ -1186,6 +1186,56 @@ fn slice_streamed_lying_final_kills_frame() {
|
||||
assert_eq!(stats.snapshot().frames_dropped, 1);
|
||||
}
|
||||
|
||||
/// Completion tiling check: a sentinel base that lies WITHIN every bounds check (in range,
|
||||
/// below the final block) but breaks the tiling — a gap at the honest base, an overlap at
|
||||
/// the claimed one — must NOT be delivered as a `complete` frame (the black-band corruption
|
||||
/// shape: wrong-offset bytes with zeros in the gap and no loss counter moving). The frame
|
||||
/// is counted lost instead, which is what fires the client's recovery request.
|
||||
#[test]
|
||||
fn slice_streamed_lying_base_within_bounds_kills_frame() {
|
||||
let (pkts, _) = slice_streamed_packets();
|
||||
let hdr_of = |p: &Vec<u8>| PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
|
||||
|
||||
// Shift block 1's base from shard 19 (304 B) to shard 20 (320 B) on EVERY packet of the
|
||||
// block (the base is pinned by the block's first packet, so all must agree). Still
|
||||
// shard-aligned, still 20 + 26 = 46 ≤ 63 (the final block's base) — every pre-fix
|
||||
// check passes, and the frame would have completed with a one-shard zero gap at 19
|
||||
// and block 1's last shard overwriting block 2's first.
|
||||
let delivery: Vec<Vec<u8>> = pkts
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut h = hdr_of(p);
|
||||
if h.block_count == 0 && h.block_index == 1 {
|
||||
let mut p = p.clone();
|
||||
h.frame_bytes = 320;
|
||||
p[..HEADER_LEN].copy_from_slice(h.as_bytes());
|
||||
p
|
||||
} else {
|
||||
p.clone()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cfg = slice_config();
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
let stats = StatsCounters::default();
|
||||
assert!(
|
||||
push_all(&mut r, coder.as_ref(), &stats, &delivery).is_none(),
|
||||
"a mis-tiled frame must never be delivered"
|
||||
);
|
||||
assert_eq!(
|
||||
stats.snapshot().frames_dropped,
|
||||
1,
|
||||
"the mis-tiled frame must be counted lost"
|
||||
);
|
||||
assert_eq!(r.in_flight(), 0, "the killed frame must release its budget");
|
||||
|
||||
// Its packets are stragglers for a terminated index now — no resurrection, no recount.
|
||||
assert!(push_all(&mut r, coder.as_ref(), &stats, &delivery).is_none());
|
||||
assert_eq!(stats.snapshot().frames_dropped, 1);
|
||||
}
|
||||
|
||||
/// One slice bigger than a whole FEC block must cut MULTIPLE blocks from a single push (the
|
||||
/// flush loop) — the final block can never be left oversized.
|
||||
#[test]
|
||||
|
||||
@@ -2798,6 +2798,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// `interval` was built as 1/effective_hz, so the round-trip recovers the integer
|
||||
// rate.
|
||||
let hz = interval_hz(interval);
|
||||
// Timed for the `PipelineGap` below: the rebuild stalls capture for ~0.6 s,
|
||||
// and a client that isn't told discards its starved windows as congestion
|
||||
// (the 401 ms field case: slow start killed, minutes at ~15 Mbps on a clean
|
||||
// link — review §2.2). The mode-switch and topology rebuilds already announce.
|
||||
let rebuild_t0 = std::time::Instant::now();
|
||||
match crate::encode::open_video(
|
||||
plan.codec,
|
||||
frame.format,
|
||||
@@ -2853,10 +2858,24 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
behind_score = 0;
|
||||
depth_frames = 0;
|
||||
ahead_run = 0;
|
||||
// …and it must not feed the CLIENT's controller either: announce the
|
||||
// host-local gap so the starved window is discarded, exactly as a
|
||||
// mode-switch rebuild does (review §2.2 — this arm was the one rebuild
|
||||
// that never told the client).
|
||||
announce_pipeline_gap(
|
||||
&gap_tx,
|
||||
rebuild_t0.elapsed().as_millis().min(u32::MAX as u128) as u32,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), to_kbps = new_kbps,
|
||||
"bitrate-change encoder rebuild failed — keeping the current rate");
|
||||
// The control task acked the resolved rate BEFORE this apply — with
|
||||
// the rebuild failed, the client's controller now tracks a rate the
|
||||
// encoder never ran: its climb base, utilization and proven math all
|
||||
// drift from a phantom number (review §2.3). Snap it back, same
|
||||
// channel as the short-apply correction above.
|
||||
let _ = retarget_tx.send(bitrate_kbps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -117,7 +117,7 @@
|
||||
"gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key",
|
||||
" https://git.unom.io/api/packages/unom/generic/punktfunk-keys/1/RPM-GPG-KEY-punktfunk",
|
||||
"REPO",
|
||||
"sudo dnf install punktfunk"
|
||||
"sudo dnf install punktfunk punktfunk-web punktfunk-scripting"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -29,8 +29,9 @@ For **Fedora 43 or newer** (Workstation or KDE). Bazzite and other Fedora Atomic
|
||||
|
||||
The RPM repo has one group per Fedora release: **`fedora-44`** on Fedora 44, **`bazzite`** on
|
||||
Fedora 43 (it's a plain Fedora 43 build of the same package). `rpm -E %fedora` prints your number —
|
||||
set `baseurl` to match, then install. The browser console, `punktfunk-web`, comes along
|
||||
automatically:
|
||||
set `baseurl` to match, then install. The install line names the browser console
|
||||
(`punktfunk-web`) and the plugin runner (`punktfunk-scripting`) explicitly: they're *recommended*
|
||||
deps of `punktfunk`, and a box with `install_weak_deps=False` would silently skip them.
|
||||
|
||||
<Install platform="fedora" />
|
||||
|
||||
|
||||
@@ -187,6 +187,24 @@ ffmpeg -hide_banner -encoders | grep nvenc # expect hevc_nvenc / av1_nvenc / h
|
||||
|
||||
The same applies on a layered Bazzite / Fedora Atomic install; the sysext image carries its own.
|
||||
|
||||
## `systemctl --user status punktfunk-web`: unit not found
|
||||
|
||||
The web console is its own package, and the `punktfunk` RPM only *recommends* it
|
||||
(`Recommends: punktfunk-web`) — a box with `install_weak_deps=False` in `/etc/dnf/dnf.conf`, a
|
||||
`--setopt=install_weak_deps=0` install, or an `rpm-ostree` layering that drops weak deps gets the
|
||||
host with no console and no unit to enable. Install it by name:
|
||||
|
||||
```sh
|
||||
rpm -q punktfunk-web || sudo dnf install punktfunk-web punktfunk-scripting
|
||||
systemctl --user enable --now punktfunk-web
|
||||
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
|
||||
```
|
||||
|
||||
`No match for argument` instead means the repo you're on has no console: **COPR** builds host and
|
||||
client only (its mock chroot has no `bun`). Use the RPM registry —
|
||||
[Fedora](/docs/fedora#2-install-the-host), step 2. The same weak-dep miss happens on Debian/Ubuntu
|
||||
after an `apt install --no-install-recommends`; the fix is `sudo apt install punktfunk-web`.
|
||||
|
||||
## pacman: error: could not register 'punktfunk' database (database already registered)
|
||||
|
||||
The repo block got appended to `/etc/pacman.conf` twice — the add line is an append, so running it
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
"gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key",
|
||||
" https://git.unom.io/api/packages/unom/generic/punktfunk-keys/1/RPM-GPG-KEY-punktfunk",
|
||||
"REPO",
|
||||
"sudo dnf install punktfunk"
|
||||
"sudo dnf install punktfunk punktfunk-web punktfunk-scripting"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -283,7 +283,7 @@ REPO
|
||||
CMD
|
||||
)"
|
||||
[ "$group" = fedora-44 ] || run "sudo sed -i 's|/rpm/fedora-44|/rpm/$group|' /etc/yum.repos.d/punktfunk.repo"
|
||||
run 'sudo dnf install punktfunk'
|
||||
run 'sudo dnf install punktfunk punktfunk-web punktfunk-scripting'
|
||||
;;
|
||||
sysext)
|
||||
install_line='sudo bash punktfunk-sysext.sh install'
|
||||
|
||||
Reference in New Issue
Block a user