fix(client/linux): the override marker appears on touch, profiles get a colour, and 4:4:4 gets a switch
Three things found by actually driving the client. **The marker didn't appear until you reopened the dialog.** It was rendered once, at build time, from the stored overlay — so changing a setting inside a profile looked like it did nothing. The design says touching a control creates the override and the marker appears immediately, and it has to: a user who changes a row and sees no acknowledgement has no reason to believe it took. Every profileable row now builds its dot and reset hidden, and the same handler that records the touch reveals them. Resetting a row touched in the same sitting also un-touches it, so the commit can't re-write what the reset just removed. **Profiles had no colour.** `accent` has been in the schema since P0 and nothing could set it, which left every chip the same grey — and telling profiles apart at a glance across a grid is the entire reason chips exist. Creating a profile now picks a colour in the same breath as its name (hunting for it afterwards is what leaves them all grey), an existing profile has a Colour row, and host-card chips are tinted with it. A palette of eight rather than a free picker: legibility across light and dark is the job, and the schema still accepts any `#RRGGBB` a hand-edit or a future picker writes. Anything that isn't `#RRGGBB` is refused rather than interpolated into CSS, and each distinct colour registers one display-wide rule (per-widget providers are gone since GTK 4.10). **4:4:4 had no switch anywhere but Apple.** `VIDEO_CAP_444` has been on the wire for a while with only `punktfunk-probe`'s env var to set it. It is now a setting — and a profileable one, which is the point: full chroma is what makes small text and thin UI lines crisp, so a "Work" profile wants it where "Game" usually doesn't. The host still gates it on its own policy, HEVC, and a GPU that can actually encode it; advertising only says "I can decode this and I want it". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,19 @@ const CSS: &str = "
|
||||
-- a quote in here would end it.) */
|
||||
.pf-override-dot { min-width: 8px; min-height: 8px; border-radius: 999px;
|
||||
background: @accent_color; }
|
||||
/* Profile colour swatches (the accent a profile's chips carry). One class per palette entry
|
||||
because a per-widget CSS provider for eight buttons is a lot of machinery for a dot. */
|
||||
.pf-swatch { min-width: 26px; min-height: 26px; border-radius: 999px; padding: 0; }
|
||||
.pf-swatch-none { background: alpha(currentColor, 0.15); }
|
||||
.pf-swatch-red { background: #e01b24; }
|
||||
.pf-swatch-orange { background: #ff7800; }
|
||||
.pf-swatch-yellow { background: #f6d32d; }
|
||||
.pf-swatch-green { background: #33d17a; }
|
||||
.pf-swatch-blue { background: #3584e4; }
|
||||
.pf-swatch-purple { background: #9141ac; }
|
||||
.pf-swatch-pink { background: #d16d9e; }
|
||||
.pf-swatch-slate { background: #77767b; }
|
||||
.pf-swatch-on { outline: 2px solid @accent_color; outline-offset: 2px; }
|
||||
/* Most-recent host: a full accent ring drawn as an inset outline so it follows the card's
|
||||
rounded corners (an `inset` box-shadow bar gets eaten by the 12px corner clip) and leaves
|
||||
the card's own elevation shadow intact. */
|
||||
|
||||
@@ -60,6 +60,15 @@ pub struct HostCard {
|
||||
connecting: bool,
|
||||
}
|
||||
|
||||
/// One catalog entry as the cards need it: what to call the profile, and the colour its chips
|
||||
/// carry (`StreamProfile.accent`) — the field the schema reserved for exactly this.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Profile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub accent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum CardKind {
|
||||
Saved {
|
||||
@@ -69,7 +78,7 @@ enum CardKind {
|
||||
library_enabled: bool,
|
||||
/// The profile catalog as `(id, name)`, for this card's menus and chip. Shared per
|
||||
/// refresh rather than re-read per card.
|
||||
profiles: Rc<Vec<(String, String)>>,
|
||||
profiles: Rc<Vec<Profile>>,
|
||||
/// `Some((id, name))` when this card is a PINNED host+profile pair rather than the
|
||||
/// host's primary card (design §5.2a): a one-click shortcut for a profile the user
|
||||
/// reaches for often. It is presentation state on the host record — never a second
|
||||
@@ -247,25 +256,18 @@ impl relm4::factory::FactoryComponent for HostCard {
|
||||
} else {
|
||||
pill("Trusted", "pf-accent")
|
||||
});
|
||||
// The chip says what a plain click on THIS card will do: its own profile on a
|
||||
// pinned card, the host's binding on the primary one. A binding whose profile
|
||||
// was deleted shows nothing and resolves as the defaults, which is exactly
|
||||
// what will happen on connect (design §6).
|
||||
let chip = pinned.as_ref().map(|(_, name)| name.as_str()).or_else(|| {
|
||||
k.profile_id
|
||||
.as_ref()
|
||||
.and_then(|id| profiles.iter().find(|(pid, _)| pid == id))
|
||||
.map(|(_, name)| name.as_str())
|
||||
});
|
||||
if let Some(name) = chip {
|
||||
status.append(&pill(
|
||||
name,
|
||||
if pinned.is_some() {
|
||||
"pf-accent"
|
||||
} else {
|
||||
"pf-neutral"
|
||||
},
|
||||
));
|
||||
// The chip says what a plain click on THIS card will do: its own profile on
|
||||
// a pinned card, the host's binding on the primary one. Both resolve through
|
||||
// the catalog so the chip can carry the profile's colour; a binding whose
|
||||
// profile was deleted shows nothing and resolves as the defaults, which is
|
||||
// exactly what will happen on connect (design §6).
|
||||
let chip = pinned
|
||||
.as_ref()
|
||||
.map(|(id, _)| id.as_str())
|
||||
.or(k.profile_id.as_deref())
|
||||
.and_then(|id| profiles.iter().find(|p| p.id == id));
|
||||
if let Some(p) = chip {
|
||||
status.append(&profile_pill(p));
|
||||
}
|
||||
}
|
||||
CardKind::Discovered(_) => {
|
||||
@@ -456,11 +458,9 @@ impl relm4::factory::FactoryComponent for HostCard {
|
||||
// and its own removal, and deliberately NOT pair/rename/forget — those
|
||||
// belong to the host, and offering them here would blur what the card is.
|
||||
let with = gio::Menu::new();
|
||||
for (label, target) in std::iter::once(("Default settings", "")).chain(
|
||||
profiles
|
||||
.iter()
|
||||
.map(|(id, name)| (name.as_str(), id.as_str())),
|
||||
) {
|
||||
for (label, target) in std::iter::once(("Default settings", ""))
|
||||
.chain(profiles.iter().map(|p| (p.name.as_str(), p.id.as_str())))
|
||||
{
|
||||
let item = gio::MenuItem::new(Some(label), None);
|
||||
item.set_action_and_target_value(
|
||||
Some("card.connect-with"),
|
||||
@@ -485,11 +485,9 @@ impl relm4::factory::FactoryComponent for HostCard {
|
||||
let with = gio::Menu::new();
|
||||
let bind = gio::Menu::new();
|
||||
let pin = gio::Menu::new();
|
||||
for (label, id) in std::iter::once(("Default settings", "")).chain(
|
||||
profiles
|
||||
.iter()
|
||||
.map(|(id, name)| (name.as_str(), id.as_str())),
|
||||
) {
|
||||
for (label, id) in std::iter::once(("Default settings", ""))
|
||||
.chain(profiles.iter().map(|p| (p.name.as_str(), p.id.as_str())))
|
||||
{
|
||||
// A checkmark would be the natural cue for the current binding, but
|
||||
// a GMenu radio needs shared state per card; the bound profile is
|
||||
// named on the card's chip instead, visible without opening a menu.
|
||||
@@ -584,6 +582,59 @@ const PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(12);
|
||||
|
||||
/// The key a saved host is tracked under in the probe-results map: its fingerprint (stable
|
||||
/// across IP changes) when it has one, else `addr:port` (a not-yet-paired manual entry).
|
||||
/// A profile chip in that profile's colour. `accent` is the field the catalog schema reserved
|
||||
/// for this — without it every profile is the same grey, and telling them apart across a grid
|
||||
/// at a glance is the whole reason the chip exists. No colour set keeps the neutral pill, so
|
||||
/// the palette stays opt-in.
|
||||
fn profile_pill(p: &Profile) -> gtk::Widget {
|
||||
let label = gtk::Label::new(Some(&p.name));
|
||||
label.add_css_class("pf-pill");
|
||||
let Some(hex) = p.accent.as_deref().filter(|h| is_hex_colour(h)) else {
|
||||
label.add_css_class("pf-neutral");
|
||||
return label.upcast();
|
||||
};
|
||||
label.add_css_class(&tint_class(hex));
|
||||
label.upcast()
|
||||
}
|
||||
|
||||
/// The CSS class that tints a pill with `hex`, registering its rule on the display the first
|
||||
/// time that colour is seen.
|
||||
///
|
||||
/// Per-widget providers are gone since GTK 4.10, and the colour is user data rather than one of
|
||||
/// a fixed set, so the rule is generated once per distinct colour and added display-wide. A
|
||||
/// handful of profiles means a handful of tiny rules, and re-rendering the grid (which happens
|
||||
/// on every state change) reuses them instead of allocating more.
|
||||
fn tint_class(hex: &str) -> String {
|
||||
thread_local! {
|
||||
static REGISTERED: RefCell<HashMap<String, ()>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
let class = format!("pf-tint-{}", &hex[1..]);
|
||||
REGISTERED.with_borrow_mut(|seen| {
|
||||
if seen.contains_key(&class) {
|
||||
return;
|
||||
}
|
||||
if let Some(display) = gtk::gdk::Display::default() {
|
||||
let provider = gtk::CssProvider::new();
|
||||
provider.load_from_string(&format!(
|
||||
".pf-pill.{class} {{ color: {hex}; background: alpha({hex}, 0.18); }}"
|
||||
));
|
||||
gtk::style_context_add_provider_for_display(
|
||||
&display,
|
||||
&provider,
|
||||
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
seen.insert(class.clone(), ());
|
||||
}
|
||||
});
|
||||
class
|
||||
}
|
||||
|
||||
/// `#RRGGBB` only — the value is interpolated into CSS, so anything else is refused rather
|
||||
/// than injected.
|
||||
fn is_hex_colour(s: &str) -> bool {
|
||||
s.len() == 7 && s.starts_with('#') && s[1..].chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn saved_key(h: &KnownHost) -> String {
|
||||
if h.fp_hex.is_empty() {
|
||||
format!("{}:{}", h.addr, h.port)
|
||||
@@ -992,11 +1043,15 @@ impl HostsPage {
|
||||
.map(|(fp, _)| fp);
|
||||
let library_enabled = self.settings.borrow().library_enabled;
|
||||
// One catalog read per refresh, shared by every card's menus and chip.
|
||||
let profiles: Rc<Vec<(String, String)>> = Rc::new(
|
||||
let profiles: Rc<Vec<Profile>> = Rc::new(
|
||||
pf_client_core::profiles::ProfilesFile::load()
|
||||
.profiles
|
||||
.into_iter()
|
||||
.map(|p| (p.id, p.name))
|
||||
.map(|p| Profile {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
accent: p.accent,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
@@ -1030,9 +1085,10 @@ impl HostsPage {
|
||||
// They share the host's live status because they read the same record, and a
|
||||
// pin whose profile is gone simply doesn't render (design §5.2a).
|
||||
for id in &k.pinned_profiles {
|
||||
let Some((id, name)) = profiles.iter().find(|(pid, _)| pid == id) else {
|
||||
let Some(p) = profiles.iter().find(|p| &p.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let (id, name) = (p.id.clone(), p.name.clone());
|
||||
saved.push_back(HostCard {
|
||||
// The spinner belongs to whichever card was clicked; a pin has its own
|
||||
// key so two cards for one host don't both spin.
|
||||
@@ -1043,7 +1099,7 @@ impl HostsPage {
|
||||
profiles: profiles.clone(),
|
||||
recent: false,
|
||||
library_enabled,
|
||||
pinned: Some((id.clone(), name.clone())),
|
||||
pinned: Some((id, name)),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+256
-133
@@ -45,6 +45,12 @@ impl Touched {
|
||||
fn has(&self, key: &str) -> bool {
|
||||
self.0.borrow().contains(key)
|
||||
}
|
||||
|
||||
/// Undo a touch — the user reset this row after changing it, and "back to inheriting" is
|
||||
/// the later, explicit intent.
|
||||
fn forget(&self, key: &str) {
|
||||
self.0.borrow_mut().remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// `(0, 0)` = the native size of the monitor the window is on, resolved at connect.
|
||||
@@ -61,6 +67,57 @@ const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
||||
/// `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 chip palette a profile can carry (`StreamProfile.accent`). Eight entries rather than a
|
||||
/// full colour picker: the point is telling profiles apart at a glance on a host card, which a
|
||||
/// small set of legible, contrast-checked colours does better than free choice — and the
|
||||
/// schema's `#RRGGBB` still accepts anything a future picker or a hand-edit writes.
|
||||
const SWATCHES: &[(&str, &str, &str)] = &[
|
||||
("", "pf-swatch-none", "No colour"),
|
||||
("#e01b24", "pf-swatch-red", "Red"),
|
||||
("#ff7800", "pf-swatch-orange", "Orange"),
|
||||
("#f6d32d", "pf-swatch-yellow", "Yellow"),
|
||||
("#33d17a", "pf-swatch-green", "Green"),
|
||||
("#3584e4", "pf-swatch-blue", "Blue"),
|
||||
("#9141ac", "pf-swatch-purple", "Purple"),
|
||||
("#d16d9e", "pf-swatch-pink", "Pink"),
|
||||
("#77767b", "pf-swatch-slate", "Slate"),
|
||||
];
|
||||
|
||||
/// A row of colour swatches, calling back with the chosen `#RRGGBB` (empty = none). Used both
|
||||
/// when creating a profile and when changing one later.
|
||||
fn swatch_row(current: Option<&str>, on_pick: impl Fn(String) + 'static) -> gtk::Box {
|
||||
let row = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.build();
|
||||
let on_pick = Rc::new(on_pick);
|
||||
let buttons: Rc<RefCell<Vec<(String, gtk::Button)>>> = Rc::default();
|
||||
for (hex, class, name) in SWATCHES {
|
||||
let b = gtk::Button::builder()
|
||||
.css_classes(["pf-swatch", class, "circular"])
|
||||
.tooltip_text(*name)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
if current.unwrap_or("") == *hex {
|
||||
b.add_css_class("pf-swatch-on");
|
||||
}
|
||||
{
|
||||
let (on_pick, buttons, hex) = (on_pick.clone(), buttons.clone(), hex.to_string());
|
||||
b.connect_clicked(move |me| {
|
||||
// The selection ring is exclusive, so clear every sibling before setting ours.
|
||||
for (_, other) in buttons.borrow().iter() {
|
||||
other.remove_css_class("pf-swatch-on");
|
||||
}
|
||||
me.add_css_class("pf-swatch-on");
|
||||
on_pick(hex.clone());
|
||||
});
|
||||
}
|
||||
buttons.borrow_mut().push((hex.to_string(), b.clone()));
|
||||
row.append(&b);
|
||||
}
|
||||
row
|
||||
}
|
||||
|
||||
/// The scope switcher, plus (in profile scope) that profile's management actions.
|
||||
///
|
||||
/// Switching scope does not swap the rows in place: it closes the dialog — which commits the
|
||||
@@ -109,19 +166,27 @@ fn scope_group(
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
prompt_name(
|
||||
&parent,
|
||||
"New profile",
|
||||
"Create",
|
||||
"",
|
||||
true,
|
||||
move |name, accent| {
|
||||
let mut catalog = ProfilesFile::load();
|
||||
if catalog.name_taken(&name, None) {
|
||||
return; // the prompt already refuses these; belt and braces
|
||||
}
|
||||
let mut profile = StreamProfile::new(name);
|
||||
profile.accent = accent;
|
||||
let id = profile.id.clone();
|
||||
catalog.profiles.push(profile);
|
||||
if catalog.save().is_ok() {
|
||||
*next.borrow_mut() = Some(Scope::Profile(id));
|
||||
dialog.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
*next.borrow_mut() = Some(match i {
|
||||
@@ -140,6 +205,26 @@ fn scope_group(
|
||||
std::mem::forget(row);
|
||||
|
||||
if let Some(active) = active {
|
||||
// Colour first: it is what the profile's chips carry on host cards, so it belongs with
|
||||
// the profile's identity rather than buried behind a menu.
|
||||
let colour = adw::ActionRow::builder()
|
||||
.title("Colour")
|
||||
.subtitle("Tints this profile's chips on host cards")
|
||||
.use_markup(false)
|
||||
.build();
|
||||
colour.add_suffix(&swatch_row(active.accent.as_deref(), {
|
||||
let id = active.id.clone();
|
||||
move |hex| {
|
||||
let mut catalog = ProfilesFile::load();
|
||||
if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) {
|
||||
p.accent = (!hex.is_empty()).then(|| hex.clone());
|
||||
if let Err(e) = catalog.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "saving the profile colour");
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
g.add(&colour);
|
||||
let actions = adw::ActionRow::builder()
|
||||
.title(&active.name)
|
||||
.subtitle("This profile")
|
||||
@@ -197,19 +282,26 @@ fn run_profile_action(
|
||||
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();
|
||||
}
|
||||
});
|
||||
prompt_name(
|
||||
parent,
|
||||
"Rename profile",
|
||||
"Rename",
|
||||
name,
|
||||
false,
|
||||
move |new_name, _| {
|
||||
let mut catalog = ProfilesFile::load();
|
||||
if catalog.name_taken(&new_name, Some(&keep)) {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == keep) {
|
||||
p.name = new_name;
|
||||
}
|
||||
if catalog.save().is_ok() {
|
||||
*next.borrow_mut() = Some(Scope::Profile(keep.clone()));
|
||||
dialog.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
ProfileAction::Duplicate => {
|
||||
let mut catalog = ProfilesFile::load();
|
||||
@@ -286,7 +378,8 @@ fn prompt_name(
|
||||
heading: &str,
|
||||
accept: &str,
|
||||
initial: &str,
|
||||
on_ok: impl Fn(String) + 'static,
|
||||
with_colour: bool,
|
||||
on_ok: impl Fn(String, Option<String>) + 'static,
|
||||
) {
|
||||
let dialog = adw::AlertDialog::new(Some(heading), None);
|
||||
let entry = adw::EntryRow::builder().title("Name").build();
|
||||
@@ -296,6 +389,20 @@ fn prompt_name(
|
||||
.css_classes(["boxed-list"])
|
||||
.build();
|
||||
list.append(&entry);
|
||||
// Creating a profile picks its colour here, in the same breath as its name — going hunting
|
||||
// for it afterwards is exactly the friction that leaves every profile grey.
|
||||
let accent: Rc<RefCell<Option<String>>> = Rc::default();
|
||||
if with_colour {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Colour")
|
||||
.use_markup(false)
|
||||
.build();
|
||||
row.add_suffix(&swatch_row(None, {
|
||||
let accent = accent.clone();
|
||||
move |hex| *accent.borrow_mut() = (!hex.is_empty()).then(|| hex.clone())
|
||||
}));
|
||||
list.append(&row);
|
||||
}
|
||||
dialog.set_extra_child(Some(&list));
|
||||
dialog.add_responses(&[("cancel", "Cancel"), ("ok", accept)]);
|
||||
dialog.set_response_appearance("ok", adw::ResponseAppearance::Suggested);
|
||||
@@ -324,7 +431,7 @@ fn prompt_name(
|
||||
dialog.connect_response(Some("ok"), move |_, _| {
|
||||
let name = e.text().trim().to_string();
|
||||
if !name.is_empty() {
|
||||
on_ok(name);
|
||||
on_ok(name, accent.borrow().clone());
|
||||
}
|
||||
});
|
||||
dialog.present(Some(parent));
|
||||
@@ -369,6 +476,9 @@ fn commit_profile(
|
||||
if touched.has("hdr_enabled") {
|
||||
o.hdr_enabled = Some(values.hdr_enabled);
|
||||
}
|
||||
if touched.has("enable_444") {
|
||||
o.enable_444 = Some(values.enable_444);
|
||||
}
|
||||
if touched.has("compositor") {
|
||||
o.compositor = Some(values.compositor.clone());
|
||||
}
|
||||
@@ -867,6 +977,14 @@ pub fn show_scoped(
|
||||
the display supports it, tone-mapped otherwise",
|
||||
)
|
||||
.build();
|
||||
let chroma_row = adw::SwitchRow::builder()
|
||||
.title("Full chroma (4:4:4)")
|
||||
.subtitle(
|
||||
"Ask for full-colour video instead of subsampled \u{2014} small text and thin lines \
|
||||
stay crisp, at more bandwidth. HEVC only, and only when the host and its GPU can \
|
||||
encode it; otherwise the stream stays 4:2:0.",
|
||||
)
|
||||
.build();
|
||||
let decoder_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
@@ -1176,6 +1294,7 @@ pub fn show_scoped(
|
||||
invert_row.set_active(s.invert_scroll);
|
||||
mic_row.set_active(s.mic_enabled);
|
||||
hdr_row.set_active(s.hdr_enabled);
|
||||
chroma_row.set_active(s.enable_444);
|
||||
library_row.set_active(s.library_enabled);
|
||||
surround_row.set_selected(match s.audio_channels {
|
||||
6 => 1,
|
||||
@@ -1187,130 +1306,132 @@ pub fn show_scoped(
|
||||
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.
|
||||
// ---- Override markers, per-row reset, and the touch that creates an override ----
|
||||
// One pass per row, because the three are the same fact seen from different sides: the
|
||||
// marker says "this profile changes this", the reset is the only way back (the override
|
||||
// model never infers "not overridden" from a value comparison), and touching the control
|
||||
// is what creates it. The marker therefore has to appear ON TOUCH, not on the next open —
|
||||
// a user who changes a row and sees nothing has no reason to believe it took.
|
||||
//
|
||||
// Wired after the seed block on purpose: `set_selected`/`set_active` during setup must not
|
||||
// look like the user touching anything, or opening a profile would override every row.
|
||||
if let Some(active) = &active {
|
||||
let o = &active.overrides;
|
||||
let mark = |row: &adw::PreferencesRow, key: &'static str, overridden: bool| {
|
||||
if !overridden {
|
||||
return;
|
||||
}
|
||||
let Some(row) = row.downcast_ref::<adw::ActionRow>() else {
|
||||
return;
|
||||
};
|
||||
// Build the marker for one row and hand back a "now it's overridden" switch its
|
||||
// control's change handler can pull.
|
||||
let mark = |row: &adw::PreferencesRow,
|
||||
key: &'static str,
|
||||
overridden: bool|
|
||||
-> Option<Box<dyn Fn()>> {
|
||||
let row = row.downcast_ref::<adw::ActionRow>()?;
|
||||
let dot = gtk::Box::builder()
|
||||
.css_classes(["pf-override-dot"])
|
||||
.valign(gtk::Align::Center)
|
||||
.visible(overridden)
|
||||
.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)
|
||||
.visible(overridden)
|
||||
.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();
|
||||
});
|
||||
{
|
||||
let (dialog, next, cleared, scope) = (
|
||||
dialog.clone(),
|
||||
next_scope.clone(),
|
||||
cleared.clone(),
|
||||
scope.clone(),
|
||||
);
|
||||
let touched = touched.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. Dropping it from `touched` too keeps a
|
||||
// reset-after-touch from re-writing what the reset just removed.
|
||||
touched.forget(key);
|
||||
cleared.borrow_mut().insert(key);
|
||||
*next.borrow_mut() = Some(scope.clone());
|
||||
dialog.close();
|
||||
});
|
||||
}
|
||||
row.add_suffix(&reset);
|
||||
Some(Box::new(move || {
|
||||
dot.set_visible(true);
|
||||
reset.set_visible(true);
|
||||
}))
|
||||
};
|
||||
mark(
|
||||
res_row.widget(),
|
||||
|
||||
// `(row widget, overlay field, is it overridden right now)` — then the control's own
|
||||
// change signal marks it touched AND lights the marker.
|
||||
macro_rules! choice {
|
||||
($row:expr, $key:literal, $overridden:expr) => {{
|
||||
let show = mark($row.widget(), $key, $overridden);
|
||||
let t = touched.clone();
|
||||
$row.connect_changed(move |_| {
|
||||
t.mark($key);
|
||||
if let Some(show) = &show {
|
||||
show();
|
||||
}
|
||||
});
|
||||
}};
|
||||
}
|
||||
macro_rules! toggle {
|
||||
($row:expr, $key:literal, $overridden:expr) => {{
|
||||
let show = mark($row.upcast_ref(), $key, $overridden);
|
||||
let t = touched.clone();
|
||||
$row.connect_active_notify(move |_| {
|
||||
t.mark($key);
|
||||
if let Some(show) = &show {
|
||||
show();
|
||||
}
|
||||
});
|
||||
}};
|
||||
}
|
||||
|
||||
choice!(
|
||||
res_row,
|
||||
"resolution",
|
||||
o.width.is_some() || o.height.is_some() || o.match_window.is_some(),
|
||||
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(),
|
||||
choice!(hz_row, "refresh_hz", o.refresh_hz.is_some());
|
||||
choice!(scale_row, "render_scale", o.render_scale.is_some());
|
||||
choice!(codec_row, "codec", o.codec.is_some());
|
||||
choice!(compositor_row, "compositor", o.compositor.is_some());
|
||||
choice!(stats_row, "stats_verbosity", o.stats_verbosity.is_some());
|
||||
choice!(touch_row, "touch_mode", o.touch_mode.is_some());
|
||||
choice!(mouse_row, "mouse_mode", o.mouse_mode.is_some());
|
||||
choice!(surround_row, "audio_channels", o.audio_channels.is_some());
|
||||
choice!(pad_row, "gamepad", o.gamepad.is_some());
|
||||
toggle!(hdr_row, "hdr_enabled", o.hdr_enabled.is_some());
|
||||
toggle!(chroma_row, "enable_444", o.enable_444.is_some());
|
||||
toggle!(
|
||||
fullscreen_row,
|
||||
"fullscreen_on_stream",
|
||||
o.fullscreen_on_stream.is_some(),
|
||||
o.fullscreen_on_stream.is_some()
|
||||
);
|
||||
toggle!(
|
||||
inhibit_row,
|
||||
"inhibit_shortcuts",
|
||||
o.inhibit_shortcuts.is_some()
|
||||
);
|
||||
toggle!(invert_row, "invert_scroll", o.invert_scroll.is_some());
|
||||
toggle!(mic_row, "mic_enabled", o.mic_enabled.is_some());
|
||||
{
|
||||
let show = mark(
|
||||
bitrate_row.upcast_ref(),
|
||||
"bitrate_kbps",
|
||||
o.bitrate_kbps.is_some(),
|
||||
);
|
||||
let t = touched.clone();
|
||||
bitrate_row.connect_value_notify(move |_| {
|
||||
t.mark("bitrate_kbps");
|
||||
if let Some(show) = &show {
|
||||
show();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Assemble the category pages (the Apple revamp's map) ----
|
||||
@@ -1353,6 +1474,7 @@ pub fn show_scoped(
|
||||
quality_group.add(&bitrate_row);
|
||||
quality_group.add(codec_row.widget());
|
||||
quality_group.add(&hdr_row);
|
||||
quality_group.add(&chroma_row);
|
||||
// Decoder and GPU are facts about THIS device's hardware — never per profile (tier G).
|
||||
if !profile_mode {
|
||||
quality_group.add(decoder_row.widget());
|
||||
@@ -1496,6 +1618,7 @@ pub fn show_scoped(
|
||||
s.invert_scroll = invert_row.is_active();
|
||||
s.mic_enabled = mic_row.is_active();
|
||||
s.hdr_enabled = hdr_row.is_active();
|
||||
s.enable_444 = chroma_row.is_active();
|
||||
s.audio_channels = match surround_row.selected() {
|
||||
1 => 6,
|
||||
2 => 8,
|
||||
|
||||
Reference in New Issue
Block a user