//! 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) { 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>>>; /// 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>, /// 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, options: Rc>, } 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> = 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::>(), )) .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::() { 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, settings: Rc>, 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 = 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::>(), ); let hz_names: Vec = 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::>(), ); 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 = 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::>(), ); 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> = 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: >k::Widget, title: &str) -> Option { if let Some(row) = root.downcast_ref::() { 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::() .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::() .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); } }