Compare commits

...
Author SHA1 Message Date
enricobuehler aef7f7877f feat(console-ui): the bitrate row reaches 2 Gbps, steps finely at the bottom, and takes a typed rate
ci / bun-nix (pull_request) Successful in 43s
ci / docs-drift (pull_request) Successful in 1m24s
ci / web (pull_request) Successful in 1m34s
ci / docs-site (pull_request) Successful in 1m34s
ci / rust-arm64 (pull_request) Successful in 2m26s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 8m2s
ci / rust (pull_request) Successful in 11m57s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m0s
android / android (pull_request) Successful in 12m15s
The gamepad shell's Bitrate picker has been seven rungs ending at 80 Mbps since the
console shipped, which is the ceiling a user just ran into — the GTK dialog beside it
has always gone to 3000 Mbit/s, so the two surfaces disagreed about what this machine
may ask for, and the console was the smaller of the two.

Three changes, one row:

- The ladder is 30 rungs, 1 Mbps to 2 Gbps. Tight at the bottom (1, 2, 3, 4, 5, 6, 8,
  10, 12, 15, 20, 25 …), where one rung decides whether a thin link is watchable, and
  coarse at the top, where a rung is noise. Rates at or above a gigabit read as Gbps.
- Y opens a typed rate on that row — digits, four of them, through the tray keyboard
  (or SDL text input, and Steam's own keyboard on a Deck) exactly like the add-host and
  pair fields. A goes on cycling the ladder everywhere, so the console's grammar is
  unchanged; the field is what the ladder cannot be, which is every number in between.
- A rate that is not a rung now steps to its NEIGHBOUR. The generic picker snaps a value
  it does not recognise to its first option, which on this row is Automatic: one nudge
  threw away a rate typed here or set by the desktop spinner.

The desktop dialog gets the same complaint's other half: its spinner steps 1 Mbit/s
instead of 5, so 3, 4 and 6 are reachable without typing.

`Screen::edit_key` now takes the context, because this is the first field that commits
into the settings store when it closes rather than holding text for a later action row.
2026-08-24 11:19:45 +02:00
4 changed files with 371 additions and 17 deletions
+3 -1
View File
@@ -1250,7 +1250,9 @@ pub fn show_scoped(
"Above 1× supersamples for sharpness; below is lighter on the host",
&scale_names.iter().map(String::as_str).collect::<Vec<_>>(),
);
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
// 1 Mbit/s per step: the rungs that matter on a thin link are 3, 4, 6 — a 5-wide step
// could not name any of them, and typing was the only way to reach one.
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 1.0);
bitrate_row.set_title("Bitrate");
bitrate_row
.set_subtitle("Mbit/s · 0 = host default · a host card's menu has a network speed test");
+8 -1
View File
@@ -246,16 +246,22 @@ impl Screen {
match self {
Screen::AddHost(s) => s.text_input(text),
Screen::Pair(s) => s.text_input(text),
Screen::Settings(s) => s.text_input(text),
_ => {}
}
}
/// Raw key edits while a field is editing (Backspace repeats, Return = done).
/// Returns true when consumed.
pub(crate) fn edit_key(&mut self, key: crate::input::Key) -> bool {
///
/// Takes the context because a field can commit into the settings store on close —
/// the settings screen's typed bitrate does, where add-host and pair only hold text a
/// later action row reads.
pub(crate) fn edit_key(&mut self, key: crate::input::Key, ctx: &mut Ctx) -> bool {
match self {
Screen::AddHost(s) => s.edit_key(key),
Screen::Pair(s) => s.edit_key(key),
Screen::Settings(s) => s.edit_key(key, ctx),
_ => false,
}
}
@@ -265,6 +271,7 @@ impl Screen {
match self {
Screen::AddHost(s) => s.editing(),
Screen::Pair(s) => s.editing(),
Screen::Settings(s) => s.editing(),
_ => false,
}
}
+347 -14
View File
@@ -16,7 +16,9 @@ use crate::glyphs::{Hint, HintKey};
use crate::pointer::Pointer;
use crate::screens::{Ctx, Outbox, Screen};
use crate::theme::{fg, Fonts, W};
use crate::widgets::{ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H};
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::trust::{MouseMode, StatsVerbosity, TouchMode};
@@ -288,7 +290,18 @@ const RESOLUTIONS: [(u32, u32); 6] = [
const REFRESH: [u32; 5] = [0, 30, 60, 90, 120];
/// Mirrors [`punktfunk_core::render_scale::PRESETS`] (and the desktop pickers).
const RENDER_SCALES: [f64; 9] = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
const BITRATES: [u32; 7] = [0, 5_000, 10_000, 20_000, 30_000, 50_000, 80_000];
/// The rungs left/right steps through, in kbps. Tight at the bottom, where one rung is the
/// difference between watchable and a slideshow on a thin link, and coarse at the top, where
/// a rung is noise; the ceiling is 2 Gbps. The list is deliberately long — a ladder no thumb
/// can walk to the value it wants is what the Y field is for.
const BITRATES: [u32; 30] = [
0, 1_000, 2_000, 3_000, 4_000, 5_000, 6_000, 8_000, 10_000, 12_000, 15_000, 20_000, 25_000,
30_000, 40_000, 50_000, 60_000, 80_000, 100_000, 125_000, 150_000, 200_000, 250_000, 300_000,
400_000, 500_000, 750_000, 1_000_000, 1_500_000, 2_000_000,
];
/// What the typed field accepts, in Mbps: the ladder's own ceiling. The host clamps to its
/// range anyway (500 kbps 8 Gbps), so this is about what a client should let you ask for.
const CUSTOM_MAX_MBPS: u32 = 2_000;
const COMPOSITORS: [(&str, &str); 5] = [
("auto", "Automatic"),
("kwin", "KWin"),
@@ -371,6 +384,14 @@ 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 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
/// rarely a round rung. Y rather than A so the A-cycles-forward grammar holds everywhere.
custom_bitrate: Option<String>,
/// The tray keyboard the field types through, where the platform has no keyboard of its
/// own (on a Deck, Steam's keyboard types and ours never draws — same rule as add-host).
keyboard: Keyboard,
}
impl SettingsScreen {
@@ -388,6 +409,112 @@ impl SettingsScreen {
tab: 0,
tab_cursors: [0; TABS.len()],
profiles,
custom_bitrate: None,
keyboard: Keyboard::new(),
}
}
/// A text field is open — the run loop keeps SDL text input started, so a hardware
/// keyboard (and Steam's, on a Deck) types straight into it.
pub(crate) fn editing(&self) -> bool {
self.custom_bitrate.is_some()
}
/// Committed text from SDL. Digits only, four of them: 2000 Mbps is the ceiling.
pub(crate) fn text_input(&mut self, text: &str) {
for ch in text.chars() {
self.type_char(ch);
}
}
fn type_char(&mut self, ch: char) -> bool {
let Some(buf) = self.custom_bitrate.as_mut() else {
return false;
};
if !permits(Charset::Digits, ch) || buf.chars().count() >= 4 {
return false;
}
buf.push(ch);
true
}
fn backspace(&mut self) -> bool {
self.custom_bitrate.as_mut().and_then(String::pop).is_some()
}
/// Raw key edits while the field is open (Backspace repeats, Return/Escape are done).
pub(crate) fn edit_key(&mut self, key: crate::input::Key, ctx: &mut Ctx) -> bool {
use crate::input::Key as K;
if self.custom_bitrate.is_none() {
return false;
}
match key {
K::Backspace => {
self.backspace();
true
}
K::Return | K::Escape => {
self.commit_custom(ctx);
true
}
_ => false,
}
}
/// Close the field, storing what was typed. An empty field (or a typed `0`) leaves the
/// rate alone: a cleared field is an abandoned edit, and "let the host decide" is the
/// ladder's own first rung, not something to reach by deleting four digits.
fn commit_custom(&mut self, ctx: &mut Ctx) {
let Some(text) = self.custom_bitrate.take() else {
return;
};
let Ok(mbps) = text.parse::<u32>() else {
return;
};
if mbps == 0 {
return;
}
// The same rebase-then-save every other write here does: another writer may have
// stored the file while the keyboard was up.
*ctx.settings = ctx.store.load();
ctx.settings.bitrate_kbps = mbps.min(CUSTOM_MAX_MBPS) * 1000;
ctx.store.save(ctx.settings);
}
/// The field is modal while it is up: the tray takes the events, and closing commits.
fn custom_menu(&mut self, ev: MenuEvent, ctx: &mut Ctx) -> Option<MenuPulse> {
if ctx.deck {
// Steam's keyboard is doing the typing (text arrives through `text_input`); the
// pad is only here to say when it's done.
return match ev {
MenuEvent::Back | MenuEvent::Confirm => {
self.commit_custom(ctx);
Some(MenuPulse::Confirm)
}
_ => None,
};
}
let (msg, pulse) = self.keyboard.menu(ev);
match msg {
KeyMsg::Type(c) => {
if self.type_char(c) {
Some(MenuPulse::Move)
} else {
Some(MenuPulse::Boundary)
}
}
KeyMsg::Backspace => {
if self.backspace() {
Some(MenuPulse::Move)
} else {
Some(MenuPulse::Boundary)
}
}
KeyMsg::Done => {
self.commit_custom(ctx);
Some(MenuPulse::Confirm)
}
KeyMsg::None => pulse,
}
}
@@ -457,6 +584,27 @@ impl SettingsScreen {
/// Mouse/touch. The strip is checked first — its pills sit above the list and a press
/// there is never meant for a row.
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
if self.custom_bitrate.is_some() && !ctx.deck {
if !self.keyboard.covers(p) {
if p.press() {
self.commit_custom(ctx);
return true;
}
return false;
}
let (msg, _) = self.keyboard.pointer(p);
match msg {
KeyMsg::Type(c) => {
self.type_char(c);
}
KeyMsg::Backspace => {
self.backspace();
}
KeyMsg::Done => self.commit_custom(ctx),
KeyMsg::None => {}
}
return true;
}
if let Some(tab) = self.strip.pointer(p) {
self.show_tab(tab, ctx);
return true;
@@ -477,6 +625,9 @@ impl SettingsScreen {
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
if self.custom_bitrate.is_some() {
return self.custom_menu(ev, ctx);
}
match ev {
MenuEvent::Back => {
fx.pop();
@@ -488,6 +639,16 @@ impl SettingsScreen {
}
let ids = self.row_ids(ctx);
self.clamp_cursor(ids.len());
// Y on the Bitrate row opens the typed rate; on every other row it means nothing,
// and the hint bar only offers it where it does.
if ev == MenuEvent::Secondary {
return if ids.get(self.list.cursor) == Some(&RowId::Bitrate) {
self.custom_bitrate = Some(String::new());
Some(MenuPulse::Confirm)
} else {
None
};
}
let (msg, pulse) = self.list.menu(ev, ids.len());
self.apply_row(msg, pulse, &ids, ctx, fx)
}
@@ -590,6 +751,20 @@ impl SettingsScreen {
}
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
if self.custom_bitrate.is_some() {
if ctx.deck {
return vec![
Hint::new(HintKey::Key("STEAM + X"), "Keyboard"),
Hint::new(HintKey::Confirm, "Done"),
Hint::new(HintKey::Back, "Done"),
];
}
return vec![
Hint::new(HintKey::Confirm, "Type"),
Hint::new(HintKey::Tertiary, "Delete"),
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")];
@@ -603,6 +778,12 @@ impl SettingsScreen {
Hint::new(HintKey::Confirm, "Open"),
Hint::new(HintKey::Back, "Done"),
],
// The one row with a value the ladder cannot name every version of.
Some(RowId::Bitrate) => vec![
Hint::new(HintKey::Adjust, "Adjust"),
Hint::new(HintKey::Secondary, "Type a rate"),
Hint::new(HintKey::Back, "Done"),
],
Some(_) => vec![
Hint::new(HintKey::Adjust, "Adjust"),
Hint::new(HintKey::Confirm, "Change"),
@@ -635,20 +816,49 @@ impl SettingsScreen {
k,
dt,
);
let seat = self
.keyboard
.seat(self.custom_bitrate.is_some() && !ctx.deck, dt);
let tray_h = if seat > 0.0 {
(Keyboard::tray_height() + 12.0) * k * seat
} else {
0.0
};
let list_rect = Rect::from_ltrb(
rect.left,
rect.top + strip_h as f32,
rect.right,
rect.bottom - detail_h as f32,
rect.bottom - detail_h as f32 - tray_h as f32,
);
let ids = self.row_ids(ctx);
self.clamp_cursor(ids.len());
let rows: Vec<RowSpec> = ids
let mut rows: Vec<RowSpec> = ids
.iter()
.map(|id| row_spec(*id, ctx, &self.profiles))
.collect();
self.list
.render(canvas, list_rect, &rows, fonts, k, dt, true);
// While the field is open the Bitrate row IS the field: it shows the digits typed so
// far and carries the caret, so the value being edited is where the value lives.
if let (Some(text), Some(i)) = (
self.custom_bitrate.as_ref(),
ids.iter().position(|id| *id == RowId::Bitrate),
) {
rows[i].value = Some(if text.is_empty() {
"Mbps".into()
} else {
format!("{text} Mbps")
});
rows[i].value_dim = text.is_empty();
rows[i].caret = true;
}
self.list.render(
canvas,
list_rect,
&rows,
fonts,
k,
dt,
self.custom_bitrate.is_none(),
);
let detail = ids
.get(self.list.cursor)
.copied()
@@ -660,9 +870,19 @@ impl SettingsScreen {
13.0 * k,
fg(0.55),
f64::from(rect.left) + f64::from(rect.width()) / 2.0,
f64::from(rect.bottom) - detail_h + 6.0 * k,
f64::from(rect.bottom) - detail_h - tray_h + 6.0 * k,
f64::from(rect.width()) * 0.8,
);
if seat > 0.0 {
self.keyboard.render(
canvas,
fonts,
f64::from(rect.width()),
f64::from(rect.bottom),
seat,
k,
);
}
}
}
@@ -837,7 +1057,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
if s.bitrate_kbps == 0 {
"Automatic".into()
} else {
format!("{} Mbps", s.bitrate_kbps / 1000)
bitrate_label(s.bitrate_kbps)
},
),
RowId::Compositor => (
@@ -1045,7 +1265,9 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
"The host renders larger or smaller than the stream mode and this window \
resamples above 1× supersamples, below saves bandwidth."
}
RowId::Bitrate => "Automatic uses the host's default (20 Mbps).",
RowId::Bitrate => {
"Automatic uses the host's default (20 Mbps). Y types an exact rate, up to 2 Gbps."
}
RowId::Compositor => {
"Which compositor drives the virtual output — honored only if available on the host."
}
@@ -1219,6 +1441,26 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
}
}
/// A rate as the row says it: Mbps to a gigabit, Gbps above it, and a decimal only where
/// dropping one would print two different rates the same way (12.5 Mbps, 1.5 Gbps). Rates
/// off the ladder are real — the field below types them, and the desktop shells' free-form
/// spinner has always been able to store one.
fn bitrate_label(kbps: u32) -> String {
let unit = |v: f64, suffix: &str| {
if (v - v.round()).abs() < 0.05 {
format!("{} {suffix}", v.round())
} else {
format!("{v:.1} {suffix}")
}
};
let mbps = f64::from(kbps) / 1000.0;
if kbps >= 1_000_000 {
unit(mbps / 1000.0, "Gbps")
} else {
unit(mbps, "Mbps")
}
}
fn on_off(v: bool) -> &'static str {
if v {
"On"
@@ -1283,8 +1525,21 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
.map(|i| s.render_scale = RENDER_SCALES[i])
}
RowId::Bitrate => {
let cur = BITRATES.iter().position(|b| *b == s.bitrate_kbps);
step_option(cur, BITRATES.len(), delta, wrap).map(|i| s.bitrate_kbps = BITRATES[i])
// A typed rate (or one a desktop shell's spinner stored) sits BETWEEN rungs, and
// the generic step snaps a value it cannot find to the first option — which here
// is Automatic, i.e. one nudge throws the custom rate away. Step to the rung the
// thumb is heading for instead.
let stepped = match BITRATES.iter().position(|b| *b == s.bitrate_kbps) {
Some(i) => step_option(Some(i), BITRATES.len(), delta, wrap),
None if delta < 0 => BITRATES.iter().rposition(|b| *b < s.bitrate_kbps),
// Above the top rung there is nothing higher to step to; A (which wraps) still
// comes back round to Automatic.
None => BITRATES
.iter()
.position(|b| *b > s.bitrate_kbps)
.or(if wrap { Some(0) } else { None }),
};
stepped.map(|i| s.bitrate_kbps = BITRATES[i])
}
RowId::Compositor => step_str(&COMPOSITORS, &mut s.compositor, delta, wrap),
RowId::Codec => step_str(&CODECS, &mut s.codec, delta, wrap),
@@ -2017,10 +2272,14 @@ pub(super) mod tests {
assert_eq!(ctx.settings.mouse_mode, "capture");
}
/// A rate that is not a rung — typed on the row, or stored by a desktop shell's
/// free-form spinner — steps to its NEIGHBOUR. Every other picker here snaps an
/// unrecognised value to its first option, which on this row is Automatic: one nudge
/// would throw away the exact rate the user went to the trouble of typing.
#[test]
fn unknown_value_snaps_to_first() {
fn an_off_ladder_rate_steps_to_its_neighbour() {
let (mut settings, pads) = ctx_parts();
settings.bitrate_kbps = 12_345; // set via a desktop shell's free-form field
settings.bitrate_kbps = 12_345;
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
@@ -2035,7 +2294,81 @@ pub(super) mod tests {
t: 0.0,
};
assert!(adjust(RowId::Bitrate, 1, false, &mut ctx));
assert_eq!(ctx.settings.bitrate_kbps, 0, "snapped to Automatic");
assert_eq!(ctx.settings.bitrate_kbps, 15_000, "the rung above");
ctx.settings.bitrate_kbps = 12_345;
assert!(adjust(RowId::Bitrate, -1, false, &mut ctx));
assert_eq!(ctx.settings.bitrate_kbps, 12_000, "the rung below");
// The ends still thud rather than wrap under left/right.
ctx.settings.bitrate_kbps = 2_000_000;
assert!(!adjust(RowId::Bitrate, 1, false, &mut ctx), "the ceiling");
// …and a rung it does know steps as it always did.
ctx.settings.bitrate_kbps = 5_000;
assert!(adjust(RowId::Bitrate, -1, false, &mut ctx));
assert_eq!(ctx.settings.bitrate_kbps, 4_000);
}
/// The typed rate: Y opens the field on the Bitrate row (and nowhere else), digits land
/// in it, and closing stores what was typed — clamped to the ceiling, because four
/// digits can ask for 9999 Mbps and no client should send that.
#[test]
fn a_typed_bitrate_is_stored_and_clamped() {
let (mut settings, pads) = ctx_parts();
let library = crate::library::LibraryShared::default();
// A snapshot store, not the file one: this test SAVES, and a unit test must not
// rewrite the machine's real settings file to prove it.
let store = crate::store::SnapshotStore::new(settings.clone(), Vec::new());
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
store: &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();
let ids = s.row_ids(&ctx);
s.list.cursor = ids
.iter()
.position(|id| *id == RowId::Bitrate)
.expect("the bitrate row");
s.menu(MenuEvent::Secondary, &mut ctx, &mut fx);
assert!(s.editing(), "Y opens the field");
s.text_input("13x7"); // digits only: the 'x' is refused, not typed
assert!(s.edit_key(crate::input::Key::Return, &mut ctx));
assert!(!s.editing(), "Return closes it");
assert_eq!(ctx.settings.bitrate_kbps, 137_000);
s.menu(MenuEvent::Secondary, &mut ctx, &mut fx);
s.text_input("99999"); // four digits fit; the fifth is refused
assert!(s.edit_key(crate::input::Key::Return, &mut ctx));
assert_eq!(
ctx.settings.bitrate_kbps, 2_000_000,
"clamped to the ceiling"
);
// An emptied field is an abandoned edit, not a request for Automatic.
s.menu(MenuEvent::Secondary, &mut ctx, &mut fx);
assert!(s.edit_key(crate::input::Key::Return, &mut ctx));
assert_eq!(ctx.settings.bitrate_kbps, 2_000_000, "left alone");
// Y is the bitrate row's alone — on a neighbour it does nothing at all.
s.list.cursor = 0;
s.menu(MenuEvent::Secondary, &mut ctx, &mut fx);
assert!(!s.editing());
}
#[test]
fn rates_read_in_the_biggest_round_unit() {
assert_eq!(bitrate_label(20_000), "20 Mbps");
assert_eq!(bitrate_label(12_500), "12.5 Mbps");
assert_eq!(bitrate_label(1_000_000), "1 Gbps");
assert_eq!(bitrate_label(1_500_000), "1.5 Gbps");
assert_eq!(bitrate_label(2_000_000), "2 Gbps");
}
/// The Profiles section trails the settings rows: one row per catalog profile whose
+13 -1
View File
@@ -1005,8 +1005,20 @@ impl Shell {
pub(crate) fn key(&mut self, key: crate::input::Key, shift: bool, repeat: bool) -> bool {
use crate::input::Key as S;
if self.editing() {
let mut ctx = Ctx {
hosts: &self.hosts,
library: &self.library,
settings: &mut self.settings,
store: &*self.store,
platform: self.platform,
pads: &self.pads,
deck: self.deck,
fallback_ui: self.fallback_ui,
device_name: &self.device_name,
t: self.t0.elapsed().as_secs_f64(),
};
if let Some(top) = self.stack.last_mut() {
if top.edit_key(key) {
if top.edit_key(key, &mut ctx) {
return true;
}
}