Files
punktfunk/clients/linux/src/ui_settings.rs
T
enricobuehlerandClaude Fable 5 de7b54d4e0
ci / docs-site (push) Successful in 58s
ci / web (push) Successful in 1m11s
apple / swift (push) Successful in 1m20s
decky / build-publish (push) Successful in 29s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
fix(core): remaining 15 sweep lows — client, clipboard, C ABI (v10), FEC, GSO, GTK
Second and final batch from the 2026-07-20 core-sweep lows:

client:
- connect() timeout now sets `quit` before shutdown, so a handshake that
  completes after the deadline closes with QUIT_CLOSE_CODE instead of
  leaving the host lingering (virtual display up) for a reconnect that
  never comes.
- probe_result: saturating_add on the wire-supplied wire_packets +
  send_dropped counters (debug-build overflow panic / release wrap).
- the standing-latency bleed no longer sets flush_in_window: that flag
  is the ABR's SEVERE (×0.7) verdict, and the bleed fires only on
  provably loss-free windows the controller itself scores as fine.

clipboard:
- fetch_cancels pruned on every new fetch (was: one dead oneshot per
  paste for the session).
- serve chunks gated on a parked waiter + capped at CLIP_FETCH_CAP with
  an Error event (was: unbounded silent accumulation under any req_id).
