e9c1f4083a
- "Automatic" gamepad type resolves to the virtual Steam Deck pad on Deck
hardware (env SteamDeck / DMI Jupiter|Galileo): the built-in 28DE:1205
identity is invisible at Hello time — the Valve HIDAPI drivers run
in-session only and Steam Input shadows the pad with its virtual X360 —
so auto always fell through to Xbox 360. "steamdeck" is now also
selectable in Settings.
- Chrome-less launches flatten the window CSS (border-radius/box-shadow)
and fullscreen at startup: gamescope never ACKs the xdg fullscreen
state, so adwaita kept the floating-CSD rounded corners + shadow
visible over the stream.
- Gaming-Mode --connect launches quit on session end, so Steam ends the
"game" and the Deck returns to Gaming Mode — previously the app popped
to its own hosts page, stranding the user fullscreen and making the
escape chord read as broken.
- The capture hint is controller-aware; the chromeless hint teaches the
hold-chord ("hold L1+R1+Start+Select to leave") and a quick chord press
re-flashes it.
- Colour bisect for the reported off-colours on the VAAPI dmabuf path:
graphics offload defaults OFF under gamescope (a subsurface hands the
NV12 CSC to the compositor), PUNKTFUNK_OFFLOAD=1|0 overrides, and each
colour-signaling change logs whether GDK accepted the BT.709-narrow
color state (fallback = GDK's BT.601 dmabuf default).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
623 lines
23 KiB
Rust
623 lines
23 KiB
Rust
//! Preferences dialog: stream mode, bitrate, host compositor, gamepad type, microphone,
|
||
//! capture behavior. Written back to disk when the dialog closes.
|
||
|
||
use crate::trust::Settings;
|
||
use adw::prelude::*;
|
||
use std::cell::{Cell, RefCell};
|
||
use std::rc::Rc;
|
||
|
||
/// `(0, 0)` = the native size of the monitor the window is on, resolved at connect.
|
||
const RESOLUTIONS: &[(u32, u32)] = &[
|
||
(0, 0),
|
||
(1280, 720),
|
||
(1920, 1080),
|
||
(2560, 1440),
|
||
(3840, 2160),
|
||
];
|
||
/// `0` = the monitor's native refresh, resolved at connect.
|
||
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
||
const GAMEPADS: &[&str] = &[
|
||
"auto",
|
||
"xbox360",
|
||
"dualsense",
|
||
"xboxone",
|
||
"dualshock4",
|
||
"steamdeck",
|
||
];
|
||
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||
/// Codec setting values (persisted) paired with their display labels below.
|
||
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
|
||
const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1"];
|
||
const DECODERS: &[&str] = &["auto", "vaapi", "software"];
|
||
|
||
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
|
||
const APP_LICENSE: &str = concat!(
|
||
"Punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n",
|
||
"================================ MIT ================================\n\n",
|
||
include_str!("../../../LICENSE-MIT"),
|
||
"\n\n=============================== Apache-2.0 ===============================\n\n",
|
||
include_str!("../../../LICENSE-APACHE"),
|
||
);
|
||
/// Third-party software notices for the linked Rust crates (generated by
|
||
/// scripts/gen-third-party-notices.sh; shown as a Legal section in the About dialog).
|
||
const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt");
|
||
|
||
/// Show the About dialog (app license + the third-party-software Legal section) — reached
|
||
/// from the primary menu (app.rs `win.about`).
|
||
pub fn show_about(parent: &impl IsA<gtk::Widget>) {
|
||
let about = adw::AboutDialog::builder()
|
||
.application_name("Punktfunk")
|
||
.developer_name("unom")
|
||
.version(env!("CARGO_PKG_VERSION"))
|
||
.website("https://git.unom.io/unom/punktfunk")
|
||
.license_type(gtk::License::Custom)
|
||
.license(APP_LICENSE)
|
||
.build();
|
||
// The native (FFmpeg/GTK/PipeWire/SDL3) components are dynamically linked under their own
|
||
// (LGPL/Zlib/MIT) licenses; the Rust crate notices are the substantive attribution set.
|
||
about.add_legal_section(
|
||
"Third-party software (Rust crates)",
|
||
None,
|
||
gtk::License::Custom,
|
||
Some(THIRD_PARTY_NOTICES),
|
||
);
|
||
about.add_legal_section(
|
||
"Third-party software (system libraries)",
|
||
None,
|
||
gtk::License::Custom,
|
||
Some(
|
||
"This application dynamically links system libraries under their own licenses, \
|
||
including FFmpeg (LGPL v2.1+), GTK 4 and libadwaita (LGPL v2.1+), PipeWire (MIT), \
|
||
and SDL 3 (Zlib). Their full license texts are available from each project.",
|
||
),
|
||
);
|
||
about.present(Some(parent));
|
||
}
|
||
|
||
/// True inside a gamescope session (Steam game mode on the Deck / Bazzite): GTK popovers
|
||
/// are xdg_popups, which gamescope never maps for nested apps — a ComboRow's dropdown
|
||
/// flashes the row but no list ever appears. Selection UI must stay inside the toplevel.
|
||
fn gamescope_session() -> bool {
|
||
std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|d| d.eq_ignore_ascii_case("gamescope"))
|
||
|| std::env::var("GAMESCOPE_WAYLAND_DISPLAY").is_ok()
|
||
}
|
||
|
||
type ChangedFn = Rc<RefCell<Option<Box<dyn Fn(u32)>>>>;
|
||
|
||
/// A titled single-choice preference row. On a desktop this is a stock popover
|
||
/// [`adw::ComboRow`]; under gamescope (see [`gamescope_session`]) it becomes an activatable
|
||
/// row that pushes an in-window selection subpage onto the preferences dialog instead.
|
||
struct ChoiceRow {
|
||
row: adw::PreferencesRow,
|
||
selected: Rc<Cell<u32>>,
|
||
/// Fires on user changes only — [`connect_changed`](Self::connect_changed) is installed
|
||
/// after seeding, so programmatic `set_selected` during setup never fires it.
|
||
changed: ChangedFn,
|
||
/// Subpage mode only: the current value rendered as the row's suffix.
|
||
value_label: Option<gtk::Label>,
|
||
options: Rc<Vec<String>>,
|
||
}
|
||
|
||
impl ChoiceRow {
|
||
/// `inline` = subpage mode (gamescope): computed once per dialog via
|
||
/// [`gamescope_session`] and passed in so tests can drive both modes directly.
|
||
fn new(
|
||
dialog: &adw::PreferencesDialog,
|
||
inline: bool,
|
||
title: &str,
|
||
subtitle: &str,
|
||
options: &[&str],
|
||
) -> ChoiceRow {
|
||
let options: Rc<Vec<String>> = Rc::new(options.iter().map(|s| s.to_string()).collect());
|
||
let selected = Rc::new(Cell::new(0u32));
|
||
let changed: ChangedFn = Rc::new(RefCell::new(None));
|
||
|
||
if !inline {
|
||
let row = adw::ComboRow::builder()
|
||
.title(title)
|
||
.subtitle(subtitle)
|
||
.model(>k::StringList::new(
|
||
&options.iter().map(String::as_str).collect::<Vec<_>>(),
|
||
))
|
||
.build();
|
||
let (sel, chg) = (selected.clone(), changed.clone());
|
||
row.connect_selected_notify(move |r| {
|
||
if sel.replace(r.selected()) != r.selected() {
|
||
if let Some(f) = chg.borrow().as_ref() {
|
||
f(r.selected());
|
||
}
|
||
}
|
||
});
|
||
return ChoiceRow {
|
||
row: row.upcast(),
|
||
selected,
|
||
changed,
|
||
value_label: None,
|
||
options,
|
||
};
|
||
}
|
||
|
||
let value = gtk::Label::builder().css_classes(["dim-label"]).build();
|
||
let row = adw::ActionRow::builder()
|
||
.title(title)
|
||
.subtitle(subtitle)
|
||
.activatable(true)
|
||
.build();
|
||
row.add_suffix(&value);
|
||
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||
{
|
||
let dialog = dialog.downgrade();
|
||
let (options, sel, chg, value) = (
|
||
options.clone(),
|
||
selected.clone(),
|
||
changed.clone(),
|
||
value.clone(),
|
||
);
|
||
let title = title.to_string();
|
||
row.connect_activated(move |_| {
|
||
let Some(dialog) = dialog.upgrade() else {
|
||
return;
|
||
};
|
||
let list = gtk::ListBox::builder()
|
||
.selection_mode(gtk::SelectionMode::None)
|
||
.css_classes(["boxed-list"])
|
||
.build();
|
||
for (i, opt) in options.iter().enumerate() {
|
||
let check = gtk::Image::from_icon_name("object-select-symbolic");
|
||
check.set_visible(i as u32 == sel.get());
|
||
let opt_row = adw::ActionRow::builder()
|
||
.title(opt)
|
||
.use_markup(false)
|
||
.activatable(true)
|
||
.build();
|
||
opt_row.add_suffix(&check);
|
||
let idx = i as u32;
|
||
let dlg = dialog.downgrade();
|
||
let (sel, chg, value, label) =
|
||
(sel.clone(), chg.clone(), value.clone(), opt.clone());
|
||
opt_row.connect_activated(move |_| {
|
||
let user_change = sel.replace(idx) != idx;
|
||
value.set_text(&label);
|
||
if user_change {
|
||
if let Some(f) = chg.borrow().as_ref() {
|
||
f(idx);
|
||
}
|
||
}
|
||
if let Some(d) = dlg.upgrade() {
|
||
d.pop_subpage();
|
||
}
|
||
});
|
||
list.append(&opt_row);
|
||
}
|
||
let clamp = adw::Clamp::builder()
|
||
.child(&list)
|
||
.margin_top(24)
|
||
.margin_bottom(24)
|
||
.margin_start(12)
|
||
.margin_end(12)
|
||
.build();
|
||
let scroll = gtk::ScrolledWindow::builder()
|
||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||
.child(&clamp)
|
||
.build();
|
||
let view = adw::ToolbarView::new();
|
||
view.add_top_bar(&adw::HeaderBar::new());
|
||
view.set_content(Some(&scroll));
|
||
dialog.push_subpage(&adw::NavigationPage::new(&view, &title));
|
||
});
|
||
}
|
||
let cr = ChoiceRow {
|
||
row: row.upcast(),
|
||
selected,
|
||
changed,
|
||
value_label: Some(value),
|
||
options,
|
||
};
|
||
cr.sync_value();
|
||
cr
|
||
}
|
||
|
||
/// Subpage mode: reflect the current selection in the row's suffix label.
|
||
fn sync_value(&self) {
|
||
if let Some(l) = &self.value_label {
|
||
let i = self.selected.get() as usize;
|
||
l.set_text(self.options.get(i).map(String::as_str).unwrap_or(""));
|
||
}
|
||
}
|
||
|
||
fn widget(&self) -> &adw::PreferencesRow {
|
||
&self.row
|
||
}
|
||
|
||
fn selected(&self) -> u32 {
|
||
self.selected.get()
|
||
}
|
||
|
||
fn set_selected(&self, i: u32) {
|
||
if let Some(combo) = self.row.downcast_ref::<adw::ComboRow>() {
|
||
combo.set_selected(i); // the notify handler syncs the cell
|
||
} else {
|
||
self.selected.set(i);
|
||
self.sync_value();
|
||
}
|
||
}
|
||
|
||
fn connect_changed(&self, f: impl Fn(u32) + 'static) {
|
||
*self.changed.borrow_mut() = Some(Box::new(f));
|
||
}
|
||
}
|
||
|
||
/// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid
|
||
/// there so the experimental library toggle takes effect without a nav round-trip).
|
||
pub fn show(
|
||
parent: &impl IsA<gtk::Widget>,
|
||
settings: Rc<RefCell<Settings>>,
|
||
gamepads: &crate::gamepad::GamepadService,
|
||
on_closed: impl Fn() + 'static,
|
||
) {
|
||
// The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection
|
||
// subpage onto it.
|
||
let dialog = adw::PreferencesDialog::new();
|
||
dialog.set_title("Preferences");
|
||
let inline = gamescope_session();
|
||
let page = adw::PreferencesPage::new();
|
||
|
||
let stream = adw::PreferencesGroup::builder().title("Stream").build();
|
||
let res_names: Vec<String> = RESOLUTIONS
|
||
.iter()
|
||
.map(|&(w, h)| {
|
||
if w == 0 {
|
||
"Native display".to_string()
|
||
} else {
|
||
format!("{w} × {h}")
|
||
}
|
||
})
|
||
.collect();
|
||
let res_row = ChoiceRow::new(
|
||
&dialog,
|
||
inline,
|
||
"Resolution",
|
||
"The host creates a virtual output at exactly this size",
|
||
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||
);
|
||
let hz_names: Vec<String> = REFRESH
|
||
.iter()
|
||
.map(|&r| {
|
||
if r == 0 {
|
||
"Native".to_string()
|
||
} else {
|
||
format!("{r} Hz")
|
||
}
|
||
})
|
||
.collect();
|
||
let hz_row = ChoiceRow::new(
|
||
&dialog,
|
||
inline,
|
||
"Refresh rate",
|
||
"",
|
||
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||
);
|
||
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
|
||
bitrate_row.set_title("Bitrate");
|
||
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
|
||
let compositor_row = ChoiceRow::new(
|
||
&dialog,
|
||
inline,
|
||
"Host compositor",
|
||
"Advisory — the host falls back to auto-detect when unavailable",
|
||
&[
|
||
"Automatic",
|
||
"KWin",
|
||
"wlroots (Sway/Hyprland)",
|
||
"Mutter (GNOME)",
|
||
"gamescope",
|
||
],
|
||
);
|
||
let decoder_row = ChoiceRow::new(
|
||
&dialog,
|
||
inline,
|
||
"Video decoder",
|
||
"Automatic tries VAAPI hardware decode, then software",
|
||
&[
|
||
"Automatic (VAAPI → software)",
|
||
"Hardware (VAAPI)",
|
||
"Software",
|
||
],
|
||
);
|
||
let stats_row = adw::SwitchRow::builder()
|
||
.title("Show statistics overlay")
|
||
.subtitle("fps · bitrate · latency on the stream — Ctrl+Alt+Shift+S toggles live")
|
||
.build();
|
||
let fullscreen_row = adw::SwitchRow::builder()
|
||
.title("Start streams in fullscreen")
|
||
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
|
||
.build();
|
||
stream.add(res_row.widget());
|
||
stream.add(hz_row.widget());
|
||
stream.add(&bitrate_row);
|
||
stream.add(compositor_row.widget());
|
||
stream.add(decoder_row.widget());
|
||
stream.add(&fullscreen_row);
|
||
stream.add(&stats_row);
|
||
|
||
let input = adw::PreferencesGroup::builder().title("Input").build();
|
||
// Which physical controller forwards as pad 0: automatic = the most recently connected
|
||
// real pad (Steam's virtual pad skipped). A pin is persisted by stable key
|
||
// (`Settings::forward_pad`), so it survives restarts — and disconnects: an offline
|
||
// pinned pad keeps its entry here instead of silently snapping back to Automatic.
|
||
let pads = gamepads.pads();
|
||
let saved_pin = settings.borrow().forward_pad.clone();
|
||
let mut pad_names = vec!["Automatic (most recent)".to_string()];
|
||
let mut pad_keys: Vec<String> = Vec::new();
|
||
for p in &pads {
|
||
let kind = p.kind_label();
|
||
pad_names.push(if kind.is_empty() {
|
||
p.name.clone()
|
||
} else {
|
||
format!("{} · {kind}", p.name)
|
||
});
|
||
pad_keys.push(p.key.clone());
|
||
}
|
||
if !saved_pin.is_empty() && !pad_keys.contains(&saved_pin) {
|
||
let name = saved_pin
|
||
.splitn(3, ':')
|
||
.nth(2)
|
||
.unwrap_or("Saved controller");
|
||
pad_names.push(format!("{name} (not connected)"));
|
||
pad_keys.push(saved_pin.clone());
|
||
}
|
||
let forward_row = ChoiceRow::new(
|
||
&dialog,
|
||
inline,
|
||
"Forwarded controller",
|
||
if pads.is_empty() {
|
||
"No controllers detected"
|
||
} else {
|
||
"Exactly one controller is forwarded to the host"
|
||
},
|
||
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||
);
|
||
let pinned_i = pad_keys
|
||
.iter()
|
||
.position(|k| k == &saved_pin)
|
||
.map_or(0, |i| i + 1);
|
||
forward_row.set_selected(pinned_i as u32);
|
||
// The dialog-local choice, written into Settings on close (reading the service back
|
||
// would race its worker thread applying the Pin message).
|
||
let chosen_pin: Rc<RefCell<String>> = Rc::new(RefCell::new(saved_pin));
|
||
{
|
||
let svc = gamepads.clone();
|
||
let keys = pad_keys.clone();
|
||
let chosen = chosen_pin.clone();
|
||
forward_row.connect_changed(move |sel| {
|
||
let key = if sel == 0 {
|
||
None
|
||
} else {
|
||
keys.get(sel as usize - 1).cloned()
|
||
};
|
||
*chosen.borrow_mut() = key.clone().unwrap_or_default();
|
||
svc.set_pinned(key);
|
||
});
|
||
}
|
||
let pad_row = ChoiceRow::new(
|
||
&dialog,
|
||
inline,
|
||
"Gamepad type",
|
||
"The virtual pad the host creates — Automatic matches the physical pad",
|
||
&[
|
||
"Automatic",
|
||
"Xbox 360",
|
||
"DualSense",
|
||
"Xbox One",
|
||
"DualShock 4",
|
||
"Steam Deck",
|
||
],
|
||
);
|
||
let inhibit_row = adw::SwitchRow::builder()
|
||
.title("Capture system shortcuts")
|
||
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
|
||
.build();
|
||
input.add(forward_row.widget());
|
||
input.add(pad_row.widget());
|
||
input.add(&inhibit_row);
|
||
|
||
let audio = adw::PreferencesGroup::builder().title("Audio").build();
|
||
let surround_row = ChoiceRow::new(
|
||
&dialog,
|
||
inline,
|
||
"Audio channels",
|
||
"Request stereo or surround (the host downmixes if its output has fewer)",
|
||
&["Stereo", "5.1 Surround", "7.1 Surround"],
|
||
);
|
||
audio.add(surround_row.widget());
|
||
let codec_row = ChoiceRow::new(
|
||
&dialog,
|
||
inline,
|
||
"Video codec",
|
||
"Preferred codec — the host falls back if it can't encode this one",
|
||
CODEC_LABELS,
|
||
);
|
||
stream.add(codec_row.widget());
|
||
let mic_row = adw::SwitchRow::builder()
|
||
.title("Stream microphone")
|
||
.subtitle("Send the default input device to the host's virtual microphone")
|
||
.build();
|
||
audio.add(&mic_row);
|
||
|
||
// Experimental — mirrors the Apple client's Experimental section (wording included).
|
||
let experimental = adw::PreferencesGroup::builder()
|
||
.title("Experimental")
|
||
.build();
|
||
let library_row = adw::SwitchRow::builder()
|
||
.title("Show game library")
|
||
.subtitle(
|
||
"Adds a “Browse library…” action to each saved host that lists its games \
|
||
(Steam + custom) via the host's management API — works once you've paired",
|
||
)
|
||
.build();
|
||
experimental.add(&library_row);
|
||
|
||
// About (with the license/third-party Legal pages) lives in the primary menu now.
|
||
page.add(&stream);
|
||
page.add(&input);
|
||
page.add(&audio);
|
||
page.add(&experimental);
|
||
|
||
// Seed from the current settings.
|
||
{
|
||
let s = settings.borrow();
|
||
let res_i = RESOLUTIONS
|
||
.iter()
|
||
.position(|&(w, h)| w == s.width && h == s.height)
|
||
.unwrap_or(0);
|
||
res_row.set_selected(res_i as u32);
|
||
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
|
||
hz_row.set_selected(hz_i as u32);
|
||
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
|
||
let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0);
|
||
pad_row.set_selected(pad_i as u32);
|
||
let comp_i = COMPOSITORS
|
||
.iter()
|
||
.position(|&c| c == s.compositor)
|
||
.unwrap_or(0);
|
||
compositor_row.set_selected(comp_i as u32);
|
||
let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0);
|
||
decoder_row.set_selected(dec_i as u32);
|
||
stats_row.set_active(s.show_stats);
|
||
fullscreen_row.set_active(s.fullscreen_on_stream);
|
||
inhibit_row.set_active(s.inhibit_shortcuts);
|
||
mic_row.set_active(s.mic_enabled);
|
||
library_row.set_active(s.library_enabled);
|
||
surround_row.set_selected(match s.audio_channels {
|
||
6 => 1,
|
||
8 => 2,
|
||
_ => 0,
|
||
});
|
||
let codec_i = CODECS.iter().position(|&c| c == s.codec).unwrap_or(0);
|
||
codec_row.set_selected(codec_i as u32);
|
||
}
|
||
|
||
dialog.add(&page);
|
||
dialog.connect_closed(move |_| {
|
||
let mut s = settings.borrow_mut();
|
||
let (w, h) = RESOLUTIONS[(res_row.selected() as usize).min(RESOLUTIONS.len() - 1)];
|
||
(s.width, s.height) = (w, h);
|
||
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
|
||
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
||
s.forward_pad = chosen_pin.borrow().clone();
|
||
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
||
.to_string();
|
||
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
|
||
s.show_stats = stats_row.is_active();
|
||
s.fullscreen_on_stream = fullscreen_row.is_active();
|
||
s.inhibit_shortcuts = inhibit_row.is_active();
|
||
s.mic_enabled = mic_row.is_active();
|
||
s.audio_channels = match surround_row.selected() {
|
||
1 => 6,
|
||
2 => 8,
|
||
_ => 2,
|
||
};
|
||
s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string();
|
||
s.library_enabled = library_row.is_active();
|
||
s.save();
|
||
drop(s);
|
||
on_closed();
|
||
});
|
||
dialog.present(Some(parent));
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Depth-first search for an [`adw::ActionRow`] with the given title.
|
||
fn find_action_row(root: >k::Widget, title: &str) -> Option<adw::ActionRow> {
|
||
if let Some(row) = root.downcast_ref::<adw::ActionRow>() {
|
||
if row.title() == title {
|
||
return Some(row.clone());
|
||
}
|
||
}
|
||
let mut child = root.first_child();
|
||
while let Some(c) = child {
|
||
if let Some(hit) = find_action_row(&c, title) {
|
||
return Some(hit);
|
||
}
|
||
child = c.next_sibling();
|
||
}
|
||
None
|
||
}
|
||
|
||
fn pump() {
|
||
let ctx = gtk::glib::MainContext::default();
|
||
while ctx.iteration(false) {}
|
||
}
|
||
|
||
/// Both ChoiceRow modes in ONE test (GTK is thread-affine and libtest gives every test
|
||
/// its own thread, so the display tests can't be split). Gamescope mode: activating the
|
||
/// row pushes the in-window selection subpage; activating an option updates the
|
||
/// selection + suffix label, fires the change callback, and pops the subpage. Combo
|
||
/// mode: cell sync + change callback. Needs a display — run manually with
|
||
/// `cargo test -p punktfunk-client-linux -- --ignored` on a session box.
|
||
#[test]
|
||
#[ignore = "needs a Wayland/X display"]
|
||
fn choice_row_modes() {
|
||
assert!(gtk::init().is_ok() && adw::init().is_ok(), "no display");
|
||
let win = adw::Window::new();
|
||
let dialog = adw::PreferencesDialog::new();
|
||
let page = adw::PreferencesPage::new();
|
||
let group = adw::PreferencesGroup::new();
|
||
let row = ChoiceRow::new(&dialog, true, "Resolution", "sub", &["A", "B", "C"]);
|
||
group.add(row.widget());
|
||
page.add(&group);
|
||
dialog.add(&page);
|
||
let fired = Rc::new(Cell::new(u32::MAX));
|
||
{
|
||
let f = fired.clone();
|
||
row.connect_changed(move |i| f.set(i));
|
||
}
|
||
win.present();
|
||
dialog.present(Some(&win));
|
||
pump();
|
||
|
||
// Suffix label reflects the seed.
|
||
assert_eq!(row.value_label.as_ref().unwrap().text(), "A");
|
||
|
||
// Row activation → subpage with the options list.
|
||
row.widget()
|
||
.downcast_ref::<adw::ActionRow>()
|
||
.unwrap()
|
||
.emit_by_name::<()>("activated", &[]);
|
||
pump();
|
||
let opt_b = find_action_row(dialog.upcast_ref(), "B").expect("subpage option missing");
|
||
|
||
// Option activation → state + label + callback, subpage popped.
|
||
opt_b.emit_by_name::<()>("activated", &[]);
|
||
pump();
|
||
assert_eq!(row.selected(), 1);
|
||
assert_eq!(fired.get(), 1);
|
||
assert_eq!(row.value_label.as_ref().unwrap().text(), "B");
|
||
|
||
// Re-activating shows the check on the new selection (fresh subpage each time).
|
||
row.widget()
|
||
.downcast_ref::<adw::ActionRow>()
|
||
.unwrap()
|
||
.emit_by_name::<()>("activated", &[]);
|
||
pump();
|
||
assert!(find_action_row(dialog.upcast_ref(), "B").is_some());
|
||
|
||
// Desktop (ComboRow) mode: cell sync + change callback on selection change.
|
||
let combo = ChoiceRow::new(&dialog, false, "Codec", "", &["X", "Y"]);
|
||
combo.set_selected(1);
|
||
assert_eq!(combo.selected(), 1);
|
||
let combo_fired = Rc::new(Cell::new(u32::MAX));
|
||
{
|
||
let f = combo_fired.clone();
|
||
combo.connect_changed(move |i| f.set(i));
|
||
}
|
||
combo.set_selected(0);
|
||
assert_eq!(combo.selected(), 0);
|
||
assert_eq!(combo_fired.get(), 0);
|
||
}
|
||
}
|