//! 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(Rc>>); impl Touched { fn mark(&self, key: &'static str) { self.0.borrow_mut().insert(key); } fn has(&self, key: &str) -> bool { self.0.borrow().contains(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]; /// 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", "", move |name| { let mut catalog = ProfilesFile::load(); if catalog.name_taken(&name, None) { return; // the prompt already refuses these; belt and braces } let profile = StreamProfile::new(name); 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 { 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, 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, on_ok: impl Fn(String) + '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); 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); } }); 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("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); } 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 if scale > 1.0 { format!("{scale}× (supersample)") } else { format!("{scale}×") } } const GAMEPADS: &[&str] = &[ "auto", "xbox360", "dualsense", "xboxone", "dualshock4", "steamdeck", ]; const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"]; /// Codec setting values (persisted) paired with their display labels below. PyroWave is /// preference-only by design (`Settings::preferred_codec`) — the ladder falls back to /// HEVC when either side can't do it. const CODECS: &[&str] = &["auto", "hevc", "h264", "av1", "pyrowave"]; const CODEC_LABELS: &[&str] = &[ "Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1", "PyroWave (wired LAN)", ]; const DECODERS: &[&str] = &["auto", "vulkan", "vaapi", "software"]; /// Touch-input model values (persisted) paired with their display labels below — the /// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet). const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"]; const TOUCH_MODE_LABELS: &[&str] = &["Trackpad", "Direct pointer", "Touch passthrough"]; /// The SELECTED touch mode explained — the caption swaps with the choice (the Apple /// revamp's dynamic-caption idiom) instead of narrating all three modes at once. /// Combo-row captions must stay ONE line (~66 chars at the default dialog width): a /// wrapped subtitle's natural width crushes the selected-value label into an ellipsis. const TOUCH_MODE_CAPTIONS: &[&str] = &[ "Drives the cursor like a laptop trackpad — tap to click", "The cursor jumps to your finger — a tap clicks there", "Real multi-touch reaches the host — for touch-native apps", ]; /// 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"); /// 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 — 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); } } /// 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 } /// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid /// there so the library toggle takes effect without a nav round-trip). `probes` is the /// shell's startup device probe (`AppModel::probes`) — may still be empty. Returns the /// presented dialog so the screenshot harness can select a page; callers ignore it. pub fn show( parent: &impl IsA, settings: Rc>, gamepads: &crate::gamepad::GamepadService, probes: &DeviceProbes, on_closed: impl Fn() + 'static, ) -> adw::PreferencesDialog { show_scoped( parent, settings, gamepads, probes, Scope::Defaults, |_| {}, on_closed, ) } /// 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(); // 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 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 = if s.match_window { 1 } else { RESOLUTIONS .iter() .position(|&(w, h)| w == s.width && h == s.height) .map(|i| if i == 0 { 0 } else { i + 1 }) .unwrap_or(0) }; res_row.set_selected(res_i as u32); set_row_subtitle(res_row.widget(), resolution_caption(res_i as u32)); let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0); hz_row.set_selected(hz_i as u32); let scale_i = RENDER_SCALES .iter() .position(|&x| (x - s.render_scale).abs() < 1e-6) .unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap()); scale_row.set_selected(scale_i as u32); bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0); let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0); pad_row.set_selected(pad_i as u32); let touch_i = TOUCH_MODES .iter() .position(|&t| t == s.touch_mode) .unwrap_or(0); touch_row.set_selected(touch_i as u32); // set_selected never fires the changed hook, so seed the dynamic caption directly. set_row_subtitle(touch_row.widget(), TOUCH_MODE_CAPTIONS[touch_i]); let mouse_i = MOUSE_MODES .iter() .position(|&m| m == s.mouse_mode) .unwrap_or(0); mouse_row.set_selected(mouse_i as u32); set_row_subtitle(mouse_row.widget(), MOUSE_MODE_CAPTIONS[mouse_i]); let comp_i = COMPOSITORS .iter() .position(|&c| c == s.compositor) .unwrap_or(0); compositor_row.set_selected(comp_i as u32); let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0); decoder_row.set_selected(dec_i as u32); let stats_i = StatsVerbosity::ALL .iter() .position(|v| *v == s.stats_verbosity()) .unwrap_or(0); stats_row.set_selected(stats_i as u32); fullscreen_row.set_active(s.fullscreen_on_stream); wake_row.set_active(s.auto_wake); inhibit_row.set_active(s.inhibit_shortcuts); invert_row.set_active(s.invert_scroll); mic_row.set_active(s.mic_enabled); hdr_row.set_active(s.hdr_enabled); library_row.set_active(s.library_enabled); surround_row.set_selected(match s.audio_channels { 6 => 1, 8 => 2, _ => 0, }); let codec_i = CODECS.iter().position(|&c| c == s.codec).unwrap_or(0); codec_row.set_selected(codec_i as u32); set_row_subtitle(codec_row.widget(), codec_caption(codec_i as u32)); } // ---- Override tracking (profile scope) ---- // Installed after the seed block on purpose: `set_selected`/`set_active` during setup must // not look like the user touching the control, or opening a profile would override every // row in it. if profile_mode { macro_rules! on_change { ($row:expr, $key:literal) => {{ let t = touched.clone(); $row.connect_changed(move |_| t.mark($key)); }}; } macro_rules! on_toggle { ($row:expr, $key:literal) => {{ let t = touched.clone(); $row.connect_active_notify(move |_| t.mark($key)); }}; } on_change!(res_row, "resolution"); on_change!(hz_row, "refresh_hz"); on_change!(scale_row, "render_scale"); on_change!(codec_row, "codec"); on_change!(compositor_row, "compositor"); on_change!(stats_row, "stats_verbosity"); on_change!(touch_row, "touch_mode"); on_change!(mouse_row, "mouse_mode"); on_change!(surround_row, "audio_channels"); on_change!(pad_row, "gamepad"); on_toggle!(hdr_row, "hdr_enabled"); on_toggle!(fullscreen_row, "fullscreen_on_stream"); on_toggle!(inhibit_row, "inhibit_shortcuts"); on_toggle!(invert_row, "invert_scroll"); on_toggle!(mic_row, "mic_enabled"); let t = touched.clone(); bitrate_row.connect_value_notify(move |_| t.mark("bitrate_kbps")); } // ---- 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); // 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); 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.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 => { let mut s = settings.borrow_mut(); 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") ); } }