- serve_inbound park bounded by FETCH_STALL_SECS + send.stopped() (was:
  an unanswered FetchRequest parked the task, waiter, and bi-stream
  forever; ~100 of them exhaust the connection's bidi budget).

C ABI (ABI_VERSION 9 → 10, header regenerated, additive only):
- new punktfunk_connection_clock_offset_now_ns — the LIVE re-synced
  offset (Swift/Kotlin latency math read the frozen connect-time value
  ~40ms wrong after a wall-clock step).
- to_config: checked u64→usize narrowing of max_frame_bytes (32-bit
  armeabi truncated >4GiB to a plausible residue).
- host_poll_input: no &mut held across the embedder callback (re-entry
  aliased it — UB under noalias); mid-drain callback clears now stick.
- next_audio_pcm: DTX (empty) payloads skipped — decode synthesized
  120ms of concealment per 5ms slot and grew the playout ring forever.
- next_clipboard releases the parked payload on an empty poll (a one-off
  50MiB paste stayed resident all session).
- frames_dropped / wants_decode_latency write their documented 0/false
  defaults before the NULL-handle check.
- gamepad constant docs match pick_gamepad() reality (DualSenseEdge/
  SwitchPro landed; DualSense/DS4 honored on Windows UMDF too).

FEC:
- gf8 reconstruct/reconstruct_into reuse the (k,m) codec cache like
  encode_into (was: fresh 230×200 generator + decode inversion per
  lossy block on the pump thread).
- vendored fec-rs: the 8 safe wrap_mul_slice shims assert equal lengths
  (x86 SIMD callees bound stores on input.len() — safe-code OOB write);
  ReconstructShard's safety contract gains the len()==get().len()
  clause and reconstruct_internal sizes raw slices from the slice.

transport/GTK:
- Linux GSO super-buffer capped at the real UDP payload ceiling (65487)
  not 65535 — seg sizes ≥1024 could EMSGSIZE and latch GSO off
  process-wide, blamed on the network.
- GTK settings dialog no longer rewrites an unlisted-but-valid stored
  gamepad preference to "auto" on close.

Already fixed on main (verified stale, skipped): the reassembler FEC
ceiling, wants_decode_latency's third term, request_probe rollback +
the generalized probe watchdog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:26:43 +02:00

1023 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Preferences dialog on the cross-client category map (the Apple 2026-07 settings
//! revamp): General / Display / Input / Audio / Controllers pages — Display owns
//! everything about the picture — with per-field captions in each row's subtitle,
//! dynamic where the meaning depends on the selection (touch mode). Written back to
//! disk when the dialog closes. About stays in the primary menu (GNOME convention)
//! rather than as a page.
use crate::trust::Settings;
use adw::prelude::*;
use pf_client_core::trust::StatsVerbosity;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
/// `(0, 0)` = the native size of the monitor the window is on, resolved at connect.
const RESOLUTIONS: &[(u32, u32)] = &[
(0, 0),
(1280, 720),
(1920, 1080),
(2560, 1440),
(3840, 2160),
];
/// `0` = the monitor's native refresh, resolved at connect.
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
/// Render-scale multipliers (persisted as f64; mirrors [`punktfunk_core::render_scale::PRESETS`]).
/// `1.0` = Native. Applied at connect and each match-window resize.
const RENDER_SCALES: &[f64] = &[0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
/// A compact label for a render-scale multiplier: "Native" / "1.5×" / "2× (supersample)".
fn render_scale_label(scale: f64) -> String {
if scale == 1.0 {
"Native".to_string()
} else if scale > 1.0 {
format!("{scale}× (supersample)")
} else {
format!("{scale}×")
}
}
const GAMEPADS: &[&str] = &[
"auto",
"xbox360",
"dualsense",
"xboxone",
"dualshock4",
"steamdeck",
];
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
/// Codec setting values (persisted) paired with their display labels below. PyroWave is
/// preference-only by design (`Settings::preferred_codec`) — the ladder falls back to
/// HEVC when either side can't do it.
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1", "pyrowave"];
const CODEC_LABELS: &[&str] = &[
"Automatic",
"HEVC (H.265)",
"H.264 (AVC)",
"AV1",
"PyroWave (wired LAN)",
];
const DECODERS: &[&str] = &["auto", "vulkan", "vaapi", "software"];
/// Touch-input model values (persisted) paired with their display labels below — the
/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet).
const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"];
const TOUCH_MODE_LABELS: &[&str] = &["Trackpad", "Direct pointer", "Touch passthrough"];
/// The SELECTED touch mode explained — the caption swaps with the choice (the Apple
/// revamp's dynamic-caption idiom) instead of narrating all three modes at once.
/// Combo-row captions must stay ONE line (~66 chars at the default dialog width): a
/// wrapped subtitle's natural width crushes the selected-value label into an ellipsis.
const TOUCH_MODE_CAPTIONS: &[&str] = &[
"Drives the cursor like a laptop trackpad — tap to click",
"The cursor jumps to your finger — a tap clicks there",
"Real multi-touch reaches the host — for touch-native apps",
];
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
const APP_LICENSE: &str = concat!(
"Punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n",
"================================ MIT ================================\n\n",
include_str!("../../../LICENSE-MIT"),
"\n\n=============================== Apache-2.0 ===============================\n\n",
include_str!("../../../LICENSE-APACHE"),
);
/// Third-party software notices for the linked Rust crates (generated by
/// scripts/gen-third-party-notices.sh; shown as a Legal section in the About dialog).
const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt");
/// Show the About dialog (app license + the third-party-software Legal section) — reached
/// from the primary menu (app.rs `win.about`).
pub fn show_about(parent: &impl IsA<gtk::Widget>) {
let about = adw::AboutDialog::builder()
.application_name("Punktfunk")
.developer_name("unom")
.version(env!("CARGO_PKG_VERSION"))
.website("https://git.unom.io/unom/punktfunk")
.license_type(gtk::License::Custom)
.license(APP_LICENSE)
.build();
// The native (FFmpeg/GTK/PipeWire/SDL3) components are dynamically linked under their own
// (LGPL/Zlib/MIT) licenses; the Rust crate notices are the substantive attribution set.
about.add_legal_section(
"Third-party software (Rust crates)",
None,
gtk::License::Custom,
Some(THIRD_PARTY_NOTICES),
);
about.add_legal_section(
"Third-party software (system libraries)",
None,
gtk::License::Custom,
Some(
"This application dynamically links system libraries under their own licenses, \
including FFmpeg (LGPL v2.1+), GTK 4 and libadwaita (LGPL v2.1+), PipeWire (MIT), \
and SDL 3 (Zlib). Their full license texts are available from each project.",
),
);
about.present(Some(parent));
}
/// True inside a gamescope session (Steam game mode on the Deck / Bazzite): GTK popovers
/// are xdg_popups, which gamescope never maps for nested apps — a ComboRow's dropdown
/// flashes the row but no list ever appears. Selection UI must stay inside the toplevel.
fn gamescope_session() -> bool {
std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|d| d.eq_ignore_ascii_case("gamescope"))
|| std::env::var("GAMESCOPE_WAYLAND_DISPLAY").is_ok()
}
type ChangedFn = Rc<RefCell<Option<Box<dyn Fn(u32)>>>>;
/// A titled single-choice preference row. On a desktop this is a stock popover
/// [`adw::ComboRow`]; under gamescope (see [`gamescope_session`]) it becomes an activatable
/// row that pushes an in-window selection subpage onto the preferences dialog instead.
struct ChoiceRow {
row: adw::PreferencesRow,
selected: Rc<Cell<u32>>,
/// Fires on user changes only — [`connect_changed`](Self::connect_changed) is installed
/// after seeding, so programmatic `set_selected` during setup never fires it.
changed: ChangedFn,
/// Subpage mode only: the current value rendered as the row's suffix.
value_label: Option<gtk::Label>,
options: Rc<Vec<String>>,
}
impl ChoiceRow {
/// `inline` = subpage mode (gamescope): computed once per dialog via
/// [`gamescope_session`] and passed in so tests can drive both modes directly.
fn new(
dialog: &adw::PreferencesDialog,
inline: bool,
title: &str,
subtitle: &str,
options: &[&str],
) -> ChoiceRow {
let options: Rc<Vec<String>> = Rc::new(options.iter().map(|s| s.to_string()).collect());
let selected = Rc::new(Cell::new(0u32));
let changed: ChangedFn = Rc::new(RefCell::new(None));
if !inline {
let row = adw::ComboRow::builder()
.title(title)
.subtitle(subtitle)
.model(&gtk::StringList::new(
&options.iter().map(String::as_str).collect::<Vec<_>>(),
))
.build();
let (sel, chg) = (selected.clone(), changed.clone());
row.connect_selected_notify(move |r| {
if sel.replace(r.selected()) != r.selected() {
if let Some(f) = chg.borrow().as_ref() {
f(r.selected());
}
}
});
return ChoiceRow {
row: row.upcast(),
selected,
changed,
value_label: None,
options,
};
}
let value = gtk::Label::builder().css_classes(["dim-label"]).build();
let row = adw::ActionRow::builder()
.title(title)
.subtitle(subtitle)
.activatable(true)
.build();
row.add_suffix(&value);
row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
{
let dialog = dialog.downgrade();
let (options, sel, chg, value) = (
options.clone(),
selected.clone(),
changed.clone(),
value.clone(),
);
let title = title.to_string();
row.connect_activated(move |_| {
let Some(dialog) = dialog.upgrade() else {
return;
};
let list = gtk::ListBox::builder()
.selection_mode(gtk::SelectionMode::None)
.css_classes(["boxed-list"])
.build();
for (i, opt) in options.iter().enumerate() {
let check = gtk::Image::from_icon_name("object-select-symbolic");
check.set_visible(i as u32 == sel.get());
let opt_row = adw::ActionRow::builder()
.title(opt)
.use_markup(false)
.activatable(true)
.build();
opt_row.add_suffix(&check);
let idx = i as u32;
let dlg = dialog.downgrade();
let (sel, chg, value, label) =
(sel.clone(), chg.clone(), value.clone(), opt.clone());
opt_row.connect_activated(move |_| {
let user_change = sel.replace(idx) != idx;
value.set_text(&label);
if user_change {
if let Some(f) = chg.borrow().as_ref() {
f(idx);
}
}
if let Some(d) = dlg.upgrade() {
d.pop_subpage();
}
});
list.append(&opt_row);
}
let clamp = adw::Clamp::builder()
.child(&list)
.margin_top(24)
.margin_bottom(24)
.margin_start(12)
.margin_end(12)
.build();
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.child(&clamp)
.build();
let view = adw::ToolbarView::new();
view.add_top_bar(&adw::HeaderBar::new());
view.set_content(Some(&scroll));
dialog.push_subpage(&adw::NavigationPage::new(&view, &title));
});
}
let cr = ChoiceRow {
row: row.upcast(),
selected,
changed,
value_label: Some(value),
options,
};
cr.sync_value();
cr
}
/// Subpage mode: reflect the current selection in the row's suffix label.
fn sync_value(&self) {
if let Some(l) = &self.value_label {
let i = self.selected.get() as usize;
l.set_text(self.options.get(i).map(String::as_str).unwrap_or(""));
}
}
fn widget(&self) -> &adw::PreferencesRow {
&self.row
}
fn selected(&self) -> u32 {
self.selected.get()
}
fn set_selected(&self, i: u32) {
if let Some(combo) = self.row.downcast_ref::<adw::ComboRow>() {
combo.set_selected(i); // the notify handler syncs the cell
} else {
self.selected.set(i);
self.sync_value();
}
}
fn connect_changed(&self, f: impl Fn(u32) + 'static) {
*self.changed.borrow_mut() = Some(Box::new(f));
}
}
/// Update a row's caption after construction — the dynamic-caption hook (touch mode,
/// resolution, codec). Both ChoiceRow shapes carry their subtitle on [`adw::ActionRow`]
/// ([`adw::ComboRow`] derives from it), so one downcast covers desktop and gamescope mode.
fn set_row_subtitle(row: &adw::PreferencesRow, text: &str) {
if let Some(r) = row.downcast_ref::<adw::ActionRow>() {
r.set_subtitle(text);
}
}
/// The SELECTED resolution choice explained (row index: 0 = Native, 1 = Match window,
/// 2.. = explicit sizes) — one line each, see the caption-width note on
/// [`TOUCH_MODE_CAPTIONS`].
fn resolution_caption(i: u32) -> &'static str {
match i {
0 => "The native mode of this monitor, resolved at connect",
1 => "Follows the stream window — resizes renegotiate the host output",
_ => "The host drives a virtual output at exactly this size",
}
}
/// The SELECTED codec explained: the PyroWave entry is the one that needs its trade-off
/// spelled out; everything else shares the soft-preference line.
fn codec_caption(i: u32) -> &'static str {
if CODECS.get(i as usize) == Some(&"pyrowave") {
"Wavelet codec for wired LAN — minimal latency, lots of bandwidth"
} else {
"A preference — the host falls back if it can't encode it"
}
}
/// A settings category page for the dialog's view switcher.
fn page(title: &str, icon: &str) -> adw::PreferencesPage {
adw::PreferencesPage::builder()
// The name addresses the page programmatically (`set_visible_page_name` — the
// screenshot harness's page knob); the title is what the view switcher shows.
.name(title.to_lowercase())
.title(title)
.icon_name(icon)
.build()
}
/// Startup device probes for the pickers — filled by the app shell in the background
/// (GPUs via `punktfunk-session --list-adapters`, audio endpoints via the PipeWire
/// registry); any list may still be empty when the dialog opens, which simply hides
/// that picker.
#[derive(Default)]
pub struct DeviceProbes {
pub adapters: Vec<String>,
pub speakers: Vec<pf_client_core::audio::AudioDevice>,
pub mics: Vec<pf_client_core::audio::AudioDevice>,
}
/// A titled group of rows; `description` (may be empty) is the one form-level note —
/// per-field explanations belong in row subtitles, not here.
fn group(title: &str, description: &str) -> adw::PreferencesGroup {
let g = adw::PreferencesGroup::builder().title(title).build();
if !description.is_empty() {
g.set_description(Some(description));
}
g
}
/// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid
/// there so the library toggle takes effect without a nav round-trip). `probes` is the
/// shell's startup device probe (`AppModel::probes`) — may still be empty. Returns the
/// presented dialog so the screenshot harness can select a page; callers ignore it.
pub fn show(
parent: &impl IsA<gtk::Widget>,
settings: Rc<RefCell<Settings>>,
gamepads: &crate::gamepad::GamepadService,
probes: &DeviceProbes,
on_closed: impl Fn() + 'static,
) -> adw::PreferencesDialog {
// The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection
// subpage onto it.
let dialog = adw::PreferencesDialog::new();
dialog.set_title("Preferences");
dialog.set_search_enabled(true);
// Wide enough that the category switcher sits in the HEADER BAR (the tabbed look the
// Apple/Windows clients have): AdwPreferencesDialog moves it to a bottom bar below a
// breakpoint of 110pt × page count (≈ 733 px for our five pages). In a window that
// can't give the dialog this width it still collapses to the bottom bar on its own.
dialog.set_content_width(830);
let inline = gamescope_session();
// ---- Display: Resolution ----
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
// `match_window` flag), then the explicit sizes.
let res_names: Vec<String> = std::iter::once("Native display".to_string())
.chain(std::iter::once("Match window".to_string()))
.chain(
RESOLUTIONS
.iter()
.skip(1)
.map(|&(w, h)| format!("{w} × {h}")),
)
.collect();
let res_row = ChoiceRow::new(
&dialog,
inline,
"Resolution",
resolution_caption(0),
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
);
{
let w = res_row.widget().clone();
res_row.connect_changed(move |i| set_row_subtitle(&w, resolution_caption(i)));
}
let hz_names: Vec<String> = REFRESH
.iter()
.map(|&r| {
if r == 0 {
"Native".to_string()
} else {
format!("{r} Hz")
}
})
.collect();
let hz_row = ChoiceRow::new(
&dialog,
inline,
"Refresh rate",
"Native follows the monitor the window is on",
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
);
// ---- Display: Quality ----
let scale_names: Vec<String> = RENDER_SCALES
.iter()
.map(|&s| render_scale_label(s))
.collect();
let scale_row = ChoiceRow::new(
&dialog,
inline,
"Render scale",
"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);
bitrate_row.set_title("Bitrate");
bitrate_row
.set_subtitle("Mbit/s · 0 = host default · a host card's menu has a network speed test");
let codec_row = ChoiceRow::new(
&dialog,
inline,
"Video codec",
codec_caption(0),
CODEC_LABELS,
);
{
let w = codec_row.widget().clone();
codec_row.connect_changed(move |i| set_row_subtitle(&w, codec_caption(i)));
}
let hdr_row = adw::SwitchRow::builder()
.title("10-bit HDR")
.subtitle(
"Advertise 10-bit HDR10 so the host upgrades HDR content — shown in HDR where \
the display supports it, tone-mapped otherwise",
)
.build();
let decoder_row = ChoiceRow::new(
&dialog,
inline,
"Video decoder",
"Automatic picks the best hardware decode, then software",
&["Automatic", "Vulkan Video", "VAAPI", "Software"],
);
// GPU picker (multi-GPU boxes): the adapter name feeds the session's device pick
// via `Settings::adapter` → PUNKTFUNK_VK_ADAPTER. Hidden when there's nothing to
// pick; a saved adapter that's gone (eGPU unplugged) keeps a revertable entry.
let saved_adapter = settings.borrow().adapter.clone();
let mut gpu_names = vec!["Automatic".to_string()];
let mut gpu_keys: Vec<String> = vec![String::new()];
for a in &probes.adapters {
gpu_names.push(a.clone());
gpu_keys.push(a.clone());
}
if !saved_adapter.is_empty() && !gpu_keys.contains(&saved_adapter) {
gpu_names.push(format!("{saved_adapter} (not detected)"));
gpu_keys.push(saved_adapter.clone());
}
let gpu_row = (gpu_keys.len() > 1).then(|| {
let row = ChoiceRow::new(
&dialog,
inline,
"GPU",
"Decodes and presents the stream",
&gpu_names.iter().map(String::as_str).collect::<Vec<_>>(),
);
let i = gpu_keys
.iter()
.position(|k| k == &saved_adapter)
.unwrap_or(0);
row.set_selected(i as u32);
row
});
// ---- Display: Host output ----
let compositor_row = ChoiceRow::new(
&dialog,
inline,
"Host compositor",
"Advisory — the host falls back to auto-detect when unavailable",
&[
"Automatic",
"KWin",
"wlroots (Sway/Hyprland)",
"Mutter (GNOME)",
"gamescope",
],
);
// ---- General ----
let fullscreen_row = adw::SwitchRow::builder()
.title("Start streams in fullscreen")
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
.build();
let wake_row = adw::SwitchRow::builder()
.title("Auto-wake on connect")
.subtitle(
"Sends Wake-on-LAN to an offline saved host and waits for it to boot — turn \
off if hosts behind a VPN look offline when they aren't",
)
.build();
let stats_row = ChoiceRow::new(
&dialog,
inline,
"Statistics overlay",
"Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live",
&["Off", "Compact", "Normal", "Detailed"],
);
let library_row = adw::SwitchRow::builder()
.title("Show game library")
.subtitle(
"Adds “Browse library…” to paired hosts — list their Steam and custom games \
and launch one directly. No extra host setup",
)
.build();
// ---- Input ----
let touch_row = ChoiceRow::new(
&dialog,
inline,
"Touch input",
TOUCH_MODE_CAPTIONS[0],
TOUCH_MODE_LABELS,
);
// Dynamic caption: describe the SELECTED mode, not all three at once.
{
let w = touch_row.widget().clone();
touch_row.connect_changed(move |i| {
let i = (i as usize).min(TOUCH_MODE_CAPTIONS.len() - 1);
set_row_subtitle(&w, TOUCH_MODE_CAPTIONS[i]);
});
}
let inhibit_row = adw::SwitchRow::builder()
.title("Capture system shortcuts")
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
.build();
let invert_row = adw::SwitchRow::builder()
.title("Invert scroll direction")
.subtitle("Reverses the wheel and trackpad scroll direction sent to the host")
.build();
// ---- Audio ----
let surround_row = ChoiceRow::new(
&dialog,
inline,
"Audio channels",
"Stereo or surround — the host downmixes if its output has fewer",
&["Stereo", "5.1 Surround", "7.1 Surround"],
);
let mic_row = adw::SwitchRow::builder()
.title("Stream microphone")
.subtitle("Sends your microphone to the host's virtual mic")
.build();
// Endpoint pickers (from the PipeWire probe): visible labels are descriptions, the
// stored value is the node name. Hidden when the probe found nothing; a saved
// device that's gone keeps a revertable "(not detected)" entry, like the GPU row.
let dev_row = |saved: String,
devs: &[pf_client_core::audio::AudioDevice],
title: &str,
subtitle: &str| {
let mut names = vec!["System default".to_string()];
let mut keys = vec![String::new()];
for d in devs {
names.push(d.description.clone());
keys.push(d.name.clone());
}
if !saved.is_empty() && !keys.contains(&saved) {
names.push(format!("{saved} (not detected)"));
keys.push(saved.clone());
}
let row = (keys.len() > 1).then(|| {
let row = ChoiceRow::new(
&dialog,
inline,
title,
subtitle,
&names.iter().map(String::as_str).collect::<Vec<_>>(),
);
row.set_selected(keys.iter().position(|k| k == &saved).unwrap_or(0) as u32);
row
});
(row, keys)
};
let (speaker_row, speaker_keys) = dev_row(
settings.borrow().speaker_device.clone(),
&probes.speakers,
"Speaker",
"Host audio plays here — System default follows the desktop",
);
let (micdev_row, micdev_keys) = dev_row(
settings.borrow().mic_device.clone(),
&probes.mics,
"Microphone",
"The input that feeds the host's virtual mic",
);
// The device pick only matters while the mic streams at all.
if let Some(r) = &micdev_row {
let w = r.widget().clone();
w.set_sensitive(mic_row.is_active());
mic_row.connect_active_notify(move |m| w.set_sensitive(m.is_active()));
}
// ---- Controllers ----
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad
// (Steam's virtual pad skipped); pinning one restricts the session to that single
// controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`),
// so it survives restarts — and disconnects: an offline pinned pad keeps its entry here
// instead of silently snapping back to Automatic.
let pads = gamepads.pads();
let saved_pin = settings.borrow().forward_pad.clone();
let mut pad_names = vec!["Automatic (all controllers)".to_string()];
let mut pad_keys: Vec<String> = Vec::new();
for p in &pads {
let kind = p.kind_label();
pad_names.push(if kind.is_empty() {
p.name.clone()
} else {
format!("{} · {kind}", p.name)
});
pad_keys.push(p.key.clone());
}
if !saved_pin.is_empty() && !pad_keys.contains(&saved_pin) {
let name = saved_pin
.splitn(3, ':')
.nth(2)
.unwrap_or("Saved controller");
pad_names.push(format!("{name} (not connected)"));
pad_keys.push(saved_pin.clone());
}
let forward_row = ChoiceRow::new(
&dialog,
inline,
"Forwarded controller",
if pads.is_empty() {
"No controllers detected"
} else {
"Every pad is its own player — pick one to force single-player"
},
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
);
let pinned_i = pad_keys
.iter()
.position(|k| k == &saved_pin)
.map_or(0, |i| i + 1);
forward_row.set_selected(pinned_i as u32);
// The dialog-local choice, written into Settings on close (reading the service back
// would race its worker thread applying the Pin message).
let chosen_pin: Rc<RefCell<String>> = Rc::new(RefCell::new(saved_pin));
{
let svc = gamepads.clone();
let keys = pad_keys.clone();
let chosen = chosen_pin.clone();
forward_row.connect_changed(move |sel| {
let key = if sel == 0 {
None
} else {
keys.get(sel as usize - 1).cloned()
};
*chosen.borrow_mut() = key.clone().unwrap_or_default();
svc.set_pinned(key);
});
}
let pad_row = ChoiceRow::new(
&dialog,
inline,
"Gamepad type",
"The virtual pad on the host — Automatic matches your controller",
&[
"Automatic",
"Xbox 360",
"DualSense",
"Xbox One",
"DualShock 4",
"Steam Deck",
],
);
// ---- Seed from the current settings ----
{
let s = settings.borrow();
let res_i = if s.match_window {
1
} else {
RESOLUTIONS
.iter()
.position(|&(w, h)| w == s.width && h == s.height)
.map(|i| if i == 0 { 0 } else { i + 1 })
.unwrap_or(0)
};
res_row.set_selected(res_i as u32);
set_row_subtitle(res_row.widget(), resolution_caption(res_i as u32));
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
hz_row.set_selected(hz_i as u32);
let scale_i = RENDER_SCALES
.iter()
.position(|&x| (x - s.render_scale).abs() < 1e-6)
.unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap());
scale_row.set_selected(scale_i as u32);
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0);
pad_row.set_selected(pad_i as u32);
let touch_i = TOUCH_MODES
.iter()
.position(|&t| t == s.touch_mode)
.unwrap_or(0);
touch_row.set_selected(touch_i as u32);
// set_selected never fires the changed hook, so seed the dynamic caption directly.
set_row_subtitle(touch_row.widget(), TOUCH_MODE_CAPTIONS[touch_i]);
let comp_i = COMPOSITORS
.iter()
.position(|&c| c == s.compositor)
.unwrap_or(0);
compositor_row.set_selected(comp_i as u32);
let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0);
decoder_row.set_selected(dec_i as u32);
let stats_i = StatsVerbosity::ALL
.iter()
.position(|v| *v == s.stats_verbosity())
.unwrap_or(0);
stats_row.set_selected(stats_i as u32);
fullscreen_row.set_active(s.fullscreen_on_stream);
wake_row.set_active(s.auto_wake);
inhibit_row.set_active(s.inhibit_shortcuts);
invert_row.set_active(s.invert_scroll);
mic_row.set_active(s.mic_enabled);
hdr_row.set_active(s.hdr_enabled);
library_row.set_active(s.library_enabled);
surround_row.set_selected(match s.audio_channels {
6 => 1,
8 => 2,
_ => 0,
});
let codec_i = CODECS.iter().position(|&c| c == s.codec).unwrap_or(0);
codec_row.set_selected(codec_i as u32);
set_row_subtitle(codec_row.widget(), codec_caption(codec_i as u32));
}
// ---- Assemble the category pages (the Apple revamp's map) ----
let general = page("General", "preferences-system-symbolic");
let session_group = group("Session", "");
session_group.add(&fullscreen_row);
session_group.add(&wake_row);
let stats_group = group("Statistics", "");
stats_group.add(stats_row.widget());
let library_group = group("Library", "");
library_group.add(&library_row);
general.add(&session_group);
general.add(&stats_group);
general.add(&library_group);
let display = page("Display", "video-display-symbolic");
let resolution_group = group("Resolution", "");
resolution_group.add(res_row.widget());
resolution_group.add(hz_row.widget());
let quality_group = group("Quality", "");
quality_group.add(scale_row.widget());
quality_group.add(&bitrate_row);
quality_group.add(codec_row.widget());
quality_group.add(&hdr_row);
quality_group.add(decoder_row.widget());
if let Some(r) = &gpu_row {
quality_group.add(r.widget());
}
// The one form-level note (deliberately not repeated on every row).
let output_group = group(
"Host output",
"Display changes apply from the next session.",
);
output_group.add(compositor_row.widget());
display.add(&resolution_group);
display.add(&quality_group);
display.add(&output_group);
let input = page("Input", "input-keyboard-symbolic");
let touch_group = group("Touch", "");
touch_group.add(touch_row.widget());
// Group titles are Pango markup — the ampersand must be an entity.
let kbm_group = group("Keyboard &amp; mouse", "");
kbm_group.add(&inhibit_row);
kbm_group.add(&invert_row);
input.add(&touch_group);
input.add(&kbm_group);
let audio = page("Audio", "audio-volume-high-symbolic");
let audio_group = group("", "Applies from the next session.");
audio_group.add(surround_row.widget());
if let Some(r) = &speaker_row {
audio_group.add(r.widget());
}
audio_group.add(&mic_row);
if let Some(r) = &micdev_row {
audio_group.add(r.widget());
}
audio.add(&audio_group);
let controllers = page("Controllers", "input-gaming-symbolic");
let controllers_group = group("", "");
// The detected-pad list (mirrors the Apple Controllers section): informational rows
// above the pickers, from the same snapshot that feeds the forwarding picker.
if pads.is_empty() {
let none = adw::ActionRow::builder()
.title("No controllers detected")
.css_classes(["dim-label"])
.build();
controllers_group.add(&none);
} else {
for p in &pads {
let row = adw::ActionRow::builder()
.title(&p.name)
.use_markup(false)
.build();
if p.steam_virtual {
row.set_subtitle(
"Steam Input's virtual pad — Automatic skips it while a real pad is connected",
);
} else {
row.set_subtitle(p.kind_label());
}
row.add_prefix(&gtk::Image::from_icon_name("input-gaming-symbolic"));
controllers_group.add(&row);
}
}
controllers_group.add(forward_row.widget());
controllers_group.add(pad_row.widget());
controllers.add(&controllers_group);
dialog.add(&general);
dialog.add(&display);
dialog.add(&input);
dialog.add(&audio);
dialog.add(&controllers);
dialog.connect_closed(move |_| {
let mut s = settings.borrow_mut();
// Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit.
let res_i = (res_row.selected() as usize).min(RESOLUTIONS.len());
s.match_window = res_i == 1;
(s.width, s.height) = if res_i <= 1 {
(0, 0)
} else {
RESOLUTIONS[res_i - 1]
};
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
s.render_scale =
RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)];
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
// Keep a stored preference this table doesn't list (e.g. "switchpro" — valid to the
// session, hand-edited or written by another client): it displays as "Automatic", and
// writing that back would silently erase it just by opening + closing the dialog.
// Persist the row only when the user picked a non-Auto entry or the stored value was
// a listed one to begin with.
let pad_sel = (pad_row.selected() as usize).min(GAMEPADS.len() - 1);
if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) {
s.gamepad = GAMEPADS[pad_sel].to_string();
}
s.touch_mode =
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
s.forward_pad = chosen_pin.borrow().clone();
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
.to_string();
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
if let Some(r) = &gpu_row {
s.adapter = gpu_keys[(r.selected() as usize).min(gpu_keys.len() - 1)].clone();
}
if let Some(r) = &speaker_row {
s.speaker_device =
speaker_keys[(r.selected() as usize).min(speaker_keys.len() - 1)].clone();
}
if let Some(r) = &micdev_row {
s.mic_device = micdev_keys[(r.selected() as usize).min(micdev_keys.len() - 1)].clone();
}
s.set_stats_verbosity(
StatsVerbosity::ALL[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)],
);
s.fullscreen_on_stream = fullscreen_row.is_active();
s.auto_wake = wake_row.is_active();
s.inhibit_shortcuts = inhibit_row.is_active();
s.invert_scroll = invert_row.is_active();
s.mic_enabled = mic_row.is_active();
s.hdr_enabled = hdr_row.is_active();
s.audio_channels = match surround_row.selected() {
1 => 6,
2 => 8,
_ => 2,
};
s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string();
s.library_enabled = library_row.is_active();
s.save();
drop(s);
on_closed();
});
dialog.present(Some(parent));
dialog
}
#[cfg(test)]
mod tests {
use super::*;
/// Depth-first search for an [`adw::ActionRow`] with the given title.
fn find_action_row(root: &gtk::Widget, title: &str) -> Option<adw::ActionRow> {
if let Some(row) = root.downcast_ref::<adw::ActionRow>() {
if row.title() == title {
return Some(row.clone());
}
}
let mut child = root.first_child();
while let Some(c) = child {
if let Some(hit) = find_action_row(&c, title) {
return Some(hit);
}
child = c.next_sibling();
}
None
}
fn pump() {
let ctx = gtk::glib::MainContext::default();
while ctx.iteration(false) {}
}
/// Both ChoiceRow modes in ONE test (GTK is thread-affine and libtest gives every test
/// its own thread, so the display tests can't be split). Gamescope mode: activating the
/// row pushes the in-window selection subpage; activating an option updates the
/// selection + suffix label, fires the change callback, and pops the subpage. Combo
/// mode: cell sync + change callback. Needs a display — run manually with
/// `cargo test -p punktfunk-client-linux -- --ignored` on a session box.
#[test]
#[ignore = "needs a Wayland/X display"]
fn choice_row_modes() {
assert!(gtk::init().is_ok() && adw::init().is_ok(), "no display");
let win = adw::Window::new();
let dialog = adw::PreferencesDialog::new();
let page = adw::PreferencesPage::new();
let group = adw::PreferencesGroup::new();
let row = ChoiceRow::new(&dialog, true, "Resolution", "sub", &["A", "B", "C"]);
group.add(row.widget());
page.add(&group);
dialog.add(&page);
let fired = Rc::new(Cell::new(u32::MAX));
{
let f = fired.clone();
row.connect_changed(move |i| f.set(i));
}
win.present();
dialog.present(Some(&win));
pump();
// Suffix label reflects the seed.
assert_eq!(row.value_label.as_ref().unwrap().text(), "A");
// Row activation → subpage with the options list.
row.widget()
.downcast_ref::<adw::ActionRow>()
.unwrap()
.emit_by_name::<()>("activated", &[]);
pump();
let opt_b = find_action_row(dialog.upcast_ref(), "B").expect("subpage option missing");
// Option activation → state + label + callback, subpage popped.
opt_b.emit_by_name::<()>("activated", &[]);
pump();
assert_eq!(row.selected(), 1);
assert_eq!(fired.get(), 1);
assert_eq!(row.value_label.as_ref().unwrap().text(), "B");
// The dynamic-caption hook drives the same subtitle both row shapes expose.
set_row_subtitle(row.widget(), "swapped");
assert_eq!(
row.widget()
.downcast_ref::<adw::ActionRow>()
.unwrap()
.subtitle()
.as_deref(),
Some("swapped")
);
// Re-activating shows the check on the new selection (fresh subpage each time).
row.widget()
.downcast_ref::<adw::ActionRow>()
.unwrap()
.emit_by_name::<()>("activated", &[]);
pump();
assert!(find_action_row(dialog.upcast_ref(), "B").is_some());
// Desktop (ComboRow) mode: cell sync + change callback on selection change.
let combo = ChoiceRow::new(&dialog, false, "Codec", "", &["X", "Y"]);
combo.set_selected(1);
assert_eq!(combo.selected(), 1);
let combo_fired = Rc::new(Cell::new(u32::MAX));
{
let f = combo_fired.clone();
combo.connect_changed(move |i| f.set(i));
}
combo.set_selected(0);
assert_eq!(combo.selected(), 0);
assert_eq!(combo_fired.get(), 0);
// ComboRow derives from ActionRow, so the caption hook reaches it too.
set_row_subtitle(combo.widget(), "combo caption");
assert_eq!(
combo
.widget()
.downcast_ref::<adw::ActionRow>()
.unwrap()
.subtitle()
.as_deref(),
Some("combo caption")
);
}
}