windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m14s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m47s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m50s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m48s
ci / web (push) Successful in 1m8s
apple / swift (push) Successful in 5m16s
ci / docs-site (push) Successful in 1m4s
android / android (push) Failing after 9m5s
ci / bench (push) Successful in 5m34s
decky / build-publish (push) Successful in 21s
ci / rust-arm64 (push) Failing after 8m37s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
ci / rust (push) Failing after 9m28s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 20s
deb / build-publish-host (push) Successful in 10m37s
deb / build-publish-client-arm64 (push) Successful in 9m38s
arch / build-publish (push) Successful in 17m48s
deb / build-publish (push) Successful in 13m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 6m33s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m9s
flatpak / build-publish (push) Failing after 9m27s
apple / screenshots (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m9s
Groundwork for the Windows half of P1, and a dedup of the Linux one. The two shells commit differently: GTK writes once when the dialog closes (so it can hand over a list of touched rows), WinUI writes on every control change (so it can't). `absorb` serves the second shape — give it the effective settings before and after one control fired, and it records the field that moved. That is deliberately NOT the diff-on-save the design rejects. The comparison is against the EFFECTIVE settings, i.e. what the control was actually showing, not against the globals — so setting a value back to whatever the global happens to be still records an override, which is exactly the pin the design wants. It only ever adds; removing an override stays an explicit, separate operation. `clear` is that operation, keyed by the overlay's own field names, with `resolution` as the one alias for the width/height/match-window tri-state a single control drives on every client. The GTK reset path now calls it instead of carrying its own `match` — that second list was already a place where the two could drift, and the next client would have made it a third. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1647 lines
64 KiB
Rust
1647 lines
64 KiB
Rust
//! 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<RefCell<HashSet<&'static str>>>);
|
||
|
||
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<RefCell<Option<Scope>>>,
|
||
parent: &impl IsA<gtk::Widget>,
|
||
) -> adw::PreferencesGroup {
|
||
let g = group(
|
||
"",
|
||
"A profile overrides only what you change here; everything else follows Default \
|
||
settings.",
|
||
);
|
||
let mut labels: Vec<String> = 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::<Vec<_>>(),
|
||
);
|
||
row.set_selected(current);
|
||
{
|
||
let (dialog, next, parent) = (dialog.clone(), next_scope.clone(), parent.as_ref().clone());
|
||
let ids: Vec<String> = 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<RefCell<Option<Scope>>>,
|
||
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<gtk::Widget>,
|
||
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,
|
||
cleared: &HashSet<&'static str>,
|
||
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);
|
||
}
|
||
// Resets last: a row the user reset may also have fired its change handler on the way
|
||
// (a ComboRow re-seeds), and "back to inheriting" is the later, explicit intent. The field
|
||
// names are the overlay's own, so the list of what can be cleared lives in ONE place —
|
||
// this used to be a second `match` here, which is exactly how the two drift.
|
||
for key in cleared {
|
||
if !o.clear(key) {
|
||
tracing::warn!(field = key, "reset of an unknown overlay field");
|
||
}
|
||
}
|
||
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<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<Vec<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 — 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<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(Vec::new()));
|
||
|
||
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() {
|
||
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::<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().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::<adw::ActionRow>() {
|
||
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<String>,
|
||
pub speakers: Vec<pf_client_core::audio::AudioDevice>,
|
||
pub mics: Vec<pf_client_core::audio::AudioDevice>,
|
||
}
|
||
|
||
/// 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<gtk::Widget>,
|
||
settings: Rc<RefCell<Settings>>,
|
||
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<StreamProfile> = 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();
|
||
// Fields whose override a per-row reset dropped — applied at commit, after the touched
|
||
// ones, so "reset" wins over a value the same row happens to be showing.
|
||
let cleared: Rc<RefCell<HashSet<&'static str>>> = Rc::default();
|
||
// Where a scope switch wants to go once this dialog has committed and closed.
|
||
let next_scope: Rc<RefCell<Option<Scope>>> = 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<String> = 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::<Vec<_>>(),
|
||
);
|
||
{
|
||
let w = res_row.widget().clone();
|
||
res_row.connect_changed(move |i| set_row_subtitle(&w, resolution_caption(i)));
|
||
}
|
||
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",
|
||
"Native follows the monitor the window is on",
|
||
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||
);
|
||
|
||
// ---- Display: Quality ----
|
||
let scale_names: Vec<String> = 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::<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 · 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<String> = 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::<Vec<_>>(),
|
||
);
|
||
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::<Vec<_>>(),
|
||
);
|
||
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<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 {
|
||
"Every pad is its own player — pick one to force single-player"
|
||
},
|
||
&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 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"));
|
||
}
|
||
|
||
// ---- Override markers + per-row reset (profile scope) ----
|
||
// Every overridden row says so — an accent dot — and carries the only way back to
|
||
// inheriting: an explicit reset. Without this a profile is a one-way door, since the
|
||
// override model deliberately never infers "not overridden" from a value comparison.
|
||
if let Some(active) = &active {
|
||
let o = &active.overrides;
|
||
let mark = |row: &adw::PreferencesRow, key: &'static str, overridden: bool| {
|
||
if !overridden {
|
||
return;
|
||
}
|
||
let Some(row) = row.downcast_ref::<adw::ActionRow>() else {
|
||
return;
|
||
};
|
||
let dot = gtk::Box::builder()
|
||
.css_classes(["pf-override-dot"])
|
||
.valign(gtk::Align::Center)
|
||
.build();
|
||
row.add_prefix(&dot);
|
||
let reset = gtk::Button::builder()
|
||
.icon_name("edit-undo-symbolic")
|
||
.tooltip_text("Reset to Default settings")
|
||
.valign(gtk::Align::Center)
|
||
.css_classes(["flat"])
|
||
.build();
|
||
let (dialog, next, cleared, scope) = (
|
||
dialog.clone(),
|
||
next_scope.clone(),
|
||
cleared.clone(),
|
||
scope.clone(),
|
||
);
|
||
reset.connect_clicked(move |_| {
|
||
// Queue the clear and re-open in the same scope: the close handler commits
|
||
// whatever else was edited first, then drops this field, and the rebuilt rows
|
||
// show the inherited value. One code path builds rows — including this one.
|
||
cleared.borrow_mut().insert(key);
|
||
*next.borrow_mut() = Some(scope.clone());
|
||
dialog.close();
|
||
});
|
||
row.add_suffix(&reset);
|
||
};
|
||
mark(
|
||
res_row.widget(),
|
||
"resolution",
|
||
o.width.is_some() || o.height.is_some() || o.match_window.is_some(),
|
||
);
|
||
mark(hz_row.widget(), "refresh_hz", o.refresh_hz.is_some());
|
||
mark(scale_row.widget(), "render_scale", o.render_scale.is_some());
|
||
mark(
|
||
bitrate_row.upcast_ref(),
|
||
"bitrate_kbps",
|
||
o.bitrate_kbps.is_some(),
|
||
);
|
||
mark(codec_row.widget(), "codec", o.codec.is_some());
|
||
mark(hdr_row.upcast_ref(), "hdr_enabled", o.hdr_enabled.is_some());
|
||
mark(
|
||
compositor_row.widget(),
|
||
"compositor",
|
||
o.compositor.is_some(),
|
||
);
|
||
mark(
|
||
surround_row.widget(),
|
||
"audio_channels",
|
||
o.audio_channels.is_some(),
|
||
);
|
||
mark(mic_row.upcast_ref(), "mic_enabled", o.mic_enabled.is_some());
|
||
mark(touch_row.widget(), "touch_mode", o.touch_mode.is_some());
|
||
mark(mouse_row.widget(), "mouse_mode", o.mouse_mode.is_some());
|
||
mark(
|
||
invert_row.upcast_ref(),
|
||
"invert_scroll",
|
||
o.invert_scroll.is_some(),
|
||
);
|
||
mark(
|
||
inhibit_row.upcast_ref(),
|
||
"inhibit_shortcuts",
|
||
o.inhibit_shortcuts.is_some(),
|
||
);
|
||
mark(pad_row.widget(), "gamepad", o.gamepad.is_some());
|
||
mark(
|
||
stats_row.widget(),
|
||
"stats_verbosity",
|
||
o.stats_verbosity.is_some(),
|
||
);
|
||
mark(
|
||
fullscreen_row.upcast_ref(),
|
||
"fullscreen_on_stream",
|
||
o.fullscreen_on_stream.is_some(),
|
||
);
|
||
}
|
||
|
||
// ---- 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, &cleared.borrow(), &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<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");
|
||
|
||
// The dynamic-caption hook drives the same subtitle both row shapes expose.
|
||
set_row_subtitle(row.widget(), "swapped");
|
||
assert_eq!(
|
||
row.widget()
|
||
.downcast_ref::<adw::ActionRow>()
|
||
.unwrap()
|
||
.subtitle()
|
||
.as_deref(),
|
||
Some("swapped")
|
||
);
|
||
|
||
// 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);
|
||
// ComboRow derives from ActionRow, so the caption hook reaches it too.
|
||
set_row_subtitle(combo.widget(), "combo caption");
|
||
assert_eq!(
|
||
combo
|
||
.widget()
|
||
.downcast_ref::<adw::ActionRow>()
|
||
.unwrap()
|
||
.subtitle()
|
||
.as_deref(),
|
||
Some("combo caption")
|
||
);
|
||
}
|
||
}
|