fix(client/linux): reverting an override acts in place, and the card menu stops being a list of everything
Four things from driving it. **Reverting one row rebuilt the whole dialog.** It closed and reopened to re-render, which animated the entire surface for a one-row change and dropped the user back on the General page — a heavy, disorienting answer to "undo this". A reset now clears the field, puts that one control back to the inherited value, and drops the marker, all in place. Doing that needs the programmatic change not to read as a touch (it would instantly re-create the override the reset just removed), so `Touched` gained a suspend flag the revert holds; and it needs the seed pass and the revert to agree on where each picker sits, so that index maths is now one shared `index` module instead of two copies drifting apart. **Hidden markers still held their space.** The dot and reset were built invisible and revealed on touch, but an invisible prefix still costs its slot, so every un-overridden row carried a dot's worth of inset and the column read as misaligned. They are built lazily now — the row has nothing until it has an override. **The colour swatches squeezed their own label.** Nine of them as an ActionRow suffix left the title unreadable; squeezing the label to fit the control is backwards. They get a full-width row with the caption above and a wrapping FlowBox beneath (and not a homogeneous one, which stretched 26px circles into rounded rectangles). **The card menu had grown to eleven entries and three submenus** — the useful ones (connect, library, speed) buried in list management. Two rules thin it: anything that CONFIGURES the host moves to the edit sheet, which is where you already go to change its name; and what remains is sectioned, so a glance lands on the right third instead of scanning eleven similar lines. The default profile and the pinned cards are properties of the record, so they now live in the sheet — pins as a switch per profile — and the menu keeps "Connect with", the things you look at, the two link actions, and host management. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+138
-142
@@ -91,19 +91,11 @@ enum CardKind {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum CardOutput {
|
pub enum CardOutput {
|
||||||
Connect(ConnectRequest),
|
Connect(ConnectRequest),
|
||||||
/// Set (or clear, with `None`) this host's DEFAULT settings profile — the explicit
|
|
||||||
/// rebinding act; a one-off connect never does this.
|
|
||||||
BindProfile {
|
|
||||||
fp_hex: String,
|
|
||||||
addr: String,
|
|
||||||
port: u16,
|
|
||||||
profile_id: Option<String>,
|
|
||||||
},
|
|
||||||
WakeConnect(ConnectRequest),
|
WakeConnect(ConnectRequest),
|
||||||
Pair(ConnectRequest),
|
Pair(ConnectRequest),
|
||||||
SpeedTest(ConnectRequest),
|
SpeedTest(ConnectRequest),
|
||||||
Library(ConnectRequest),
|
Library(ConnectRequest),
|
||||||
/// Open the host edit sheet (name, profile binding, clipboard).
|
/// Open the host edit sheet (name, profile binding, pinned cards, clipboard).
|
||||||
Edit {
|
Edit {
|
||||||
fp_hex: String,
|
fp_hex: String,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -309,6 +301,13 @@ impl relm4::factory::FactoryComponent for HostCard {
|
|||||||
});
|
});
|
||||||
actions.add_action(&a);
|
actions.add_action(&a);
|
||||||
};
|
};
|
||||||
|
{
|
||||||
|
let req = req.clone();
|
||||||
|
add(
|
||||||
|
"connect",
|
||||||
|
Box::new(move || CardOutput::Connect(req.clone())),
|
||||||
|
);
|
||||||
|
}
|
||||||
{
|
{
|
||||||
let req = req.clone();
|
let req = req.clone();
|
||||||
add("pair", Box::new(move || CardOutput::Pair(req.clone())));
|
add("pair", Box::new(move || CardOutput::Pair(req.clone())));
|
||||||
@@ -357,10 +356,47 @@ impl relm4::factory::FactoryComponent for HostCard {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Profiles: a ONE-OFF connect ("Connect with"), and the explicit rebinding
|
// "Copy link" / "Create shortcut…": the self-emitted URL for this card, which
|
||||||
// act ("Default profile"). They are separate menus on purpose — the whole
|
// is what an external tool (a Playnite entry, a Stream Deck macro) is
|
||||||
// predictability rule is that connecting with a profile never changes what
|
// configured with. It carries the stable id AND host+fp, so it still resolves
|
||||||
// the card will do next time (design §5.2).
|
// after a re-address or a reinstall (design/client-deep-links.md §2/§5).
|
||||||
|
{
|
||||||
|
let (host, profile) = (k.clone(), pinned.clone());
|
||||||
|
let a = gio::SimpleAction::new("copy-link", None);
|
||||||
|
let sender = sender.clone();
|
||||||
|
a.connect_activate(move |_, _| {
|
||||||
|
let url = pf_client_core::deeplink::DeepLink::for_host(
|
||||||
|
&host,
|
||||||
|
None,
|
||||||
|
profile.as_ref().map(|(id, _)| id.as_str()),
|
||||||
|
)
|
||||||
|
.to_url();
|
||||||
|
let _ = sender.output(CardOutput::CopyLink(url));
|
||||||
|
});
|
||||||
|
actions.add_action(&a);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let (host, profile) = (k.clone(), pinned.clone());
|
||||||
|
let a = gio::SimpleAction::new("shortcut", None);
|
||||||
|
let sender = sender.clone();
|
||||||
|
a.connect_activate(move |_, _| {
|
||||||
|
let url = pf_client_core::deeplink::DeepLink::for_host(
|
||||||
|
&host,
|
||||||
|
None,
|
||||||
|
profile.as_ref().map(|(id, _)| id.as_str()),
|
||||||
|
)
|
||||||
|
.to_url();
|
||||||
|
let label = match &profile {
|
||||||
|
Some((_, name)) => format!("{} \u{00b7} {name}", host.name),
|
||||||
|
None => host.name.clone(),
|
||||||
|
};
|
||||||
|
let _ = sender.output(CardOutput::CreateShortcut { label, url });
|
||||||
|
});
|
||||||
|
actions.add_action(&a);
|
||||||
|
}
|
||||||
|
// A one-off connect ("Connect with") never rebinds the host — the whole
|
||||||
|
// predictability rule is that it can't change what the card does next time.
|
||||||
|
// Rebinding, and pinning, live in the edit sheet (design §5.2).
|
||||||
{
|
{
|
||||||
let profile_action =
|
let profile_action =
|
||||||
|name: &str, out: Box<dyn Fn(Option<String>) -> CardOutput>| {
|
|name: &str, out: Box<dyn Fn(Option<String>) -> CardOutput>| {
|
||||||
@@ -385,56 +421,6 @@ impl relm4::factory::FactoryComponent for HostCard {
|
|||||||
CardOutput::Connect(req)
|
CardOutput::Connect(req)
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
let (fp, addr, port) = (k.fp_hex.clone(), k.addr.clone(), k.port);
|
|
||||||
profile_action(
|
|
||||||
"bind-profile",
|
|
||||||
Box::new(move |id| CardOutput::BindProfile {
|
|
||||||
fp_hex: fp.clone(),
|
|
||||||
addr: addr.clone(),
|
|
||||||
port,
|
|
||||||
profile_id: id,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
// "Copy link": the self-emitted URL for this card, which is the pairing
|
|
||||||
// an external tool (a Playnite entry, a Stream Deck macro) is configured
|
|
||||||
// with. It carries the stable id AND host+fp, so it still resolves after a
|
|
||||||
// re-address or a reinstall (design/client-deep-links.md §2/§5).
|
|
||||||
{
|
|
||||||
let (host, profile) = (k.clone(), pinned.clone());
|
|
||||||
let a = gio::SimpleAction::new("copy-link", None);
|
|
||||||
let sender = sender.clone();
|
|
||||||
a.connect_activate(move |_, _| {
|
|
||||||
let url = pf_client_core::deeplink::DeepLink::for_host(
|
|
||||||
&host,
|
|
||||||
None,
|
|
||||||
profile.as_ref().map(|(id, _)| id.as_str()),
|
|
||||||
)
|
|
||||||
.to_url();
|
|
||||||
let _ = sender.output(CardOutput::CopyLink(url));
|
|
||||||
});
|
|
||||||
actions.add_action(&a);
|
|
||||||
}
|
|
||||||
// "Create shortcut…": the same URL as Copy link, wrapped in a desktop
|
|
||||||
// entry so it is double-clickable from the app grid.
|
|
||||||
{
|
|
||||||
let (host, profile) = (k.clone(), pinned.clone());
|
|
||||||
let a = gio::SimpleAction::new("shortcut", None);
|
|
||||||
let sender = sender.clone();
|
|
||||||
a.connect_activate(move |_, _| {
|
|
||||||
let url = pf_client_core::deeplink::DeepLink::for_host(
|
|
||||||
&host,
|
|
||||||
None,
|
|
||||||
profile.as_ref().map(|(id, _)| id.as_str()),
|
|
||||||
)
|
|
||||||
.to_url();
|
|
||||||
let label = match &profile {
|
|
||||||
Some((_, name)) => format!("{} \u{00b7} {name}", host.name),
|
|
||||||
None => host.name.clone(),
|
|
||||||
};
|
|
||||||
let _ = sender.output(CardOutput::CreateShortcut { label, url });
|
|
||||||
});
|
|
||||||
actions.add_action(&a);
|
|
||||||
}
|
|
||||||
// The same action pins from a primary card and unpins from a pinned one —
|
// The same action pins from a primary card and unpins from a pinned one —
|
||||||
// which of the two this card is decides the direction.
|
// which of the two this card is decides the direction.
|
||||||
let (fp, addr, port) = (k.fp_hex.clone(), k.addr.clone(), k.port);
|
let (fp, addr, port) = (k.fp_hex.clone(), k.addr.clone(), k.port);
|
||||||
@@ -452,23 +438,33 @@ impl relm4::factory::FactoryComponent for HostCard {
|
|||||||
}
|
}
|
||||||
overlay.insert_action_group("card", Some(&actions));
|
overlay.insert_action_group("card", Some(&actions));
|
||||||
|
|
||||||
|
// The card menu, kept short on purpose. It had grown to eleven entries and
|
||||||
|
// three submenus, at which point the useful ones (connect, library, speed) were
|
||||||
|
// buried in list management. Two rules thin it:
|
||||||
|
//
|
||||||
|
// * anything that CONFIGURES the host lives in the edit sheet, not here —
|
||||||
|
// the default profile and the pinned cards are properties of the record,
|
||||||
|
// and the sheet is where you already go to change its name or address;
|
||||||
|
// * what remains is grouped into sections, so a glance lands on the right
|
||||||
|
// third of the menu instead of scanning eleven similar lines.
|
||||||
|
//
|
||||||
|
// What's left is: start something, look at something, take a link, manage the
|
||||||
|
// host itself.
|
||||||
let menu = gio::Menu::new();
|
let menu = gio::Menu::new();
|
||||||
if let Some((pin_id, pin_name)) = pinned {
|
if let Some((pin_id, pin_name)) = pinned {
|
||||||
// A pinned card is a shortcut, not a second host: it offers the one-offs
|
// A pinned card is a shortcut, not a second host: it starts a stream, hands
|
||||||
// and its own removal, and deliberately NOT pair/rename/forget — those
|
// out its link, and removes itself. Pair/edit/forget belong to the host and
|
||||||
// belong to the host, and offering them here would blur what the card is.
|
// offering them here would blur what the card is.
|
||||||
let with = gio::Menu::new();
|
let launch = gio::Menu::new();
|
||||||
for (label, target) in std::iter::once(("Default settings", ""))
|
launch.append(Some("Connect"), Some("card.connect"));
|
||||||
.chain(profiles.iter().map(|p| (p.name.as_str(), p.id.as_str())))
|
menu.append_section(None, &launch);
|
||||||
{
|
|
||||||
let item = gio::MenuItem::new(Some(label), None);
|
let links = gio::Menu::new();
|
||||||
item.set_action_and_target_value(
|
links.append(Some("Copy link"), Some("card.copy-link"));
|
||||||
Some("card.connect-with"),
|
links.append(Some("Create shortcut\u{2026}"), Some("card.shortcut"));
|
||||||
Some(&target.to_variant()),
|
menu.append_section(None, &links);
|
||||||
);
|
|
||||||
with.append_item(&item);
|
let manage = gio::Menu::new();
|
||||||
}
|
|
||||||
menu.append_submenu(Some("Connect with"), &with);
|
|
||||||
let unpin = gio::MenuItem::new(
|
let unpin = gio::MenuItem::new(
|
||||||
Some(&format!("Unpin \u{201c}{pin_name}\u{201d}")),
|
Some(&format!("Unpin \u{201c}{pin_name}\u{201d}")),
|
||||||
None,
|
None,
|
||||||
@@ -477,60 +473,51 @@ impl relm4::factory::FactoryComponent for HostCard {
|
|||||||
Some("card.toggle-pin"),
|
Some("card.toggle-pin"),
|
||||||
Some(&pin_id.as_str().to_variant()),
|
Some(&pin_id.as_str().to_variant()),
|
||||||
);
|
);
|
||||||
menu.append_item(&unpin);
|
manage.append_item(&unpin);
|
||||||
menu.append(Some("Copy link"), Some("card.copy-link"));
|
menu.append_section(None, &manage);
|
||||||
menu.append(Some("Create shortcut\u{2026}"), Some("card.shortcut"));
|
|
||||||
} else {
|
} else {
|
||||||
|
// Starting a stream: a plain click already connects with the host's own
|
||||||
|
// profile, so the menu only needs the one-offs — and only when there are
|
||||||
|
// profiles to pick between.
|
||||||
if !profiles.is_empty() {
|
if !profiles.is_empty() {
|
||||||
let with = gio::Menu::new();
|
let with = gio::Menu::new();
|
||||||
let bind = gio::Menu::new();
|
|
||||||
let pin = gio::Menu::new();
|
|
||||||
for (label, id) in std::iter::once(("Default settings", ""))
|
for (label, id) in std::iter::once(("Default settings", ""))
|
||||||
.chain(profiles.iter().map(|p| (p.name.as_str(), p.id.as_str())))
|
.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
|
let item = gio::MenuItem::new(Some(label), None);
|
||||||
// a GMenu radio needs shared state per card; the bound profile is
|
item.set_action_and_target_value(
|
||||||
// named on the card's chip instead, visible without opening a menu.
|
Some("card.connect-with"),
|
||||||
for (menu, action) in
|
Some(&id.to_variant()),
|
||||||
[(&with, "card.connect-with"), (&bind, "card.bind-profile")]
|
);
|
||||||
{
|
with.append_item(&item);
|
||||||
let item = gio::MenuItem::new(Some(label), None);
|
|
||||||
item.set_action_and_target_value(
|
|
||||||
Some(action),
|
|
||||||
Some(&id.to_variant()),
|
|
||||||
);
|
|
||||||
menu.append_item(&item);
|
|
||||||
}
|
|
||||||
// "Default settings" is not pinnable — the primary card is that.
|
|
||||||
if !id.is_empty() && !k.pinned_profiles.iter().any(|p| p == id) {
|
|
||||||
let item = gio::MenuItem::new(Some(label), None);
|
|
||||||
item.set_action_and_target_value(
|
|
||||||
Some("card.toggle-pin"),
|
|
||||||
Some(&id.to_variant()),
|
|
||||||
);
|
|
||||||
pin.append_item(&item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
menu.append_submenu(Some("Connect with"), &with);
|
|
||||||
menu.append_submenu(Some("Default profile"), &bind);
|
|
||||||
if pin.n_items() > 0 {
|
|
||||||
menu.append_submenu(Some("Pin as card"), &pin);
|
|
||||||
}
|
}
|
||||||
|
let launch = gio::Menu::new();
|
||||||
|
launch.append_submenu(Some("Connect with"), &with);
|
||||||
|
menu.append_section(None, &launch);
|
||||||
}
|
}
|
||||||
menu.append(Some("Pair with PIN\u{2026}"), Some("card.pair"));
|
|
||||||
menu.append(Some("Test network speed\u{2026}"), Some("card.speed"));
|
let look = gio::Menu::new();
|
||||||
// An explicit wake only when offline and a MAC is known.
|
|
||||||
if !online && !k.mac.is_empty() {
|
|
||||||
menu.append(Some("Wake host"), Some("card.wake"));
|
|
||||||
}
|
|
||||||
// Experimental (Preferences gate): browse the host's game library.
|
// Experimental (Preferences gate): browse the host's game library.
|
||||||
if *library_enabled {
|
if *library_enabled {
|
||||||
menu.append(Some("Browse library\u{2026}"), Some("card.library"));
|
look.append(Some("Browse library\u{2026}"), Some("card.library"));
|
||||||
}
|
}
|
||||||
menu.append(Some("Copy link"), Some("card.copy-link"));
|
look.append(Some("Test network speed\u{2026}"), Some("card.speed"));
|
||||||
menu.append(Some("Create shortcut\u{2026}"), Some("card.shortcut"));
|
// An explicit wake only when offline and a MAC is known.
|
||||||
menu.append(Some("Edit\u{2026}"), Some("card.rename"));
|
if !online && !k.mac.is_empty() {
|
||||||
menu.append(Some("Forget"), Some("card.forget"));
|
look.append(Some("Wake host"), Some("card.wake"));
|
||||||
|
}
|
||||||
|
menu.append_section(None, &look);
|
||||||
|
|
||||||
|
let links = gio::Menu::new();
|
||||||
|
links.append(Some("Copy link"), Some("card.copy-link"));
|
||||||
|
links.append(Some("Create shortcut\u{2026}"), Some("card.shortcut"));
|
||||||
|
menu.append_section(None, &links);
|
||||||
|
|
||||||
|
let manage = gio::Menu::new();
|
||||||
|
manage.append(Some("Edit\u{2026}"), Some("card.rename"));
|
||||||
|
manage.append(Some("Pair with PIN\u{2026}"), Some("card.pair"));
|
||||||
|
manage.append(Some("Forget"), Some("card.forget"));
|
||||||
|
menu.append_section(None, &manage);
|
||||||
}
|
}
|
||||||
let menu_btn = gtk::MenuButton::builder()
|
let menu_btn = gtk::MenuButton::builder()
|
||||||
.icon_name("view-more-symbolic")
|
.icon_name("view-more-symbolic")
|
||||||
@@ -967,27 +954,6 @@ impl SimpleComponent for HostsPage {
|
|||||||
CardOutput::Edit { fp_hex, name } => self.edit_host_dialog(&sender, &fp_hex, &name),
|
CardOutput::Edit { fp_hex, name } => self.edit_host_dialog(&sender, &fp_hex, &name),
|
||||||
CardOutput::Forget { fp_hex, name } => self.forget_dialog(&sender, &fp_hex, &name),
|
CardOutput::Forget { fp_hex, name } => self.forget_dialog(&sender, &fp_hex, &name),
|
||||||
CardOutput::Wake { mac, addr } => crate::wol::wake(&mac, addr.parse().ok()),
|
CardOutput::Wake { mac, addr } => crate::wol::wake(&mac, addr.parse().ok()),
|
||||||
CardOutput::BindProfile {
|
|
||||||
fp_hex,
|
|
||||||
addr,
|
|
||||||
port,
|
|
||||||
profile_id,
|
|
||||||
} => {
|
|
||||||
// Written straight onto the host record — the binding IS a field there, so
|
|
||||||
// there is no map to keep in step (design §4.1). Matched by fingerprint
|
|
||||||
// when there is one, else by address, like every other per-host lookup.
|
|
||||||
let mut known = KnownHosts::load();
|
|
||||||
if let Some(h) = known.hosts.iter_mut().find(|h| {
|
|
||||||
(!fp_hex.is_empty() && h.fp_hex == fp_hex)
|
|
||||||
|| (h.addr == addr && h.port == port)
|
|
||||||
}) {
|
|
||||||
h.profile_id = profile_id;
|
|
||||||
if let Err(e) = known.save() {
|
|
||||||
tracing::warn!(error = %format!("{e:#}"), "saving the profile binding");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.rebuild(); // the chip follows immediately
|
|
||||||
}
|
|
||||||
CardOutput::CopyLink(url) => {
|
CardOutput::CopyLink(url) => {
|
||||||
if let Some(display) = gtk::gdk::Display::default() {
|
if let Some(display) = gtk::gdk::Display::default() {
|
||||||
display.clipboard().set_text(&url);
|
display.clipboard().set_text(&url);
|
||||||
@@ -1247,6 +1213,26 @@ impl HostsPage {
|
|||||||
.build();
|
.build();
|
||||||
profile_row.set_selected(selected as u32);
|
profile_row.set_selected(selected as u32);
|
||||||
|
|
||||||
|
// Pinned cards: which profiles get their own one-click card for this host. They used
|
||||||
|
// to be a third submenu on the card, which is what tipped that menu over — and this is
|
||||||
|
// where they belong anyway, next to the default they sit beside (design §5.2a).
|
||||||
|
let pin_rows: Vec<(String, adw::SwitchRow)> = catalog
|
||||||
|
.profiles
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
let row = adw::SwitchRow::builder()
|
||||||
|
.title(&p.name)
|
||||||
|
.subtitle("Show as its own card")
|
||||||
|
.build();
|
||||||
|
row.set_active(
|
||||||
|
stored
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|h| h.pinned_profiles.iter().any(|id| id == &p.id)),
|
||||||
|
);
|
||||||
|
(p.id.clone(), row)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
let list = gtk::ListBox::builder()
|
let list = gtk::ListBox::builder()
|
||||||
.selection_mode(gtk::SelectionMode::None)
|
.selection_mode(gtk::SelectionMode::None)
|
||||||
.css_classes(["boxed-list"])
|
.css_classes(["boxed-list"])
|
||||||
@@ -1254,6 +1240,9 @@ impl HostsPage {
|
|||||||
list.append(&name_row);
|
list.append(&name_row);
|
||||||
list.append(&profile_row);
|
list.append(&profile_row);
|
||||||
list.append(&clipboard_row);
|
list.append(&clipboard_row);
|
||||||
|
for (_, row) in &pin_rows {
|
||||||
|
list.append(row);
|
||||||
|
}
|
||||||
|
|
||||||
let dialog = adw::AlertDialog::new(Some("Edit Host"), None);
|
let dialog = adw::AlertDialog::new(Some("Edit Host"), None);
|
||||||
dialog.set_extra_child(Some(&list));
|
dialog.set_extra_child(Some(&list));
|
||||||
@@ -1276,6 +1265,13 @@ impl HostsPage {
|
|||||||
.get(profile_row.selected() as usize)
|
.get(profile_row.selected() as usize)
|
||||||
.filter(|id| !id.is_empty())
|
.filter(|id| !id.is_empty())
|
||||||
.cloned();
|
.cloned();
|
||||||
|
// Rebuilt from the switches rather than toggled, so the card order follows
|
||||||
|
// the catalog and a profile deleted meanwhile simply drops out.
|
||||||
|
h.pinned_profiles = pin_rows
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, row)| row.is_active())
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
.collect();
|
||||||
let _ = known.save();
|
let _ = known.save();
|
||||||
}
|
}
|
||||||
sender.input(HostsMsg::Refresh);
|
sender.input(HostsMsg::Refresh);
|
||||||
|
|||||||
+357
-168
@@ -35,21 +35,35 @@ pub enum Scope {
|
|||||||
/// (design §4.1). Keys are the overlay's field names, with `resolution` covering the
|
/// (design §4.1). Keys are the overlay's field names, with `resolution` covering the
|
||||||
/// width/height/match-window tri-state that one row drives.
|
/// width/height/match-window tri-state that one row drives.
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
struct Touched(Rc<RefCell<HashSet<&'static str>>>);
|
struct Touched {
|
||||||
|
keys: Rc<RefCell<HashSet<&'static str>>>,
|
||||||
|
/// Set while a control is being changed BY US (putting a reset row back to the inherited
|
||||||
|
/// value). Without it the programmatic change would read as the user touching the row and
|
||||||
|
/// immediately re-create the override the reset just removed.
|
||||||
|
suspended: Rc<std::cell::Cell<bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
impl Touched {
|
impl Touched {
|
||||||
fn mark(&self, key: &'static str) {
|
fn mark(&self, key: &'static str) {
|
||||||
self.0.borrow_mut().insert(key);
|
self.keys.borrow_mut().insert(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn suspended(&self) -> bool {
|
||||||
|
self.suspended.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_suspended(&self, v: bool) {
|
||||||
|
self.suspended.set(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has(&self, key: &str) -> bool {
|
fn has(&self, key: &str) -> bool {
|
||||||
self.0.borrow().contains(key)
|
self.keys.borrow().contains(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Undo a touch — the user reset this row after changing it, and "back to inheriting" is
|
/// Undo a touch — the user reset this row after changing it, and "back to inheriting" is
|
||||||
/// the later, explicit intent.
|
/// the later, explicit intent.
|
||||||
fn forget(&self, key: &str) {
|
fn forget(&self, key: &str) {
|
||||||
self.0.borrow_mut().remove(key);
|
self.keys.borrow_mut().remove(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +81,83 @@ const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
|||||||
/// `1.0` = Native. Applied at connect and each match-window resize.
|
/// `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];
|
const RENDER_SCALES: &[f64] = &[0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||||
|
|
||||||
|
/// Where each picker sits for a given settings snapshot. Factored out because two places need
|
||||||
|
/// exactly this: seeding the dialog, and putting ONE row back to the inherited value when its
|
||||||
|
/// override is reset — and those two must agree, or a reset would land on a different option
|
||||||
|
/// than reopening the dialog would show.
|
||||||
|
mod index {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub fn resolution(s: &Settings) -> u32 {
|
||||||
|
// Index 1 is the virtual "Match window" entry; 0 = Native, 2.. = the explicit sizes.
|
||||||
|
let i = if s.match_window {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
RESOLUTIONS
|
||||||
|
.iter()
|
||||||
|
.position(|&(w, h)| w == s.width && h == s.height)
|
||||||
|
.map(|i| if i == 0 { 0 } else { i + 1 })
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
i as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh(s: &Settings) -> u32 {
|
||||||
|
REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_scale(s: &Settings) -> u32 {
|
||||||
|
RENDER_SCALES
|
||||||
|
.iter()
|
||||||
|
.position(|&x| (x - s.render_scale).abs() < 1e-6)
|
||||||
|
.unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap()) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn codec(s: &Settings) -> u32 {
|
||||||
|
CODECS.iter().position(|&c| c == s.codec).unwrap_or(0) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compositor(s: &Settings) -> u32 {
|
||||||
|
COMPOSITORS
|
||||||
|
.iter()
|
||||||
|
.position(|&c| c == s.compositor)
|
||||||
|
.unwrap_or(0) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stats(s: &Settings) -> u32 {
|
||||||
|
StatsVerbosity::ALL
|
||||||
|
.iter()
|
||||||
|
.position(|v| *v == s.stats_verbosity())
|
||||||
|
.unwrap_or(0) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn touch(s: &Settings) -> u32 {
|
||||||
|
TOUCH_MODES
|
||||||
|
.iter()
|
||||||
|
.position(|&t| t == s.touch_mode)
|
||||||
|
.unwrap_or(0) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mouse(s: &Settings) -> u32 {
|
||||||
|
MOUSE_MODES
|
||||||
|
.iter()
|
||||||
|
.position(|&m| m == s.mouse_mode)
|
||||||
|
.unwrap_or(0) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn surround(s: &Settings) -> u32 {
|
||||||
|
match s.audio_channels {
|
||||||
|
6 => 1,
|
||||||
|
8 => 2,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gamepad(s: &Settings) -> u32 {
|
||||||
|
GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0) as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The chip palette a profile can carry (`StreamProfile.accent`). Eight entries rather than a
|
/// 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
|
/// 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
|
/// small set of legible, contrast-checked colours does better than free choice — and the
|
||||||
@@ -83,12 +174,52 @@ const SWATCHES: &[(&str, &str, &str)] = &[
|
|||||||
("#77767b", "pf-swatch-slate", "Slate"),
|
("#77767b", "pf-swatch-slate", "Slate"),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// A row of colour swatches, calling back with the chosen `#RRGGBB` (empty = none). Used both
|
/// A colour picker as its own full-width row: the caption above, the swatches on their own
|
||||||
/// when creating a profile and when changing one later.
|
/// line beneath, wrapping if the dialog is narrow.
|
||||||
fn swatch_row(current: Option<&str>, on_pick: impl Fn(String) + 'static) -> gtk::Box {
|
///
|
||||||
let row = gtk::Box::builder()
|
/// They started as an [`adw::ActionRow`] suffix, which is where a control that size normally
|
||||||
.orientation(gtk::Orientation::Horizontal)
|
/// goes — but nine swatches squeeze the row's title until it is unreadable, and squeezing the
|
||||||
|
/// label to fit the control is backwards. A `FlowBox` beneath keeps both legible at any width.
|
||||||
|
fn colour_row(
|
||||||
|
title: &str,
|
||||||
|
current: Option<&str>,
|
||||||
|
on_pick: impl Fn(String) + 'static,
|
||||||
|
) -> adw::PreferencesRow {
|
||||||
|
let box_ = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
|
.margin_top(12)
|
||||||
|
.margin_bottom(12)
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.build();
|
||||||
|
box_.append(
|
||||||
|
>k::Label::builder()
|
||||||
|
.label(title)
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
box_.append(&swatch_row(current, on_pick));
|
||||||
|
adw::PreferencesRow::builder()
|
||||||
|
.activatable(false)
|
||||||
|
.child(&box_)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The swatches themselves, calling back with the chosen `#RRGGBB` (empty = none).
|
||||||
|
fn swatch_row(current: Option<&str>, on_pick: impl Fn(String) + 'static) -> gtk::FlowBox {
|
||||||
|
let row = gtk::FlowBox::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.selection_mode(gtk::SelectionMode::None)
|
||||||
|
.column_spacing(8)
|
||||||
|
.row_spacing(8)
|
||||||
|
.min_children_per_line(5)
|
||||||
|
.max_children_per_line(9)
|
||||||
|
// NOT homogeneous, and start-aligned: a homogeneous FlowBox stretches every child to
|
||||||
|
// the widest one, which turns 26px circles into wide rounded rectangles the moment the
|
||||||
|
// row has space to spare.
|
||||||
|
.homogeneous(false)
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
.build();
|
.build();
|
||||||
let on_pick = Rc::new(on_pick);
|
let on_pick = Rc::new(on_pick);
|
||||||
let buttons: Rc<RefCell<Vec<(String, gtk::Button)>>> = Rc::default();
|
let buttons: Rc<RefCell<Vec<(String, gtk::Button)>>> = Rc::default();
|
||||||
@@ -113,7 +244,7 @@ fn swatch_row(current: Option<&str>, on_pick: impl Fn(String) + 'static) -> gtk:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
buttons.borrow_mut().push((hex.to_string(), b.clone()));
|
buttons.borrow_mut().push((hex.to_string(), b.clone()));
|
||||||
row.append(&b);
|
row.insert(&b, -1);
|
||||||
}
|
}
|
||||||
row
|
row
|
||||||
}
|
}
|
||||||
@@ -207,24 +338,22 @@ fn scope_group(
|
|||||||
if let Some(active) = active {
|
if let Some(active) = active {
|
||||||
// Colour first: it is what the profile's chips carry on host cards, so it belongs with
|
// 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.
|
// the profile's identity rather than buried behind a menu.
|
||||||
let colour = adw::ActionRow::builder()
|
g.add(&colour_row(
|
||||||
.title("Colour")
|
"Colour \u{2014} tints this profile's chips on host cards",
|
||||||
.subtitle("Tints this profile's chips on host cards")
|
active.accent.as_deref(),
|
||||||
.use_markup(false)
|
{
|
||||||
.build();
|
let id = active.id.clone();
|
||||||
colour.add_suffix(&swatch_row(active.accent.as_deref(), {
|
move |hex| {
|
||||||
let id = active.id.clone();
|
let mut catalog = ProfilesFile::load();
|
||||||
move |hex| {
|
if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) {
|
||||||
let mut catalog = ProfilesFile::load();
|
p.accent = (!hex.is_empty()).then(|| hex.clone());
|
||||||
if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) {
|
if let Err(e) = catalog.save() {
|
||||||
p.accent = (!hex.is_empty()).then(|| hex.clone());
|
tracing::warn!(error = %format!("{e:#}"), "saving the profile colour");
|
||||||
if let Err(e) = catalog.save() {
|
}
|
||||||
tracing::warn!(error = %format!("{e:#}"), "saving the profile colour");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}));
|
));
|
||||||
g.add(&colour);
|
|
||||||
let actions = adw::ActionRow::builder()
|
let actions = adw::ActionRow::builder()
|
||||||
.title(&active.name)
|
.title(&active.name)
|
||||||
.subtitle("This profile")
|
.subtitle("This profile")
|
||||||
@@ -393,15 +522,10 @@ fn prompt_name(
|
|||||||
// for it afterwards is exactly the friction that leaves every profile grey.
|
// for it afterwards is exactly the friction that leaves every profile grey.
|
||||||
let accent: Rc<RefCell<Option<String>>> = Rc::default();
|
let accent: Rc<RefCell<Option<String>>> = Rc::default();
|
||||||
if with_colour {
|
if with_colour {
|
||||||
let row = adw::ActionRow::builder()
|
list.append(&colour_row("Colour", None, {
|
||||||
.title("Colour")
|
|
||||||
.use_markup(false)
|
|
||||||
.build();
|
|
||||||
row.add_suffix(&swatch_row(None, {
|
|
||||||
let accent = accent.clone();
|
let accent = accent.clone();
|
||||||
move |hex| *accent.borrow_mut() = (!hex.is_empty()).then(|| hex.clone())
|
move |hex| *accent.borrow_mut() = (!hex.is_empty()).then(|| hex.clone())
|
||||||
}));
|
}));
|
||||||
list.append(&row);
|
|
||||||
}
|
}
|
||||||
dialog.set_extra_child(Some(&list));
|
dialog.set_extra_child(Some(&list));
|
||||||
dialog.add_responses(&[("cancel", "Cancel"), ("ok", accept)]);
|
dialog.add_responses(&[("cancel", "Cancel"), ("ok", accept)]);
|
||||||
@@ -444,12 +568,7 @@ fn prompt_name(
|
|||||||
/// what keeps an older client from erasing a newer one's values just by opening the dialog.
|
/// 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
|
/// The catalog is re-read here rather than reused, so a profile renamed in another window
|
||||||
/// between opening and closing this one survives.
|
/// between opening and closing this one survives.
|
||||||
fn commit_profile(
|
fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings) {
|
||||||
active: &StreamProfile,
|
|
||||||
touched: &Touched,
|
|
||||||
cleared: &HashSet<&'static str>,
|
|
||||||
values: &Settings,
|
|
||||||
) {
|
|
||||||
let mut catalog = ProfilesFile::load();
|
let mut catalog = ProfilesFile::load();
|
||||||
let Some(slot) = catalog.profiles.iter_mut().find(|p| p.id == active.id) else {
|
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
|
return; // deleted from under us — nothing to write to, and nothing to complain about
|
||||||
@@ -509,15 +628,9 @@ fn commit_profile(
|
|||||||
if touched.has("fullscreen_on_stream") {
|
if touched.has("fullscreen_on_stream") {
|
||||||
o.fullscreen_on_stream = Some(values.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
|
// Resets are not handled here: they clear the field and re-seed their row the moment the
|
||||||
// (a ComboRow re-seeds), and "back to inheriting" is the later, explicit intent. The field
|
// user asks, so by the time this runs the catalog already reflects them and the row is no
|
||||||
// names are the overlay's own, so the list of what can be cleared lives in ONE place —
|
// longer marked touched.
|
||||||
// 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() {
|
if let Err(e) = catalog.save() {
|
||||||
tracing::warn!(error = %format!("{e:#}"), "saving the profile catalog");
|
tracing::warn!(error = %format!("{e:#}"), "saving the profile catalog");
|
||||||
}
|
}
|
||||||
@@ -633,6 +746,7 @@ type ChangedFn = Rc<RefCell<Vec<Box<dyn Fn(u32)>>>>;
|
|||||||
/// A titled single-choice preference row. On a desktop this is a stock popover
|
/// 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
|
/// [`adw::ComboRow`]; under gamescope (see [`gamescope_session`]) it becomes an activatable
|
||||||
/// row that pushes an in-window selection subpage onto the preferences dialog instead.
|
/// row that pushes an in-window selection subpage onto the preferences dialog instead.
|
||||||
|
#[derive(Clone)]
|
||||||
struct ChoiceRow {
|
struct ChoiceRow {
|
||||||
row: adw::PreferencesRow,
|
row: adw::PreferencesRow,
|
||||||
selected: Rc<Cell<u32>>,
|
selected: Rc<Cell<u32>>,
|
||||||
@@ -884,9 +998,9 @@ pub fn show_scoped(
|
|||||||
None => settings.borrow().clone(),
|
None => settings.borrow().clone(),
|
||||||
};
|
};
|
||||||
let touched = Touched::default();
|
let touched = Touched::default();
|
||||||
// Fields whose override a per-row reset dropped — applied at commit, after the touched
|
// The globals as they are right now — what a reset row goes back to showing. Read before
|
||||||
// ones, so "reset" wins over a value the same row happens to be showing.
|
// the close handler takes ownership of the cell.
|
||||||
let cleared: Rc<RefCell<HashSet<&'static str>>> = Rc::default();
|
let globals: Settings = settings.borrow().clone();
|
||||||
// Where a scope switch wants to go once this dialog has committed and closed.
|
// Where a scope switch wants to go once this dialog has committed and closed.
|
||||||
let next_scope: Rc<RefCell<Option<Scope>>> = Rc::default();
|
let next_scope: Rc<RefCell<Option<Scope>>> = Rc::default();
|
||||||
|
|
||||||
@@ -1242,52 +1356,24 @@ pub fn show_scoped(
|
|||||||
// ---- Seed from the effective settings for this scope ----
|
// ---- Seed from the effective settings for this scope ----
|
||||||
{
|
{
|
||||||
let s = &seed;
|
let s = &seed;
|
||||||
let res_i = if s.match_window {
|
let res_i = index::resolution(s);
|
||||||
1
|
res_row.set_selected(res_i);
|
||||||
} else {
|
set_row_subtitle(res_row.widget(), resolution_caption(res_i));
|
||||||
RESOLUTIONS
|
hz_row.set_selected(index::refresh(s));
|
||||||
.iter()
|
scale_row.set_selected(index::render_scale(s));
|
||||||
.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);
|
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(index::gamepad(s));
|
||||||
pad_row.set_selected(pad_i as u32);
|
let touch_i = index::touch(s);
|
||||||
let touch_i = TOUCH_MODES
|
touch_row.set_selected(touch_i);
|
||||||
.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_selected never fires the changed hook, so seed the dynamic caption directly.
|
||||||
set_row_subtitle(touch_row.widget(), TOUCH_MODE_CAPTIONS[touch_i]);
|
set_row_subtitle(touch_row.widget(), TOUCH_MODE_CAPTIONS[touch_i as usize]);
|
||||||
let mouse_i = MOUSE_MODES
|
let mouse_i = index::mouse(s);
|
||||||
.iter()
|
mouse_row.set_selected(mouse_i);
|
||||||
.position(|&m| m == s.mouse_mode)
|
set_row_subtitle(mouse_row.widget(), MOUSE_MODE_CAPTIONS[mouse_i as usize]);
|
||||||
.unwrap_or(0);
|
compositor_row.set_selected(index::compositor(s));
|
||||||
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);
|
let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0);
|
||||||
decoder_row.set_selected(dec_i as u32);
|
decoder_row.set_selected(dec_i as u32);
|
||||||
let stats_i = StatsVerbosity::ALL
|
stats_row.set_selected(index::stats(s));
|
||||||
.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);
|
fullscreen_row.set_active(s.fullscreen_on_stream);
|
||||||
wake_row.set_active(s.auto_wake);
|
wake_row.set_active(s.auto_wake);
|
||||||
inhibit_row.set_active(s.inhibit_shortcuts);
|
inhibit_row.set_active(s.inhibit_shortcuts);
|
||||||
@@ -1296,80 +1382,121 @@ pub fn show_scoped(
|
|||||||
hdr_row.set_active(s.hdr_enabled);
|
hdr_row.set_active(s.hdr_enabled);
|
||||||
chroma_row.set_active(s.enable_444);
|
chroma_row.set_active(s.enable_444);
|
||||||
library_row.set_active(s.library_enabled);
|
library_row.set_active(s.library_enabled);
|
||||||
surround_row.set_selected(match s.audio_channels {
|
surround_row.set_selected(index::surround(s));
|
||||||
6 => 1,
|
let codec_i = index::codec(s);
|
||||||
8 => 2,
|
codec_row.set_selected(codec_i);
|
||||||
_ => 0,
|
set_row_subtitle(codec_row.widget(), codec_caption(codec_i));
|
||||||
});
|
|
||||||
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 markers, per-row reset, and the touch that creates an override ----
|
// ---- 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
|
// One pass per row, because the three are the same fact from different sides: the marker
|
||||||
// marker says "this profile changes this", the reset is the only way back (the override
|
// says "this profile changes this", the reset is the only way back (the override model
|
||||||
// model never infers "not overridden" from a value comparison), and touching the control
|
// never infers "not overridden" from a value comparison), and touching the control is what
|
||||||
// is what creates it. The marker therefore has to appear ON TOUCH, not on the next open —
|
// creates it. The marker appears ON TOUCH — a user who changes a row and sees no
|
||||||
// a user who changes a row and sees nothing has no reason to believe it took.
|
// acknowledgement has no reason to believe it took.
|
||||||
|
//
|
||||||
|
// A reset acts IN PLACE: it clears the field, puts that one control back to the inherited
|
||||||
|
// value, and drops the marker. Rebuilding the dialog would be simpler, but it animates the
|
||||||
|
// whole surface for a one-row change and dumps the user back on the first page — a heavy,
|
||||||
|
// disorienting answer to "undo this row".
|
||||||
//
|
//
|
||||||
// Wired after the seed block on purpose: `set_selected`/`set_active` during setup must not
|
// 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.
|
// look like the user touching anything, or opening a profile would override every row.
|
||||||
if let Some(active) = &active {
|
if let Some(active) = &active {
|
||||||
let o = &active.overrides;
|
let o = &active.overrides;
|
||||||
// Build the marker for one row and hand back a "now it's overridden" switch its
|
let profile_id = active.id.clone();
|
||||||
// control's change handler can pull.
|
|
||||||
|
// The marker pair, created LAZILY: a hidden prefix still costs its slot in the row's
|
||||||
|
// layout, and every un-overridden row carrying an invisible dot's worth of inset reads
|
||||||
|
// as a misalignment. Returns the "now it is overridden" switch the control's change
|
||||||
|
// handler pulls.
|
||||||
let mark = |row: &adw::PreferencesRow,
|
let mark = |row: &adw::PreferencesRow,
|
||||||
key: &'static str,
|
key: &'static str,
|
||||||
overridden: bool|
|
overridden: bool,
|
||||||
-> Option<Box<dyn Fn()>> {
|
revert_control: Box<dyn Fn()>|
|
||||||
let row = row.downcast_ref::<adw::ActionRow>()?;
|
-> Option<Rc<dyn Fn()>> {
|
||||||
let dot = gtk::Box::builder()
|
let row = row.downcast_ref::<adw::ActionRow>()?.clone();
|
||||||
.css_classes(["pf-override-dot"])
|
let widgets: Rc<RefCell<Option<(gtk::Box, gtk::Button)>>> = Rc::default();
|
||||||
.valign(gtk::Align::Center)
|
let (touched, id) = (touched.clone(), profile_id.clone());
|
||||||
.visible(overridden)
|
let revert = Rc::new(revert_control);
|
||||||
.build();
|
let build = {
|
||||||
row.add_prefix(&dot);
|
let (widgets, row, touched, id, revert) = (
|
||||||
let reset = gtk::Button::builder()
|
widgets.clone(),
|
||||||
.icon_name("edit-undo-symbolic")
|
row.clone(),
|
||||||
.tooltip_text("Reset to Default settings")
|
touched.clone(),
|
||||||
.valign(gtk::Align::Center)
|
id.clone(),
|
||||||
.visible(overridden)
|
revert.clone(),
|
||||||
.css_classes(["flat"])
|
|
||||||
.build();
|
|
||||||
{
|
|
||||||
let (dialog, next, cleared, scope) = (
|
|
||||||
dialog.clone(),
|
|
||||||
next_scope.clone(),
|
|
||||||
cleared.clone(),
|
|
||||||
scope.clone(),
|
|
||||||
);
|
);
|
||||||
let touched = touched.clone();
|
Rc::new(move || {
|
||||||
reset.connect_clicked(move |_| {
|
if widgets.borrow().is_some() {
|
||||||
// Queue the clear and re-open in the same scope: the close handler commits
|
return;
|
||||||
// whatever else was edited first, then drops this field, and the rebuilt
|
}
|
||||||
// rows show the inherited value. Dropping it from `touched` too keeps a
|
let dot = gtk::Box::builder()
|
||||||
// reset-after-touch from re-writing what the reset just removed.
|
.css_classes(["pf-override-dot"])
|
||||||
touched.forget(key);
|
.valign(gtk::Align::Center)
|
||||||
cleared.borrow_mut().insert(key);
|
.build();
|
||||||
*next.borrow_mut() = Some(scope.clone());
|
let reset = gtk::Button::builder()
|
||||||
dialog.close();
|
.icon_name("edit-undo-symbolic")
|
||||||
});
|
.tooltip_text("Reset to Default settings")
|
||||||
|
.valign(gtk::Align::Center)
|
||||||
|
.css_classes(["flat"])
|
||||||
|
.build();
|
||||||
|
row.add_prefix(&dot);
|
||||||
|
row.add_suffix(&reset);
|
||||||
|
{
|
||||||
|
let (widgets, row, touched, id, revert) = (
|
||||||
|
widgets.clone(),
|
||||||
|
row.clone(),
|
||||||
|
touched.clone(),
|
||||||
|
id.clone(),
|
||||||
|
revert.clone(),
|
||||||
|
);
|
||||||
|
reset.connect_clicked(move |_| {
|
||||||
|
// Drop the override from the catalog now — the close handler
|
||||||
|
// re-reads it, so there is nothing to reconcile later — and
|
||||||
|
// un-touch the row so the commit can't re-write what this removed.
|
||||||
|
touched.forget(key);
|
||||||
|
let mut catalog = ProfilesFile::load();
|
||||||
|
if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) {
|
||||||
|
p.overrides.clear(key);
|
||||||
|
if let Err(e) = catalog.save() {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "clearing an override");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
revert();
|
||||||
|
if let Some((dot, reset)) = widgets.borrow_mut().take() {
|
||||||
|
row.remove(&dot);
|
||||||
|
row.remove(&reset);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
*widgets.borrow_mut() = Some((dot, reset));
|
||||||
|
}) as Rc<dyn Fn()>
|
||||||
|
};
|
||||||
|
if overridden {
|
||||||
|
build();
|
||||||
}
|
}
|
||||||
row.add_suffix(&reset);
|
Some(build)
|
||||||
Some(Box::new(move || {
|
|
||||||
dot.set_visible(true);
|
|
||||||
reset.set_visible(true);
|
|
||||||
}))
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// `(row widget, overlay field, is it overridden right now)` — then the control's own
|
// Each row: how to put it back to the INHERITED value (the globals, not this
|
||||||
// change signal marks it touched AND lights the marker.
|
// profile's), and what marks it touched.
|
||||||
macro_rules! choice {
|
macro_rules! choice {
|
||||||
($row:expr, $key:literal, $overridden:expr) => {{
|
($row:expr, $key:literal, $overridden:expr, $idx:path) => {{
|
||||||
let show = mark($row.widget(), $key, $overridden);
|
let revert = {
|
||||||
|
let (row, globals, touched) = ($row.clone(), globals.clone(), touched.clone());
|
||||||
|
Box::new(move || {
|
||||||
|
touched.set_suspended(true);
|
||||||
|
row.set_selected($idx(&globals));
|
||||||
|
touched.set_suspended(false);
|
||||||
|
}) as Box<dyn Fn()>
|
||||||
|
};
|
||||||
|
let show = mark($row.widget(), $key, $overridden, revert);
|
||||||
let t = touched.clone();
|
let t = touched.clone();
|
||||||
$row.connect_changed(move |_| {
|
$row.connect_changed(move |_| {
|
||||||
|
if t.suspended() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
t.mark($key);
|
t.mark($key);
|
||||||
if let Some(show) = &show {
|
if let Some(show) = &show {
|
||||||
show();
|
show();
|
||||||
@@ -1378,10 +1505,21 @@ pub fn show_scoped(
|
|||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
macro_rules! toggle {
|
macro_rules! toggle {
|
||||||
($row:expr, $key:literal, $overridden:expr) => {{
|
($row:expr, $key:literal, $overridden:expr, $field:ident) => {{
|
||||||
let show = mark($row.upcast_ref(), $key, $overridden);
|
let revert = {
|
||||||
|
let (row, globals, touched) = ($row.clone(), globals.clone(), touched.clone());
|
||||||
|
Box::new(move || {
|
||||||
|
touched.set_suspended(true);
|
||||||
|
row.set_active(globals.$field);
|
||||||
|
touched.set_suspended(false);
|
||||||
|
}) as Box<dyn Fn()>
|
||||||
|
};
|
||||||
|
let show = mark($row.upcast_ref(), $key, $overridden, revert);
|
||||||
let t = touched.clone();
|
let t = touched.clone();
|
||||||
$row.connect_active_notify(move |_| {
|
$row.connect_active_notify(move |_| {
|
||||||
|
if t.suspended() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
t.mark($key);
|
t.mark($key);
|
||||||
if let Some(show) = &show {
|
if let Some(show) = &show {
|
||||||
show();
|
show();
|
||||||
@@ -1393,39 +1531,90 @@ pub fn show_scoped(
|
|||||||
choice!(
|
choice!(
|
||||||
res_row,
|
res_row,
|
||||||
"resolution",
|
"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(),
|
||||||
|
index::resolution
|
||||||
);
|
);
|
||||||
choice!(hz_row, "refresh_hz", o.refresh_hz.is_some());
|
choice!(hz_row, "refresh_hz", o.refresh_hz.is_some(), index::refresh);
|
||||||
choice!(scale_row, "render_scale", o.render_scale.is_some());
|
choice!(
|
||||||
choice!(codec_row, "codec", o.codec.is_some());
|
scale_row,
|
||||||
choice!(compositor_row, "compositor", o.compositor.is_some());
|
"render_scale",
|
||||||
choice!(stats_row, "stats_verbosity", o.stats_verbosity.is_some());
|
o.render_scale.is_some(),
|
||||||
choice!(touch_row, "touch_mode", o.touch_mode.is_some());
|
index::render_scale
|
||||||
choice!(mouse_row, "mouse_mode", o.mouse_mode.is_some());
|
);
|
||||||
choice!(surround_row, "audio_channels", o.audio_channels.is_some());
|
choice!(codec_row, "codec", o.codec.is_some(), index::codec);
|
||||||
choice!(pad_row, "gamepad", o.gamepad.is_some());
|
choice!(
|
||||||
toggle!(hdr_row, "hdr_enabled", o.hdr_enabled.is_some());
|
compositor_row,
|
||||||
toggle!(chroma_row, "enable_444", o.enable_444.is_some());
|
"compositor",
|
||||||
|
o.compositor.is_some(),
|
||||||
|
index::compositor
|
||||||
|
);
|
||||||
|
choice!(
|
||||||
|
stats_row,
|
||||||
|
"stats_verbosity",
|
||||||
|
o.stats_verbosity.is_some(),
|
||||||
|
index::stats
|
||||||
|
);
|
||||||
|
choice!(
|
||||||
|
touch_row,
|
||||||
|
"touch_mode",
|
||||||
|
o.touch_mode.is_some(),
|
||||||
|
index::touch
|
||||||
|
);
|
||||||
|
choice!(
|
||||||
|
mouse_row,
|
||||||
|
"mouse_mode",
|
||||||
|
o.mouse_mode.is_some(),
|
||||||
|
index::mouse
|
||||||
|
);
|
||||||
|
choice!(
|
||||||
|
surround_row,
|
||||||
|
"audio_channels",
|
||||||
|
o.audio_channels.is_some(),
|
||||||
|
index::surround
|
||||||
|
);
|
||||||
|
choice!(pad_row, "gamepad", o.gamepad.is_some(), index::gamepad);
|
||||||
|
toggle!(hdr_row, "hdr_enabled", o.hdr_enabled.is_some(), hdr_enabled);
|
||||||
|
toggle!(chroma_row, "enable_444", o.enable_444.is_some(), enable_444);
|
||||||
toggle!(
|
toggle!(
|
||||||
fullscreen_row,
|
fullscreen_row,
|
||||||
"fullscreen_on_stream",
|
"fullscreen_on_stream",
|
||||||
o.fullscreen_on_stream.is_some()
|
o.fullscreen_on_stream.is_some(),
|
||||||
|
fullscreen_on_stream
|
||||||
);
|
);
|
||||||
toggle!(
|
toggle!(
|
||||||
inhibit_row,
|
inhibit_row,
|
||||||
"inhibit_shortcuts",
|
"inhibit_shortcuts",
|
||||||
o.inhibit_shortcuts.is_some()
|
o.inhibit_shortcuts.is_some(),
|
||||||
|
inhibit_shortcuts
|
||||||
);
|
);
|
||||||
toggle!(invert_row, "invert_scroll", o.invert_scroll.is_some());
|
toggle!(
|
||||||
toggle!(mic_row, "mic_enabled", o.mic_enabled.is_some());
|
invert_row,
|
||||||
|
"invert_scroll",
|
||||||
|
o.invert_scroll.is_some(),
|
||||||
|
invert_scroll
|
||||||
|
);
|
||||||
|
toggle!(mic_row, "mic_enabled", o.mic_enabled.is_some(), mic_enabled);
|
||||||
{
|
{
|
||||||
|
let revert = {
|
||||||
|
let (row, globals, touched) =
|
||||||
|
(bitrate_row.clone(), globals.clone(), touched.clone());
|
||||||
|
Box::new(move || {
|
||||||
|
touched.set_suspended(true);
|
||||||
|
row.set_value(f64::from(globals.bitrate_kbps) / 1000.0);
|
||||||
|
touched.set_suspended(false);
|
||||||
|
}) as Box<dyn Fn()>
|
||||||
|
};
|
||||||
let show = mark(
|
let show = mark(
|
||||||
bitrate_row.upcast_ref(),
|
bitrate_row.upcast_ref(),
|
||||||
"bitrate_kbps",
|
"bitrate_kbps",
|
||||||
o.bitrate_kbps.is_some(),
|
o.bitrate_kbps.is_some(),
|
||||||
|
revert,
|
||||||
);
|
);
|
||||||
let t = touched.clone();
|
let t = touched.clone();
|
||||||
bitrate_row.connect_value_notify(move |_| {
|
bitrate_row.connect_value_notify(move |_| {
|
||||||
|
if t.suspended() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
t.mark("bitrate_kbps");
|
t.mark("bitrate_kbps");
|
||||||
if let Some(show) = &show {
|
if let Some(show) = &show {
|
||||||
show();
|
show();
|
||||||
@@ -1634,7 +1823,7 @@ pub fn show_scoped(
|
|||||||
Some(active) => {
|
Some(active) => {
|
||||||
let mut values = seed.clone();
|
let mut values = seed.clone();
|
||||||
apply_rows(&mut values);
|
apply_rows(&mut values);
|
||||||
commit_profile(active, &touched, &cleared.borrow(), &values);
|
commit_profile(active, &touched, &values);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let mut s = settings.borrow_mut();
|
let mut s = settings.borrow_mut();
|
||||||
|
|||||||
Reference in New Issue
Block a user