9296e1bed7
apple / swift (push) Successful in 1m20s
apple / screenshots (push) Successful in 6m29s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m4s
ci / bench (push) Successful in 5m5s
android / android (push) Successful in 14m15s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
deb / build-publish (push) Successful in 10m12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
deb / build-publish-host (push) Successful in 9m36s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m10s
arch / build-publish (push) Successful in 21m29s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9m48s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10m32s
flatpak / build-publish (push) Successful in 6m21s
ci / rust (push) Has been cancelled
docker / deploy-docs (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
AdwPreferencesDialog parks its view switcher in a bottom bar whenever the dialog is narrower than 110pt × page count (≈ 733 px for our five pages), and the default float width (~640 px) is always under that — so the tabs could never reach the header. 830 px puts them there for good (the tabbed look the Apple and Windows clients share) with margin to spare, and gives the caption-bearing rows room to breathe. A window too small to grant the width still collapses the switcher to the bottom bar on its own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1015 lines
39 KiB
Rust
1015 lines
39 KiB
Rust
//! 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(>k::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(>k::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 & 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(>k::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;
|
||
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].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: >k::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")
|
||
);
|
||
}
|
||
}
|