Files
punktfunk/clients/linux/src/ui_settings.rs
T
enricobuehler e9c1f4083a fix(client-linux): Deck Gaming Mode — auto pad type, real chrome-less fullscreen, leave-to-Gaming-Mode, colour bisect
- "Automatic" gamepad type resolves to the virtual Steam Deck pad on Deck
  hardware (env SteamDeck / DMI Jupiter|Galileo): the built-in 28DE:1205
  identity is invisible at Hello time — the Valve HIDAPI drivers run
  in-session only and Steam Input shadows the pad with its virtual X360 —
  so auto always fell through to Xbox 360. "steamdeck" is now also
  selectable in Settings.
- Chrome-less launches flatten the window CSS (border-radius/box-shadow)
  and fullscreen at startup: gamescope never ACKs the xdg fullscreen
  state, so adwaita kept the floating-CSD rounded corners + shadow
  visible over the stream.
- Gaming-Mode --connect launches quit on session end, so Steam ends the
  "game" and the Deck returns to Gaming Mode — previously the app popped
  to its own hosts page, stranding the user fullscreen and making the
  escape chord read as broken.
- The capture hint is controller-aware; the chromeless hint teaches the
  hold-chord ("hold L1+R1+Start+Select to leave") and a quick chord press
  re-flashes it.
- Colour bisect for the reported off-colours on the VAAPI dmabuf path:
  graphics offload defaults OFF under gamescope (a subsurface hands the
  NV12 CSC to the compositor), PUNKTFUNK_OFFLOAD=1|0 overrides, and each
  colour-signaling change logs whether GDK accepted the BT.709-narrow
  color state (fallback = GDK's BT.601 dmabuf default).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:16:26 +00:00

623 lines
23 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: stream mode, bitrate, host compositor, gamepad type, microphone,
//! capture behavior. Written back to disk when the dialog closes.
use crate::trust::Settings;
use adw::prelude::*;
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];
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.
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1"];
const DECODERS: &[&str] = &["auto", "vaapi", "software"];
/// 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));
}
}
/// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid
/// there so the experimental library toggle takes effect without a nav round-trip).
pub fn show(
parent: &impl IsA<gtk::Widget>,
settings: Rc<RefCell<Settings>>,
gamepads: &crate::gamepad::GamepadService,
on_closed: impl Fn() + 'static,
) {
// 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");
let inline = gamescope_session();
let page = adw::PreferencesPage::new();
let stream = adw::PreferencesGroup::builder().title("Stream").build();
let res_names: Vec<String> = RESOLUTIONS
.iter()
.map(|&(w, h)| {
if w == 0 {
"Native display".to_string()
} else {
format!("{w} × {h}")
}
})
.collect();
let res_row = ChoiceRow::new(
&dialog,
inline,
"Resolution",
"The host creates a virtual output at exactly this size",
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
);
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",
"",
&hz_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 · run a speed test before going high");
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",
],
);
let decoder_row = ChoiceRow::new(
&dialog,
inline,
"Video decoder",
"Automatic tries VAAPI hardware decode, then software",
&[
"Automatic (VAAPI → software)",
"Hardware (VAAPI)",
"Software",
],
);
let stats_row = adw::SwitchRow::builder()
.title("Show statistics overlay")
.subtitle("fps · bitrate · latency on the stream — Ctrl+Alt+Shift+S toggles live")
.build();
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();
stream.add(res_row.widget());
stream.add(hz_row.widget());
stream.add(&bitrate_row);
stream.add(compositor_row.widget());
stream.add(decoder_row.widget());
stream.add(&fullscreen_row);
stream.add(&stats_row);
let input = adw::PreferencesGroup::builder().title("Input").build();
// Which physical controller forwards as pad 0: automatic = the most recently connected
// real pad (Steam's virtual pad skipped). A 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 (most recent)".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 {
"Exactly one controller is forwarded to the host"
},
&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 the host creates — Automatic matches the physical pad",
&[
"Automatic",
"Xbox 360",
"DualSense",
"Xbox One",
"DualShock 4",
"Steam Deck",
],
);
let inhibit_row = adw::SwitchRow::builder()
.title("Capture system shortcuts")
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
.build();
input.add(forward_row.widget());
input.add(pad_row.widget());
input.add(&inhibit_row);
let audio = adw::PreferencesGroup::builder().title("Audio").build();
let surround_row = ChoiceRow::new(
&dialog,
inline,
"Audio channels",
"Request stereo or surround (the host downmixes if its output has fewer)",
&["Stereo", "5.1 Surround", "7.1 Surround"],
);
audio.add(surround_row.widget());
let codec_row = ChoiceRow::new(
&dialog,
inline,
"Video codec",
"Preferred codec — the host falls back if it can't encode this one",
CODEC_LABELS,
);
stream.add(codec_row.widget());
let mic_row = adw::SwitchRow::builder()
.title("Stream microphone")
.subtitle("Send the default input device to the host's virtual microphone")
.build();
audio.add(&mic_row);
// Experimental — mirrors the Apple client's Experimental section (wording included).
let experimental = adw::PreferencesGroup::builder()
.title("Experimental")
.build();
let library_row = adw::SwitchRow::builder()
.title("Show game library")
.subtitle(
"Adds a “Browse library…” action to each saved host that lists its games \
(Steam + custom) via the host's management API — works once you've paired",
)
.build();
experimental.add(&library_row);
// About (with the license/third-party Legal pages) lives in the primary menu now.
page.add(&stream);
page.add(&input);
page.add(&audio);
page.add(&experimental);
// Seed from the current settings.
{
let s = settings.borrow();
let res_i = RESOLUTIONS
.iter()
.position(|&(w, h)| w == s.width && h == s.height)
.unwrap_or(0);
res_row.set_selected(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);
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 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);
stats_row.set_active(s.show_stats);
fullscreen_row.set_active(s.fullscreen_on_stream);
inhibit_row.set_active(s.inhibit_shortcuts);
mic_row.set_active(s.mic_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);
}
dialog.add(&page);
dialog.connect_closed(move |_| {
let mut s = settings.borrow_mut();
let (w, h) = RESOLUTIONS[(res_row.selected() as usize).min(RESOLUTIONS.len() - 1)];
(s.width, s.height) = (w, h);
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.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.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();
s.show_stats = stats_row.is_active();
s.fullscreen_on_stream = fullscreen_row.is_active();
s.inhibit_shortcuts = inhibit_row.is_active();
s.mic_enabled = mic_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));
}
#[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");
// 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);
}
}