//! 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. //! //! The same surface edits SETTINGS PROFILES (design/client-settings-profiles.md §5.1): a //! scope switcher at the top swaps the whole dialog between the global defaults and one //! profile's overrides. It is deliberately not a second editor — a parallel one would drift //! from this one field by field. In profile scope only profileable ("tier P") rows render, //! every row shows the EFFECTIVE value (the inherited global until you touch it), and the //! override is recorded on touch rather than by comparing values, so a profile can pin a //! value that happens to equal today's global and keep it when the global later moves. use crate::trust::Settings; use adw::prelude::*; use pf_client_core::profiles::{ProfilesFile, SettingsOverlay, StreamProfile}; use pf_client_core::trust::StatsVerbosity; use std::cell::{Cell, RefCell}; use std::collections::HashSet; use std::rc::Rc; /// Which layer the dialog is editing. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Scope { /// The global defaults every profile inherits from — the only scope before this feature. Defaults, /// One profile's overrides, by id. Profile(String), } /// Which rows the user actually touched this session. The override model is explicit, not /// diffed: touching a control creates the override, and only an explicit reset removes it /// (design §4.1). Keys are the overlay's field names, with `resolution` covering the /// width/height/match-window tri-state that one row drives. #[derive(Clone, Default)] struct Touched { keys: Rc>>, /// Set while a control is being changed BY US (putting a reset row back to the inherited /// value). Without it the programmatic change would read as the user touching the row and /// immediately re-create the override the reset just removed. suspended: Rc>, } impl Touched { fn mark(&self, key: &'static str) { self.keys.borrow_mut().insert(key); } fn suspended(&self) -> bool { self.suspended.get() } fn set_suspended(&self, v: bool) { self.suspended.set(v); } fn has(&self, key: &str) -> bool { self.keys.borrow().contains(key) } /// Undo a touch — the user reset this row after changing it, and "back to inheriting" is /// the later, explicit intent. fn forget(&self, key: &str) { self.keys.borrow_mut().remove(key); } } /// `(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]; /// Where each picker sits for a given settings snapshot. Factored out because two places need /// exactly this: seeding the dialog, and putting ONE row back to the inherited value when its /// override is reset — and those two must agree, or a reset would land on a different option /// than reopening the dialog would show. mod index { use super::*; pub fn resolution(s: &Settings) -> u32 { // Index 1 is the virtual "Match window" entry; 0 = Native, 2.. = the explicit sizes. let 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) }; i as u32 } pub fn refresh(s: &Settings) -> u32 { REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0) as u32 } pub fn render_scale(s: &Settings) -> u32 { RENDER_SCALES .iter() .position(|&x| (x - s.render_scale).abs() < 1e-6) .unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap()) as u32 } pub fn codec(s: &Settings) -> u32 { CODECS.iter().position(|&c| c == s.codec).unwrap_or(0) as u32 } pub fn compositor(s: &Settings) -> u32 { COMPOSITORS .iter() .position(|&c| c == s.compositor) .unwrap_or(0) as u32 } pub fn stats(s: &Settings) -> u32 { StatsVerbosity::ALL .iter() .position(|v| *v == s.stats_verbosity()) .unwrap_or(0) as u32 } pub fn touch(s: &Settings) -> u32 { TOUCH_MODES .iter() .position(|&t| t == s.touch_mode) .unwrap_or(0) as u32 } pub fn mouse(s: &Settings) -> u32 { MOUSE_MODES .iter() .position(|&m| m == s.mouse_mode) .unwrap_or(0) as u32 } pub fn surround(s: &Settings) -> u32 { match s.audio_channels { 6 => 1, 8 => 2, _ => 0, } } pub fn gamepad(s: &Settings) -> u32 { GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0) as u32 } } /// The chip palette a profile can carry (`StreamProfile.accent`). Eight entries rather than a /// full colour picker: the point is telling profiles apart at a glance on a host card, which a /// small set of legible, contrast-checked colours does better than free choice — and the /// schema's `#RRGGBB` still accepts anything a future picker or a hand-edit writes. const SWATCHES: &[(&str, &str, &str)] = &[ ("", "pf-swatch-none", "No colour"), ("#e01b24", "pf-swatch-red", "Red"), ("#ff7800", "pf-swatch-orange", "Orange"), ("#f6d32d", "pf-swatch-yellow", "Yellow"), ("#33d17a", "pf-swatch-green", "Green"), ("#3584e4", "pf-swatch-blue", "Blue"), ("#9141ac", "pf-swatch-purple", "Purple"), ("#d16d9e", "pf-swatch-pink", "Pink"), ("#77767b", "pf-swatch-slate", "Slate"), ]; /// A colour picker as its own full-width row: the caption above, the swatches on their own /// line beneath, wrapping if the dialog is narrow. /// /// They started as an [`adw::ActionRow`] suffix, which is where a control that size normally /// goes — but nine swatches squeeze the row's title until it is unreadable, and squeezing the /// label to fit the control is backwards. A `FlowBox` beneath keeps both legible at any width. fn colour_row( title: &str, current: Option<&str>, on_pick: impl Fn(String) + 'static, ) -> adw::PreferencesRow { let box_ = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(8) .margin_top(12) .margin_bottom(12) .margin_start(12) .margin_end(12) .build(); box_.append( >k::Label::builder() .label(title) .halign(gtk::Align::Start) .build(), ); box_.append(&swatch_row(current, on_pick)); adw::PreferencesRow::builder() .activatable(false) .child(&box_) .build() } /// The swatches themselves, calling back with the chosen `#RRGGBB` (empty = none). fn swatch_row(current: Option<&str>, on_pick: impl Fn(String) + 'static) -> gtk::FlowBox { let row = gtk::FlowBox::builder() .orientation(gtk::Orientation::Horizontal) .selection_mode(gtk::SelectionMode::None) .column_spacing(8) .row_spacing(8) .min_children_per_line(5) .max_children_per_line(9) // NOT homogeneous, and start-aligned: a homogeneous FlowBox stretches every child to // the widest one, which turns 26px circles into wide rounded rectangles the moment the // row has space to spare. .homogeneous(false) .halign(gtk::Align::Start) .build(); let on_pick = Rc::new(on_pick); let buttons: Rc>> = Rc::default(); for (hex, class, name) in SWATCHES { let b = gtk::Button::builder() .css_classes(["pf-swatch", class, "circular"]) .tooltip_text(*name) .valign(gtk::Align::Center) .build(); if current.unwrap_or("") == *hex { b.add_css_class("pf-swatch-on"); } { let (on_pick, buttons, hex) = (on_pick.clone(), buttons.clone(), hex.to_string()); b.connect_clicked(move |me| { // The selection ring is exclusive, so clear every sibling before setting ours. for (_, other) in buttons.borrow().iter() { other.remove_css_class("pf-swatch-on"); } me.add_css_class("pf-swatch-on"); on_pick(hex.clone()); }); } buttons.borrow_mut().push((hex.to_string(), b.clone())); row.insert(&b, -1); } row } /// The scope switcher, plus (in profile scope) that profile's management actions. /// /// Switching scope does not swap the rows in place: it closes the dialog — which commits the /// layer being edited — and asks the app to re-open in the new scope. One code path builds /// the rows, and the commit ordering is unambiguous. #[allow(clippy::too_many_arguments)] fn scope_group( dialog: &adw::PreferencesDialog, inline: bool, scope: &Scope, catalog: &ProfilesFile, active: Option<&StreamProfile>, next_scope: &Rc>>, parent: &impl IsA, ) -> adw::PreferencesGroup { let g = group( "", "A profile overrides only what you change here; everything else follows Default \ settings.", ); let mut labels: Vec = vec!["Default settings".into()]; labels.extend(catalog.profiles.iter().map(|p| p.name.clone())); labels.push("New profile…".into()); let new_index = (labels.len() - 1) as u32; let current = match scope { Scope::Defaults => 0, Scope::Profile(id) => catalog .profiles .iter() .position(|p| &p.id == id) .map_or(0, |i| i as u32 + 1), }; let row = ChoiceRow::new( dialog, inline, "Editing", "Which layer these settings belong to", &labels.iter().map(String::as_str).collect::>(), ); row.set_selected(current); { let (dialog, next, parent) = (dialog.clone(), next_scope.clone(), parent.as_ref().clone()); let ids: Vec = catalog.profiles.iter().map(|p| p.id.clone()).collect(); row.connect_changed(move |i| { if i == new_index { // Creation is the one branch that has to ask a question first; the switch // happens in its callback, so a cancelled prompt leaves the dialog put. let (dialog, next) = (dialog.clone(), next.clone()); prompt_name( &parent, "New profile", "Create", "", true, move |name, accent| { let mut catalog = ProfilesFile::load(); if catalog.name_taken(&name, None) { return; // the prompt already refuses these; belt and braces } let mut profile = StreamProfile::new(name); profile.accent = accent; let id = profile.id.clone(); catalog.profiles.push(profile); if catalog.save().is_ok() { *next.borrow_mut() = Some(Scope::Profile(id)); dialog.close(); } }, ); return; } *next.borrow_mut() = Some(match i { 0 => Scope::Defaults, n => match ids.get(n as usize - 1) { Some(id) => Scope::Profile(id.clone()), None => Scope::Defaults, }, }); dialog.close(); }); } g.add(row.widget()); // Leaking the row keeps its handler alive for the dialog's lifetime — the ChoiceRow owns // the closure, and the widget alone doesn't keep it. std::mem::forget(row); if let Some(active) = active { // Colour first: it is what the profile's chips carry on host cards, so it belongs with // the profile's identity rather than buried behind a menu. g.add(&colour_row( "Colour \u{2014} tints this profile's chips on host cards", active.accent.as_deref(), { let id = active.id.clone(); move |hex| { let mut catalog = ProfilesFile::load(); if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) { p.accent = (!hex.is_empty()).then(|| hex.clone()); if let Err(e) = catalog.save() { tracing::warn!(error = %format!("{e:#}"), "saving the profile colour"); } } } }, )); let actions = adw::ActionRow::builder() .title(&active.name) .subtitle("This profile") .use_markup(false) .build(); let buttons = gtk::Box::builder() .spacing(6) .valign(gtk::Align::Center) .build(); for (label, action) in [ ("Rename…", ProfileAction::Rename), ("Duplicate", ProfileAction::Duplicate), ("Delete…", ProfileAction::Delete), ] { let b = gtk::Button::builder().label(label).build(); if matches!(action, ProfileAction::Delete) { b.add_css_class("destructive-action"); } let (dialog, next, parent, id, name) = ( dialog.clone(), next_scope.clone(), parent.as_ref().clone(), active.id.clone(), active.name.clone(), ); b.connect_clicked(move |_| { run_profile_action(action, &parent, &dialog, &next, &id, &name) }); buttons.append(&b); } actions.add_suffix(&buttons); g.add(&actions); } g } #[derive(Clone, Copy)] enum ProfileAction { Rename, Duplicate, Delete, } /// Rename / duplicate / delete for the profile in scope. Each ends by closing the dialog so /// the edit and the re-render can't disagree about what the catalog holds. fn run_profile_action( action: ProfileAction, parent: >k::Widget, dialog: &adw::PreferencesDialog, next: &Rc>>, id: &str, name: &str, ) { let (dialog, next, id) = (dialog.clone(), next.clone(), id.to_string()); match action { ProfileAction::Rename => { let keep = id.clone(); prompt_name( parent, "Rename profile", "Rename", name, false, move |new_name, _| { let mut catalog = ProfilesFile::load(); if catalog.name_taken(&new_name, Some(&keep)) { return; } if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == keep) { p.name = new_name; } if catalog.save().is_ok() { *next.borrow_mut() = Some(Scope::Profile(keep.clone())); dialog.close(); } }, ); } ProfileAction::Duplicate => { let mut catalog = ProfilesFile::load(); let Some(source) = catalog.find_by_id(&id).cloned() else { return; }; // "Work 2", "Work 3", … — the first name the catalog doesn't already hold. let copy_name = (2..) .map(|n| format!("{} {n}", source.name)) .find(|n| !catalog.name_taken(n, None)) .unwrap_or_else(|| source.name.clone()); let mut copy = StreamProfile::new(copy_name); copy.overrides = source.overrides.clone(); copy.accent = source.accent.clone(); let new_id = copy.id.clone(); catalog.profiles.push(copy); if catalog.save().is_ok() { *next.borrow_mut() = Some(Scope::Profile(new_id)); dialog.close(); } } ProfileAction::Delete => { // The warning counts what actually breaks: hosts that fall back to the defaults, // and pinned cards that disappear (design §6). let known = crate::trust::KnownHosts::load(); let bound = known .hosts .iter() .filter(|h| h.profile_id.as_deref() == Some(id.as_str())) .count(); let pinned = known .hosts .iter() .filter(|h| h.pinned_profiles.iter().any(|p| p == &id)) .count(); let mut body = format!("“{name}” will be removed."); if bound > 0 { body.push_str(&format!( "\n\n{bound} host{} will fall back to Default settings.", if bound == 1 { "" } else { "s" } )); } if pinned > 0 { body.push_str(&format!( "\n{pinned} pinned card{} will disappear.", if pinned == 1 { "" } else { "s" } )); } let confirm = adw::AlertDialog::new(Some("Delete profile?"), Some(&body)); confirm.add_responses(&[("cancel", "Cancel"), ("delete", "Delete")]); confirm.set_response_appearance("delete", adw::ResponseAppearance::Destructive); confirm.set_close_response("cancel"); confirm.connect_response(Some("delete"), move |_, _| { let mut catalog = ProfilesFile::load(); catalog.profiles.retain(|p| p.id != id); if catalog.save().is_ok() { // Bindings and pins are left dangling on purpose: they resolve as "no // profile" everywhere, and rewriting every host record here would be a // second, racier source of truth. *next.borrow_mut() = Some(Scope::Defaults); dialog.close(); } }); confirm.present(Some(parent)); } } } /// A one-line name prompt (create/rename). Refuses empty and duplicate names in place — /// menus keyed by name are ambiguous otherwise (design §6) — rather than failing after the /// dialog is gone. fn prompt_name( parent: &impl IsA, heading: &str, accept: &str, initial: &str, with_colour: bool, on_ok: impl Fn(String, Option) + 'static, ) { let dialog = adw::AlertDialog::new(Some(heading), None); let entry = adw::EntryRow::builder().title("Name").build(); entry.set_text(initial); let list = gtk::ListBox::builder() .selection_mode(gtk::SelectionMode::None) .css_classes(["boxed-list"]) .build(); list.append(&entry); // Creating a profile picks its colour here, in the same breath as its name — going hunting // for it afterwards is exactly the friction that leaves every profile grey. let accent: Rc>> = Rc::default(); if with_colour { list.append(&colour_row("Colour", None, { let accent = accent.clone(); move |hex| *accent.borrow_mut() = (!hex.is_empty()).then(|| hex.clone()) })); } dialog.set_extra_child(Some(&list)); dialog.add_responses(&[("cancel", "Cancel"), ("ok", accept)]); dialog.set_response_appearance("ok", adw::ResponseAppearance::Suggested); dialog.set_default_response(Some("ok")); dialog.set_close_response("cancel"); let taken_against = initial.to_string(); let e = entry.clone(); let d = dialog.clone(); let validate = move || { let name = e.text().trim().to_string(); let catalog = ProfilesFile::load(); let dup = !name.eq_ignore_ascii_case(&taken_against) && catalog.name_taken(&name, None); d.set_response_enabled("ok", !name.is_empty() && !dup); e.set_title(if dup { "Name — already used by another profile" } else { "Name" }); }; validate(); { let validate = validate.clone(); entry.connect_changed(move |_| validate()); } let e = entry.clone(); dialog.connect_response(Some("ok"), move |_, _| { let name = e.text().trim().to_string(); if !name.is_empty() { on_ok(name, accent.borrow().clone()); } }); dialog.present(Some(parent)); } /// Write the rows the user touched into this profile's overlay and persist the catalog. /// /// Only touched fields move: an untouched row leaves whatever the profile already had /// (an inherited `None`, or an existing override this build might not even render), which is /// what keeps an older client from erasing a newer one's values just by opening the dialog. /// The catalog is re-read here rather than reused, so a profile renamed in another window /// between opening and closing this one survives. fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings) { let mut catalog = ProfilesFile::load(); let Some(slot) = catalog.profiles.iter_mut().find(|p| p.id == active.id) else { return; // deleted from under us — nothing to write to, and nothing to complain about }; let o: &mut SettingsOverlay = &mut slot.overrides; if touched.has("resolution") { // One row drives the tri-state, so all three fields move together. o.match_window = Some(values.match_window); o.width = Some(values.width); o.height = Some(values.height); } if touched.has("refresh_hz") { o.refresh_hz = Some(values.refresh_hz); } if touched.has("render_scale") { o.render_scale = Some(values.render_scale); } if touched.has("bitrate_kbps") { o.bitrate_kbps = Some(values.bitrate_kbps); } if touched.has("codec") { o.codec = Some(values.codec.clone()); } if touched.has("hdr_enabled") { o.hdr_enabled = Some(values.hdr_enabled); } if touched.has("enable_444") { o.enable_444 = Some(values.enable_444); } if touched.has("compositor") { o.compositor = Some(values.compositor.clone()); } if touched.has("audio_channels") { o.audio_channels = Some(values.audio_channels); } if touched.has("mic_enabled") { o.mic_enabled = Some(values.mic_enabled); } if touched.has("touch_mode") { o.touch_mode = Some(values.touch_mode.clone()); } if touched.has("mouse_mode") { o.mouse_mode = Some(values.mouse_mode.clone()); } if touched.has("invert_scroll") { o.invert_scroll = Some(values.invert_scroll); } if touched.has("inhibit_shortcuts") { o.inhibit_shortcuts = Some(values.inhibit_shortcuts); } if touched.has("gamepad") { o.gamepad = Some(values.gamepad.clone()); } if touched.has("stats_verbosity") { o.stats_verbosity = Some(values.stats_verbosity()); } if touched.has("fullscreen_on_stream") { o.fullscreen_on_stream = Some(values.fullscreen_on_stream); } // Resets are not handled here: they clear the field and re-seed their row the moment the // user asks, so by the time this runs the catalog already reflects them and the row is no // longer marked touched. if let Err(e) = catalog.save() { tracing::warn!(error = %format!("{e:#}"), "saving the profile catalog"); } } /// 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 { // Just the multiplier: the row's caption already says what above and below 1× mean, // and "2× (supersample)" is long enough that the value ellipsizes to "2× (su…" the // moment the row grows another suffix. 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", ]; /// Physical-mouse model values (persisted) + labels + dynamic captions — same idiom as /// the touch rows. Ctrl+Alt+Shift+M flips the model live in-stream. const MOUSE_MODES: &[&str] = &["capture", "desktop"]; const MOUSE_MODE_LABELS: &[&str] = &["Capture (games)", "Desktop (absolute)"]; const MOUSE_MODE_CAPTIONS: &[&str] = &[ "Pointer locks to the stream — relative motion, best for games", "Pointer moves freely in and out — best for remote desktop work", ]; /// 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"); /// The dynamically linked system libraries — not in the crate notices, since they aren't /// crates. Their full texts ship with each project rather than being vendored here. const SYSTEM_LIBRARY_NOTICES: &str = "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."; /// 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) { // Every licence field here is PANGO MARKUP, not plain text — so a crate author's // `` reads as an unclosed tag and Pango drops the whole section with // "is not a valid name: @". The notices are generated from crate metadata and are full of // those, so they are escaped rather than trusted. Nothing in them wants markup anyway. let license = gtk::glib::markup_escape_text(APP_LICENSE); let crate_notices = gtk::glib::markup_escape_text(THIRD_PARTY_NOTICES); let system_notices = gtk::glib::markup_escape_text(SYSTEM_LIBRARY_NOTICES); let about = adw::AboutDialog::builder() .application_name("Punktfunk") // The app's own icon, by the id the desktop entry and the icon theme both use. It // resolves from the installed hicolor icon; an uninstalled dev run simply shows the // generic fallback rather than nothing. .application_icon(crate::app::APP_ID) .developer_name("unom") .version(env!("CARGO_PKG_VERSION")) .website("https://git.unom.io/unom/punktfunk") .license_type(gtk::License::Custom) .license(license.as_str()) .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(crate_notices.as_str()), ); about.add_legal_section( "Third-party software (system libraries)", None, gtk::License::Custom, Some(system_notices.as_str()), ); 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. #[derive(Clone)] struct ChoiceRow { row: adw::PreferencesRow, selected: Rc>, /// Fires on user changes only — handlers are installed after seeding, so programmatic /// `set_selected` during setup never fires them. A list, not one: a row can carry both a /// dynamic caption and (in profile scope) the override mark. 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(Vec::new())); 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() { for f in chg.borrow().iter() { 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 { for f in chg.borrow().iter() { 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().push(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::() { r.set_subtitle(text); cap_subtitle(r); } } /// How wide a row's caption may get before it wraps. Rows put the title and subtitle in one /// box and the control in the suffix, and that box asks for as much width as its longest line /// wants — so a long caption starves the value label next to it, which then ellipsizes ("2× /// (su…"). Capping the caption gives the width back to the control. /// /// The repo's standing rule was "keep captions to one line (~66 chars)", which works until a /// row grows another suffix — the override marker's reset button did exactly that. A cap is /// the structural version of the same rule: it holds however many suffixes a row ends up with. const CAPTION_CHARS: i32 = 46; /// Cap a row's caption width. The subtitle label isn't exposed by libadwaita, so it is found by /// matching its text — a miss simply leaves the row as it was, which is the pre-cap behaviour. fn cap_subtitle(row: &adw::ActionRow) { let subtitle = row.subtitle().unwrap_or_default(); if subtitle.is_empty() { return; } fn walk(w: >k::Widget, want: &str) -> Option { if let Some(l) = w.downcast_ref::() { if l.label() == want { return Some(l.clone()); } } let mut child = w.first_child(); while let Some(c) = child { if let Some(hit) = walk(&c, want) { return Some(hit); } child = c.next_sibling(); } None } if let Some(label) = walk(row.upcast_ref(), &subtitle) { label.set_wrap(true); label.set_max_width_chars(CAPTION_CHARS); label.set_xalign(0.0); // `max_width_chars` alone only caps what the label ASKS for; an expanding label still // fills whatever the row hands it and goes back to one long line. Refusing the expand // is what actually leaves the width for the control beside it. label.set_hexpand(false); // …and Start, not the default Fill: a filled label is allocated the whole box and // draws on one long line regardless of the cap. Start alignment makes the allocation // equal the (now capped) natural width, which is what makes the cap visible. label.set_halign(gtk::Align::Start); } } /// Walk a page and cap every caption it contains (see [`cap_subtitle`]). fn cap_captions(root: >k::Widget) { if let Some(row) = root.downcast_ref::() { cap_subtitle(row); } let mut child = root.first_child(); while let Some(c) = child { cap_captions(&c); child = c.next_sibling(); } } /// 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, pub speakers: Vec, pub mics: Vec, } /// 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 } /// The dialog in a given [`Scope`]. `on_scope` asks the app to re-open it in another one: /// switching scope closes this dialog first, so the layer being edited is committed before /// the next one is loaded, and there is exactly one place that builds the rows. pub fn show_scoped( parent: &impl IsA, settings: Rc>, gamepads: &crate::gamepad::GamepadService, probes: &DeviceProbes, scope: Scope, on_scope: impl Fn(Scope) + 'static, on_closed: impl Fn() + 'static, ) -> adw::PreferencesDialog { let catalog = ProfilesFile::load(); // A scope pointing at a deleted profile degrades to the defaults rather than erroring — // the same rule a dangling host binding follows. let active: Option = match &scope { Scope::Profile(id) => catalog.find_by_id(id).cloned(), Scope::Defaults => None, }; let profile_mode = active.is_some(); // Rows always show the EFFECTIVE value: the global underneath, with this profile's // overrides on top. A row the profile doesn't override therefore reads as the live // global, which is what "inherit by default" has to look like. let seed: Settings = match &active { Some(p) => p.overrides.apply(&settings.borrow()), None => settings.borrow().clone(), }; let touched = Touched::default(); // The globals as they are right now — what a reset row goes back to showing. Read before // the close handler takes ownership of the cell. let globals: Settings = settings.borrow().clone(); // Where a scope switch wants to go once this dialog has committed and closed. let next_scope: Rc>> = Rc::default(); // 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 = 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::>(), ); { let w = res_row.widget().clone(); res_row.connect_changed(move |i| set_row_subtitle(&w, resolution_caption(i))); } 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", "Native follows the monitor the window is on", &hz_names.iter().map(String::as_str).collect::>(), ); // ---- Display: Quality ---- let scale_names: Vec = 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::>(), ); 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 chroma_row = adw::SwitchRow::builder() .title("Full chroma (4:4:4)") .subtitle( "Full-colour video: crisp small text and thin lines, at more bandwidth. HEVC \ only, and only where the host can encode it.", ) .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 = seed.adapter.clone(); let mut gpu_names = vec!["Automatic".to_string()]; let mut gpu_keys: Vec = 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::>(), ); 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 mouse_row = ChoiceRow::new( &dialog, inline, "Mouse input", MOUSE_MODE_CAPTIONS[0], MOUSE_MODE_LABELS, ); { let w = mouse_row.widget().clone(); mouse_row.connect_changed(move |i| { let i = (i as usize).min(MOUSE_MODE_CAPTIONS.len() - 1); set_row_subtitle(&w, MOUSE_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::>(), ); 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 = 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::>(), ); 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 on the host — Automatic matches your controller", &[ "Automatic", "Xbox 360", "DualSense", "Xbox One", "DualShock 4", "Steam Deck", ], ); // ---- Seed from the effective settings for this scope ---- { let s = &seed; let res_i = index::resolution(s); res_row.set_selected(res_i); set_row_subtitle(res_row.widget(), resolution_caption(res_i)); hz_row.set_selected(index::refresh(s)); scale_row.set_selected(index::render_scale(s)); bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0); pad_row.set_selected(index::gamepad(s)); let touch_i = index::touch(s); touch_row.set_selected(touch_i); // set_selected never fires the changed hook, so seed the dynamic caption directly. set_row_subtitle(touch_row.widget(), TOUCH_MODE_CAPTIONS[touch_i as usize]); let mouse_i = index::mouse(s); mouse_row.set_selected(mouse_i); set_row_subtitle(mouse_row.widget(), MOUSE_MODE_CAPTIONS[mouse_i as usize]); compositor_row.set_selected(index::compositor(s)); let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0); decoder_row.set_selected(dec_i as u32); stats_row.set_selected(index::stats(s)); 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); chroma_row.set_active(s.enable_444); library_row.set_active(s.library_enabled); surround_row.set_selected(index::surround(s)); let codec_i = index::codec(s); codec_row.set_selected(codec_i); set_row_subtitle(codec_row.widget(), codec_caption(codec_i)); } // ---- Override markers, per-row reset, and the touch that creates an override ---- // One pass per row, because the three are the same fact from different sides: the marker // says "this profile changes this", the reset is the only way back (the override model // never infers "not overridden" from a value comparison), and touching the control is what // creates it. The marker appears ON TOUCH — a user who changes a row and sees no // acknowledgement has no reason to believe it took. // // A reset acts IN PLACE: it clears the field, puts that one control back to the inherited // value, and drops the marker. Rebuilding the dialog would be simpler, but it animates the // whole surface for a one-row change and dumps the user back on the first page — a heavy, // disorienting answer to "undo this row". // // Wired after the seed block on purpose: `set_selected`/`set_active` during setup must not // look like the user touching anything, or opening a profile would override every row. if let Some(active) = &active { let o = &active.overrides; let profile_id = active.id.clone(); // The marker pair, created LAZILY: a hidden prefix still costs its slot in the row's // layout, and every un-overridden row carrying an invisible dot's worth of inset reads // as a misalignment. Returns the "now it is overridden" switch the control's change // handler pulls. let mark = |row: &adw::PreferencesRow, key: &'static str, overridden: bool, revert_control: Box| -> Option> { let row = row.downcast_ref::()?.clone(); let widgets: Rc>> = Rc::default(); let (touched, id) = (touched.clone(), profile_id.clone()); let revert = Rc::new(revert_control); let build = { let (widgets, row, touched, id, revert) = ( widgets.clone(), row.clone(), touched.clone(), id.clone(), revert.clone(), ); Rc::new(move || { if widgets.borrow().is_some() { return; } let dot = gtk::Box::builder() .css_classes(["pf-override-dot"]) .valign(gtk::Align::Center) .build(); let reset = gtk::Button::builder() .icon_name("edit-undo-symbolic") .tooltip_text("Reset to Default settings") .valign(gtk::Align::Center) .css_classes(["flat"]) .build(); // BOTH on the left, never in the suffix. A suffix reset shifts the row's // control leftwards the instant an override appears — so the second click // on a Bitrate "+" lands on a revert button that moved under the pointer, // silently undoing the edit that had just been made. Nothing that appears // in response to a click may displace the thing that was clicked. row.add_prefix(&dot); row.add_prefix(&reset); { let (widgets, row, touched, id, revert) = ( widgets.clone(), row.clone(), touched.clone(), id.clone(), revert.clone(), ); reset.connect_clicked(move |_| { // Drop the override from the catalog now — the close handler // re-reads it, so there is nothing to reconcile later — and // un-touch the row so the commit can't re-write what this removed. touched.forget(key); let mut catalog = ProfilesFile::load(); if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) { p.overrides.clear(key); if let Err(e) = catalog.save() { tracing::warn!(error = %format!("{e:#}"), "clearing an override"); } } revert(); if let Some((dot, reset)) = widgets.borrow_mut().take() { row.remove(&dot); row.remove(&reset); } }); } *widgets.borrow_mut() = Some((dot, reset)); }) as Rc }; if overridden { build(); } Some(build) }; // Each row: how to put it back to the INHERITED value (the globals, not this // profile's), and what marks it touched. macro_rules! choice { ($row:expr, $key:literal, $overridden:expr, $idx:path) => {{ let revert = { let (row, globals, touched) = ($row.clone(), globals.clone(), touched.clone()); Box::new(move || { touched.set_suspended(true); row.set_selected($idx(&globals)); touched.set_suspended(false); }) as Box }; let show = mark($row.widget(), $key, $overridden, revert); let t = touched.clone(); $row.connect_changed(move |_| { if t.suspended() { return; } t.mark($key); if let Some(show) = &show { show(); } }); }}; } macro_rules! toggle { ($row:expr, $key:literal, $overridden:expr, $field:ident) => {{ let revert = { let (row, globals, touched) = ($row.clone(), globals.clone(), touched.clone()); Box::new(move || { touched.set_suspended(true); row.set_active(globals.$field); touched.set_suspended(false); }) as Box }; let show = mark($row.upcast_ref(), $key, $overridden, revert); let t = touched.clone(); $row.connect_active_notify(move |_| { if t.suspended() { return; } t.mark($key); if let Some(show) = &show { show(); } }); }}; } choice!( res_row, "resolution", o.width.is_some() || o.height.is_some() || o.match_window.is_some(), index::resolution ); choice!(hz_row, "refresh_hz", o.refresh_hz.is_some(), index::refresh); choice!( scale_row, "render_scale", o.render_scale.is_some(), index::render_scale ); choice!(codec_row, "codec", o.codec.is_some(), index::codec); choice!( compositor_row, "compositor", o.compositor.is_some(), index::compositor ); choice!( stats_row, "stats_verbosity", o.stats_verbosity.is_some(), index::stats ); choice!( touch_row, "touch_mode", o.touch_mode.is_some(), index::touch ); choice!( mouse_row, "mouse_mode", o.mouse_mode.is_some(), index::mouse ); choice!( surround_row, "audio_channels", o.audio_channels.is_some(), index::surround ); choice!(pad_row, "gamepad", o.gamepad.is_some(), index::gamepad); toggle!(hdr_row, "hdr_enabled", o.hdr_enabled.is_some(), hdr_enabled); toggle!(chroma_row, "enable_444", o.enable_444.is_some(), enable_444); toggle!( fullscreen_row, "fullscreen_on_stream", o.fullscreen_on_stream.is_some(), fullscreen_on_stream ); toggle!( inhibit_row, "inhibit_shortcuts", o.inhibit_shortcuts.is_some(), inhibit_shortcuts ); toggle!( invert_row, "invert_scroll", o.invert_scroll.is_some(), invert_scroll ); toggle!(mic_row, "mic_enabled", o.mic_enabled.is_some(), mic_enabled); { let revert = { let (row, globals, touched) = (bitrate_row.clone(), globals.clone(), touched.clone()); Box::new(move || { touched.set_suspended(true); row.set_value(f64::from(globals.bitrate_kbps) / 1000.0); touched.set_suspended(false); }) as Box }; let show = mark( bitrate_row.upcast_ref(), "bitrate_kbps", o.bitrate_kbps.is_some(), revert, ); let t = touched.clone(); bitrate_row.connect_value_notify(move |_| { if t.suspended() { return; } t.mark("bitrate_kbps"); if let Some(show) = &show { show(); } }); } } // ---- Assemble the category pages (the Apple revamp's map) ---- let general = page("General", "preferences-system-symbolic"); // The scope switcher heads the first page — the one row that is always about which layer // you are editing, not about the stream. general.add(&scope_group( &dialog, inline, &scope, &catalog, active.as_ref(), &next_scope, parent, )); let session_group = group("Session", ""); session_group.add(&fullscreen_row); // Auto-wake is a property of the host and this network, not of "Game vs Work" — it stays // global in v1 (design §3, tier H/G). if !profile_mode { session_group.add(&wake_row); } let stats_group = group("Statistics", ""); stats_group.add(stats_row.widget()); general.add(&session_group); general.add(&stats_group); // The library browser is an app-level toggle for this device, not a per-profile one. if !profile_mode { let library_group = group("Library", ""); library_group.add(&library_row); 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(&chroma_row); // Decoder and GPU are facts about THIS device's hardware — never per profile (tier G). if !profile_mode { quality_group.add(decoder_row.widget()); } if let (Some(r), false) = (&gpu_row, profile_mode) { 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(mouse_row.widget()); 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()); // The speaker/mic endpoint pickers below are this device's audio routing (tier G) — they // render only in the defaults scope; the surround + mic-uplink rows above are profileable. if let (Some(r), false) = (&speaker_row, profile_mode) { audio_group.add(r.widget()); } audio_group.add(&mic_row); if let (Some(r), false) = (&micdev_row, profile_mode) { 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. It is // about the hardware plugged into THIS device, so profile scope shows only the // emulated-type picker below it. if profile_mode { // nothing — the pad inventory belongs to the device, not the profile } else 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); } } if !profile_mode { controllers_group.add(forward_row.widget()); } controllers_group.add(pad_row.widget()); controllers.add(&controllers_group); // Cap every caption in one pass, after the rows exist: a per-row call would be sixteen // easy-to-forget lines, and a row added later would silently miss it. for page in [&general, &display, &input, &audio, &controllers] { cap_captions(page.upcast_ref()); } dialog.add(&general); dialog.add(&display); dialog.add(&input); dialog.add(&audio); dialog.add(&controllers); dialog.connect_closed(move |_| { // One reader for the rows, two destinations: the globals, or a profile's overrides. // Sharing it is what keeps the two scopes from interpreting the same controls // differently (the tri-state resolution row is the obvious trap). let apply_rows = |s: &mut Settings| { // 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.mouse_mode = MOUSE_MODES[(mouse_row.selected() as usize).min(MOUSE_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.enable_444 = chroma_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(); }; match &active { // Profile scope writes the touched rows into the catalog and leaves the globals // exactly as they were — the point of the whole feature. Some(active) => { let mut values = seed.clone(); apply_rows(&mut values); commit_profile(active, &touched, &values); } None => { // Rebase on the file, not the shell's start-of-app snapshot: the settings file // has other whole-file writers (the spawner persists `last_window_w/h` after a // match-window resize — see `profiles.rs` on why there's no merge), and saving // the stale snapshot here would silently revert whatever they stored while this // app was open. The rows carry every value this dialog owns, so applying them // onto a fresh load loses nothing. let mut s = settings.borrow_mut(); *s = Settings::load(); apply_rows(&mut s); s.save(); } } // A scope switch closed this dialog to commit first; now re-open in the new scope. if let Some(next) = next_scope.borrow_mut().take() { on_scope(next); } 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 { 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"); // The dynamic-caption hook drives the same subtitle both row shapes expose. set_row_subtitle(row.widget(), "swapped"); assert_eq!( row.widget() .downcast_ref::() .unwrap() .subtitle() .as_deref(), Some("swapped") ); // 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); // ComboRow derives from ActionRow, so the caption hook reaches it too. set_row_subtitle(combo.widget(), "combo caption"); assert_eq!( combo .widget() .downcast_ref::() .unwrap() .subtitle() .as_deref(), Some("combo caption") ); } }