fix(client/windows): pinning works where you can see it, and the rows learn one skeleton
"Can't pin hosts with a certain profile" — live diagnosis found the pin switches WORKED
but sat below the fold: the host editor rendered as an in-grid tile, its grid cell parked
mid-page, and on an ordinary window the pin section's visible rect was a 9-px sliver a
mouse can't hit (UIA's pattern-toggle, which bypasses hit-testing, persisted fine — that
is what isolated it). The editor is now a centred sheet — scrim + card, titled with the
host, Save/Cancel right-aligned — in a stable overlay slot beside the add modal and the
forget dialog. Verified end-to-end with a real mouse click: toggle flips, the pin lands in
client-known-hosts.json, and the pinned tile is on the grid after Save. Pin toggles also
log now — this hunt was blind until they did.
The settings rows learn one skeleton, label / (marker) / input / caption, per review:
* The Overridden capsule ("Overridden │ Reset", one tinted pill, whole-pill tap target)
sits BETWEEN the row's label and its input, left-aligned — not trailing controls of
wildly different widths, not mixed into the caption.
* Labels move out of the widgets (`.header` is gone from the row builders) into the row
itself — a widget-embedded header allows nothing between label and box, and uniform
6-epx row spacing needs the row to own all four lines.
* The scope bar's combo and Edit button share an explicit 36-epx height, and the bar shows
the active profile's colour as a 12-epx chip beside the combo (dropdown items are plain
strings in this toolkit, so the chip cannot ride inside the list).
* The sheet's colour swatches size the disc itself, not its child — they rendered as
squashed ovals under the sheet's layout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -393,13 +393,18 @@ fn edit_editor(
|
||||
.on_content("Shown")
|
||||
.off_content("Hidden")
|
||||
.on_toggled(move |v: bool| {
|
||||
tracing::info!(pin = %id, host = %fp, on = v, "pin toggle");
|
||||
let mut known = KnownHosts::load();
|
||||
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
|
||||
h.pinned_profiles.retain(|x| x != &id);
|
||||
if v {
|
||||
h.pinned_profiles.push(id.clone());
|
||||
}
|
||||
let _ = known.save();
|
||||
if let Err(e) = known.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "saving a pin");
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(host = %fp, "pin toggle: no such saved host");
|
||||
}
|
||||
})
|
||||
.into(),
|
||||
@@ -428,9 +433,17 @@ fn edit_editor(
|
||||
mac_draft.borrow().clone(),
|
||||
*clip_draft.borrow(),
|
||||
);
|
||||
let _ = initial_name;
|
||||
card(
|
||||
// A centred SHEET (scrim + card), not an in-grid tile: as a tile the editor inherited a
|
||||
// grid cell in the middle of the page, and on an ordinary window its lower half — the
|
||||
// pin switches especially — sat below the fold with nothing hinting at it ("can't pin
|
||||
// hosts", live-diagnosed 2026-07-29: the switch's visible rect was a 9-px sliver). A
|
||||
// sheet centres at its own height, scrolls internally when it must, and matches where
|
||||
// every other edit flow lives.
|
||||
let modal = dialog_surface(
|
||||
vstack((
|
||||
text_block(format!("Edit \u{201c}{initial_name}\u{201d}"))
|
||||
.font_size(20.0)
|
||||
.bold(),
|
||||
field("Name", name0, "e.g. Living Room", name_draft),
|
||||
field("Address", addr0, "IP or hostname", addr_draft),
|
||||
field("Port", port0, "9777", port_draft),
|
||||
@@ -478,11 +491,24 @@ fn edit_editor(
|
||||
.subtle()
|
||||
.on_click(move || set_edit.call(None)),
|
||||
))
|
||||
.spacing(4.0),
|
||||
.spacing(8.0)
|
||||
.horizontal_alignment(HorizontalAlignment::Right),
|
||||
))
|
||||
.spacing(10.0),
|
||||
)
|
||||
.into()
|
||||
.max_width(460.0)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Center)
|
||||
.margin(uniform(24.0));
|
||||
// The scrim: blocks the page, closes only via Save/Cancel (same rules as the add modal).
|
||||
border(modal)
|
||||
.background(Color {
|
||||
a: 140,
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
@@ -661,21 +687,6 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
.map(|p| (p.id, p.name, p.accent))
|
||||
.collect();
|
||||
for k in &known.hosts {
|
||||
// Rust 2021 (no let-chains): match the "this tile is being edited" case explicitly.
|
||||
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
|
||||
let (fp, initial) = rename.clone().unwrap();
|
||||
tiles.push(edit_editor(
|
||||
&fp,
|
||||
&initial,
|
||||
name_draft.clone(),
|
||||
addr_draft.clone(),
|
||||
port_draft.clone(),
|
||||
mac_draft.clone(),
|
||||
clip_draft.clone(),
|
||||
set_rename.clone(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let target = Target {
|
||||
name: k.name.clone(),
|
||||
addr: k.addr.clone(),
|
||||
@@ -1090,5 +1101,20 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
} else {
|
||||
border(vstack(Vec::<Element>::new())).into()
|
||||
};
|
||||
grid(vec![page, add_slot, forget_confirm]).into()
|
||||
// The host editor sheet, in its own stable slot (see the add modal's note).
|
||||
let edit_slot: Element = if let Some((fp, initial)) = &rename {
|
||||
edit_editor(
|
||||
fp,
|
||||
initial,
|
||||
name_draft.clone(),
|
||||
addr_draft.clone(),
|
||||
port_draft.clone(),
|
||||
mac_draft.clone(),
|
||||
clip_draft.clone(),
|
||||
set_rename.clone(),
|
||||
)
|
||||
} else {
|
||||
border(vstack(Vec::<Element>::new())).into()
|
||||
};
|
||||
grid(vec![page, add_slot, edit_slot, forget_confirm]).into()
|
||||
}
|
||||
|
||||
+199
-259
@@ -167,7 +167,11 @@ fn colour_swatches(profile: &StreamProfile, rev: u64, set_rev: &AsyncSetState<u6
|
||||
});
|
||||
let (id, set_rev, hex_owned) = (profile.id.clone(), set_rev.clone(), hex.to_string());
|
||||
row.push(
|
||||
border(vstack(Vec::<Element>::new()).width(20.0).height(20.0))
|
||||
// Size on the BORDER itself: sized only via its child, the border gets squeezed
|
||||
// by the sheet's layout and the discs render as squashed ovals.
|
||||
border(vstack(Vec::<Element>::new()))
|
||||
.width(20.0)
|
||||
.height(20.0)
|
||||
.background(fill)
|
||||
.corner_radius(10.0)
|
||||
.border_brush(if selected {
|
||||
@@ -447,11 +451,14 @@ fn active_profile(scope: &str) -> Option<StreamProfile> {
|
||||
.flatten()
|
||||
}
|
||||
|
||||
// NOTE: the row builders no longer set the widget's own `.header` — the row label is
|
||||
// rendered by [`described_overridable`]/[`described_labeled`], because the Overridden pill
|
||||
// must sit BETWEEN the label and the input, and a widget-embedded header allows nothing
|
||||
// between itself and its box.
|
||||
fn setting_combo(
|
||||
ctx: &Arc<AppCtx>,
|
||||
scope: &str,
|
||||
rev: (u64, &AsyncSetState<u64>),
|
||||
header: &str,
|
||||
names: Vec<String>,
|
||||
current: usize,
|
||||
apply: impl Fn(&mut Settings, usize) + 'static,
|
||||
@@ -460,7 +467,6 @@ fn setting_combo(
|
||||
let (rev, set_rev) = (rev.0, rev.1.clone());
|
||||
let max = names.len().saturating_sub(1);
|
||||
ComboBox::new(names)
|
||||
.header(header)
|
||||
.selected_index(current as i32)
|
||||
.on_selection_changed(move |i: i32| {
|
||||
commit(&ctx, &scope, (rev, &set_rev), |s| {
|
||||
@@ -476,19 +482,18 @@ fn presets<V>(table: &[(V, &str)], is_current: impl Fn(&V) -> bool) -> (Vec<Stri
|
||||
(names, current)
|
||||
}
|
||||
|
||||
/// A `ToggleSwitch` bound to one boolean settings field.
|
||||
/// A `ToggleSwitch` bound to one boolean settings field (label rendered by the row — see
|
||||
/// [`setting_combo`]'s note).
|
||||
fn setting_toggle(
|
||||
ctx: &Arc<AppCtx>,
|
||||
scope: &str,
|
||||
rev: (u64, &AsyncSetState<u64>),
|
||||
header: &str,
|
||||
on: bool,
|
||||
apply: impl Fn(&mut Settings, bool) + 'static,
|
||||
) -> ToggleSwitch {
|
||||
let (ctx, scope) = (ctx.clone(), scope.to_string());
|
||||
let (rev, set_rev) = (rev.0, rev.1.clone());
|
||||
ToggleSwitch::new(on)
|
||||
.header(header)
|
||||
.on_content("On")
|
||||
.off_content("Off")
|
||||
.on_toggled(move |v: bool| {
|
||||
@@ -503,7 +508,7 @@ fn setting_toggle(
|
||||
/// but a caption under it reads as a caption, which is how every Windows Settings page and
|
||||
/// the Apple client both do it. Width-capped for the same reason Apple caps at 360pt: a
|
||||
/// full-width caption runs into the control column and the whole cell reads as one block.
|
||||
/// [`described`], plus the override marker and reset a profile-scope row carries: the caption
|
||||
/// [`described_labeled`], plus the override marker and reset a profile-scope row carries: the caption
|
||||
/// says the profile changes this one, and the button is the only way back to inheriting.
|
||||
/// An override is recorded when a control's committed value differs from what it was
|
||||
/// SHOWING (`SettingsOverlay::absorb` diffs against the effective snapshot — see `commit`);
|
||||
@@ -514,70 +519,94 @@ fn described_overridable(
|
||||
rev: (u64, &AsyncSetState<u64>),
|
||||
scope: &str,
|
||||
field: &'static str,
|
||||
label: &str,
|
||||
overridden: bool,
|
||||
control: impl Into<Element>,
|
||||
caption: &str,
|
||||
) -> Element {
|
||||
if scope.is_empty() || !overridden {
|
||||
return described(control, caption);
|
||||
return described_labeled(label, control, caption);
|
||||
}
|
||||
// The override marker lives on the CONTROL's line, not in the caption: a small tinted
|
||||
// "Overridden" chip and the Reset button sit to the control's right, bottom-aligned to
|
||||
// its box. The caption below stays plain and identical in both states — appearing to
|
||||
// the SIDE means nothing moves and the description stays a description (the mixed
|
||||
// marker-plus-caption line read as neither).
|
||||
// The override marker is ONE capsule on its own line BETWEEN the control and its
|
||||
// caption (the reviewed placement): left-aligned like everything else in the card, so
|
||||
// every row's marker sits identically no matter how wide its control is. The capsule
|
||||
// holds the state ("Overridden") and the way out ("Reset") as segments of a single
|
||||
// tinted pill, the whole of which is the tap target; the caption below stays a plain
|
||||
// description in both states.
|
||||
let (rev, set_rev) = (rev.0, rev.1.clone());
|
||||
let scope = scope.to_string();
|
||||
vstack((
|
||||
let reset_pill = border(
|
||||
hstack((
|
||||
control.into(),
|
||||
pill("Overridden", Pill::Info)
|
||||
.vertical_alignment(VerticalAlignment::Bottom)
|
||||
.margin(edges(0.0, 0.0, 0.0, 5.0)),
|
||||
button("Reset")
|
||||
.icon(Symbol::Undo)
|
||||
.tooltip("Back to inheriting from Default settings")
|
||||
.vertical_alignment(VerticalAlignment::Bottom)
|
||||
.on_click(move || {
|
||||
let mut catalog = ProfilesFile::load();
|
||||
if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == scope) {
|
||||
p.overrides.clear(field);
|
||||
if let Err(e) = catalog.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "clearing an override");
|
||||
}
|
||||
}
|
||||
// The catalog changed behind the controls, and nothing the page reads
|
||||
// as state did — bump the revision so the row re-renders showing the
|
||||
// inherited value again.
|
||||
set_rev.call(rev + 1);
|
||||
}),
|
||||
text_block("Overridden")
|
||||
.font_size(11.0)
|
||||
.semibold()
|
||||
.foreground(ThemeRef::SystemAttention)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
// The seam between the state and the action.
|
||||
border(vstack(Vec::<Element>::new()).width(1.0).height(12.0))
|
||||
.background(ThemeRef::CardStroke)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
text_block("Reset")
|
||||
.font_size(11.0)
|
||||
.semibold()
|
||||
.foreground(ThemeRef::AccentText)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
))
|
||||
.spacing(8.0),
|
||||
text_block(caption)
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.wrap()
|
||||
.max_width(420.0)
|
||||
.horizontal_alignment(HorizontalAlignment::Left),
|
||||
.spacing(7.0),
|
||||
)
|
||||
.background(ThemeRef::SystemAttentionBackground)
|
||||
.border_brush(ThemeRef::CardStroke)
|
||||
.border_thickness(uniform(1.0))
|
||||
.corner_radius(10.0)
|
||||
.padding(edges(10.0, 3.0, 10.0, 3.0))
|
||||
.tooltip("Overridden by this profile \u{2014} Reset returns it to Default settings")
|
||||
.on_tapped(move || {
|
||||
let mut catalog = ProfilesFile::load();
|
||||
if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == scope) {
|
||||
p.overrides.clear(field);
|
||||
if let Err(e) = catalog.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "clearing an override");
|
||||
}
|
||||
}
|
||||
// The catalog changed behind the controls, and nothing the page reads as state
|
||||
// did — bump the revision so the row re-renders showing the inherited value.
|
||||
set_rev.call(rev + 1);
|
||||
});
|
||||
vstack((
|
||||
row_label(label),
|
||||
Element::from(reset_pill).horizontal_alignment(HorizontalAlignment::Left),
|
||||
control.into(),
|
||||
row_caption(caption),
|
||||
))
|
||||
.spacing(5.0)
|
||||
.spacing(6.0)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn described(control: impl Into<Element>, caption: &str) -> Element {
|
||||
vstack((
|
||||
control.into(),
|
||||
text_block(caption)
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.wrap()
|
||||
.max_width(420.0)
|
||||
// Stretch (the TextBlock default) CENTRES a MaxWidth-capped block in the leftover
|
||||
// width — the caption must be pinned left or it drifts away from its control.
|
||||
.horizontal_alignment(HorizontalAlignment::Left),
|
||||
))
|
||||
.spacing(5.0)
|
||||
.into()
|
||||
/// The row's label line — what the widgets' `.header` used to render, moved out so the
|
||||
/// Overridden pill can sit between label and input with ONE consistent gap everywhere.
|
||||
fn row_label(label: &str) -> Element {
|
||||
text_block(label)
|
||||
.horizontal_alignment(HorizontalAlignment::Left)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The row's caption line (shared styling for every variant).
|
||||
fn row_caption(caption: &str) -> Element {
|
||||
text_block(caption)
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.wrap()
|
||||
.max_width(420.0)
|
||||
.horizontal_alignment(HorizontalAlignment::Left)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The plain row with the row-owned label line: label, input, caption — the same skeleton
|
||||
/// as an overridable row minus the pill, so both kinds space out identically.
|
||||
fn described_labeled(label: &str, control: impl Into<Element>, caption: &str) -> Element {
|
||||
vstack((row_label(label), control.into(), row_caption(caption)))
|
||||
.spacing(6.0)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A settings sub-section heading. Deliberately NOT the shared [`section`] helper: that one
|
||||
@@ -653,7 +682,7 @@ pub(crate) fn settings_page(
|
||||
};
|
||||
let profile_mode = active.is_some();
|
||||
// Which rows this profile overrides — the marker + reset each of them carries. In the
|
||||
// defaults scope nothing is marked, and `described_overridable` degrades to `described`.
|
||||
// defaults scope nothing is marked, and `described_overridable` degrades to `described_labeled`.
|
||||
let over = OverrideFlags::of(active.as_ref());
|
||||
// Every control shows the EFFECTIVE value: the global underneath with this profile's
|
||||
// overrides on top, so a row the profile doesn't override reads as the live global.
|
||||
@@ -689,18 +718,10 @@ pub(crate) fn settings_page(
|
||||
};
|
||||
(names, i)
|
||||
};
|
||||
let res_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
"Resolution",
|
||||
res_names,
|
||||
res_i,
|
||||
|s, i| {
|
||||
s.match_window = i == 1;
|
||||
(s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] };
|
||||
},
|
||||
);
|
||||
let res_combo = setting_combo(ctx, scope, (rev, set_rev), res_names, res_i, |s, i| {
|
||||
s.match_window = i == 1;
|
||||
(s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] };
|
||||
});
|
||||
let (hz_names, hz_i) = {
|
||||
let names: Vec<String> = REFRESH
|
||||
.iter()
|
||||
@@ -715,17 +736,9 @@ pub(crate) fn settings_page(
|
||||
let i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
|
||||
(names, i)
|
||||
};
|
||||
let hz_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
"Refresh rate",
|
||||
hz_names,
|
||||
hz_i,
|
||||
|s, i| {
|
||||
s.refresh_hz = REFRESH[i];
|
||||
},
|
||||
);
|
||||
let hz_combo = setting_combo(ctx, scope, (rev, set_rev), hz_names, hz_i, |s, i| {
|
||||
s.refresh_hz = REFRESH[i];
|
||||
});
|
||||
let (scale_names, scale_i) = {
|
||||
let names: Vec<String> = RENDER_SCALES
|
||||
.iter()
|
||||
@@ -737,59 +750,29 @@ pub(crate) fn settings_page(
|
||||
.unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap());
|
||||
(names, i)
|
||||
};
|
||||
let scale_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
"Render scale",
|
||||
scale_names,
|
||||
scale_i,
|
||||
|s, i| {
|
||||
s.render_scale = RENDER_SCALES[i];
|
||||
},
|
||||
);
|
||||
let scale_combo = setting_combo(ctx, scope, (rev, set_rev), scale_names, scale_i, |s, i| {
|
||||
s.render_scale = RENDER_SCALES[i];
|
||||
});
|
||||
let (comp_names, comp_i) = presets(COMPOSITORS, |v| *v == s.compositor);
|
||||
let comp_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
"Host compositor",
|
||||
comp_names,
|
||||
comp_i,
|
||||
|s, i| {
|
||||
s.compositor = COMPOSITORS[i].0.to_string();
|
||||
},
|
||||
);
|
||||
let auto_wake_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
"Auto-wake on connect",
|
||||
s.auto_wake,
|
||||
|s, on| s.auto_wake = on,
|
||||
);
|
||||
let comp_combo = setting_combo(ctx, scope, (rev, set_rev), comp_names, comp_i, |s, i| {
|
||||
s.compositor = COMPOSITORS[i].0.to_string();
|
||||
});
|
||||
let auto_wake_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.auto_wake, |s, on| {
|
||||
s.auto_wake = on
|
||||
});
|
||||
let fullscreen_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
"Start streams fullscreen",
|
||||
s.fullscreen_on_stream,
|
||||
|s, on| s.fullscreen_on_stream = on,
|
||||
);
|
||||
|
||||
// --- Video -----------------------------------------------------------------------------
|
||||
let (dec_names, dec_i) = presets(DECODERS, |v| *v == s.decoder);
|
||||
let decoder_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
"Video decoder",
|
||||
dec_names,
|
||||
dec_i,
|
||||
|s, i| {
|
||||
s.decoder = DECODERS[i].0.to_string();
|
||||
},
|
||||
);
|
||||
let decoder_combo = setting_combo(ctx, scope, (rev, set_rev), dec_names, dec_i, |s, i| {
|
||||
s.decoder = DECODERS[i].0.to_string();
|
||||
});
|
||||
// GPU picker, only on a multi-GPU box (hybrid laptop, eGPU): which adapter decodes + presents.
|
||||
// Stored as the adapter description; empty = automatic (the window's monitor's adapter).
|
||||
let gpus = crate::gpu::adapter_names();
|
||||
@@ -801,34 +784,18 @@ pub(crate) fn settings_page(
|
||||
.position(|n| *n == s.adapter)
|
||||
.map_or(0, |i| i + 1);
|
||||
let gpus = gpus.clone();
|
||||
setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
"GPU",
|
||||
names,
|
||||
current,
|
||||
move |s, i| {
|
||||
s.adapter = if i == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
gpus[i - 1].clone()
|
||||
};
|
||||
},
|
||||
)
|
||||
setting_combo(ctx, scope, (rev, set_rev), names, current, move |s, i| {
|
||||
s.adapter = if i == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
gpus[i - 1].clone()
|
||||
};
|
||||
})
|
||||
});
|
||||
let (codec_names, codec_i) = presets(CODECS, |v| *v == s.codec);
|
||||
let codec_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
"Video codec",
|
||||
codec_names,
|
||||
codec_i,
|
||||
|s, i| {
|
||||
s.codec = CODECS[i].0.to_string();
|
||||
},
|
||||
);
|
||||
let codec_combo = setting_combo(ctx, scope, (rev, set_rev), codec_names, codec_i, |s, i| {
|
||||
s.codec = CODECS[i].0.to_string();
|
||||
});
|
||||
// Free-form Mb/s (0 = host default) instead of presets, so a speed-test recommendation
|
||||
// round-trips exactly. Through `commit` like every other row: writing `ctx.settings`
|
||||
// directly here would edit the GLOBAL defaults from inside a profile scope (and record
|
||||
@@ -836,7 +803,6 @@ pub(crate) fn settings_page(
|
||||
let bitrate_box = {
|
||||
let (ctx, scope, set_rev) = (ctx.clone(), scope.to_string(), set_rev.clone());
|
||||
NumberBox::new(f64::from(s.bitrate_kbps) / 1000.0)
|
||||
.header("Bitrate (Mb/s, 0 = automatic)")
|
||||
.range(0.0, 3000.0)
|
||||
.on_value_changed(move |v: f64| {
|
||||
commit(&ctx, &scope, (rev, &set_rev), |s| {
|
||||
@@ -844,22 +810,12 @@ pub(crate) fn settings_page(
|
||||
});
|
||||
})
|
||||
};
|
||||
let hdr_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
"HDR (10-bit, BT.2020 PQ)",
|
||||
s.hdr_enabled,
|
||||
|s, on| s.hdr_enabled = on,
|
||||
);
|
||||
let chroma_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
"Full chroma (4:4:4)",
|
||||
s.enable_444,
|
||||
|s, on| s.enable_444 = on,
|
||||
);
|
||||
let hdr_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.hdr_enabled, |s, on| {
|
||||
s.hdr_enabled = on
|
||||
});
|
||||
let chroma_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.enable_444, |s, on| {
|
||||
s.enable_444 = on
|
||||
});
|
||||
|
||||
// --- Input -----------------------------------------------------------------------------
|
||||
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
|
||||
@@ -888,7 +844,6 @@ pub(crate) fn settings_page(
|
||||
let ctx2 = ctx.clone();
|
||||
let keys: Vec<String> = pads.iter().map(|p| p.key.clone()).collect();
|
||||
ComboBox::new(fwd_names)
|
||||
.header("Forwarded controller")
|
||||
.selected_index(fwd_i as i32)
|
||||
.on_selection_changed(move |i: i32| {
|
||||
let sel = i.max(0) as usize;
|
||||
@@ -908,105 +863,47 @@ pub(crate) fn settings_page(
|
||||
let (pad_names, pad_i) = presets(GAMEPADS, |v| {
|
||||
GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad)
|
||||
});
|
||||
let pad_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
"Gamepad type",
|
||||
pad_names,
|
||||
pad_i,
|
||||
|s, i| {
|
||||
s.gamepad = GAMEPADS[i].0.to_string();
|
||||
},
|
||||
);
|
||||
let pad_combo = setting_combo(ctx, scope, (rev, set_rev), pad_names, pad_i, |s, i| {
|
||||
s.gamepad = GAMEPADS[i].0.to_string();
|
||||
});
|
||||
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
|
||||
let touch_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
"Touch input",
|
||||
touch_names,
|
||||
touch_i,
|
||||
|s, i| {
|
||||
s.touch_mode = TOUCH_MODES[i].0.to_string();
|
||||
},
|
||||
);
|
||||
let touch_combo = setting_combo(ctx, scope, (rev, set_rev), touch_names, touch_i, |s, i| {
|
||||
s.touch_mode = TOUCH_MODES[i].0.to_string();
|
||||
});
|
||||
let (mouse_names, mouse_i) = presets(MOUSE_MODES, |v| *v == s.mouse_mode);
|
||||
let mouse_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
"Mouse input",
|
||||
mouse_names,
|
||||
mouse_i,
|
||||
|s, i| {
|
||||
s.mouse_mode = MOUSE_MODES[i].0.to_string();
|
||||
},
|
||||
);
|
||||
let invert_scroll_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
"Invert scroll direction",
|
||||
s.invert_scroll,
|
||||
|s, on| s.invert_scroll = on,
|
||||
);
|
||||
let shortcuts_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
"Capture system shortcuts (Alt+Tab, Win, \u{2026})",
|
||||
s.inhibit_shortcuts,
|
||||
|s, on| s.inhibit_shortcuts = on,
|
||||
);
|
||||
let mouse_combo = setting_combo(ctx, scope, (rev, set_rev), mouse_names, mouse_i, |s, i| {
|
||||
s.mouse_mode = MOUSE_MODES[i].0.to_string();
|
||||
});
|
||||
let invert_scroll_toggle =
|
||||
setting_toggle(ctx, scope, (rev, set_rev), s.invert_scroll, |s, on| {
|
||||
s.invert_scroll = on
|
||||
});
|
||||
let shortcuts_toggle =
|
||||
setting_toggle(ctx, scope, (rev, set_rev), s.inhibit_shortcuts, |s, on| {
|
||||
s.inhibit_shortcuts = on
|
||||
});
|
||||
|
||||
// --- Audio -----------------------------------------------------------------------------
|
||||
let (ac_names, ac_i) = presets(AUDIO_CHANNELS, |v| *v == s.audio_channels);
|
||||
let channels_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
"Audio channels",
|
||||
ac_names,
|
||||
ac_i,
|
||||
|s, i| {
|
||||
s.audio_channels = AUDIO_CHANNELS[i].0;
|
||||
},
|
||||
);
|
||||
let mic_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
"Stream microphone to the host",
|
||||
s.mic_enabled,
|
||||
|s, on| s.mic_enabled = on,
|
||||
);
|
||||
let channels_combo = setting_combo(ctx, scope, (rev, set_rev), ac_names, ac_i, |s, i| {
|
||||
s.audio_channels = AUDIO_CHANNELS[i].0;
|
||||
});
|
||||
let mic_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.mic_enabled, |s, on| {
|
||||
s.mic_enabled = on
|
||||
});
|
||||
|
||||
let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity());
|
||||
let hud_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
"Stats overlay (HUD)",
|
||||
hud_names,
|
||||
hud_i,
|
||||
|s, i| {
|
||||
s.set_stats_verbosity(STATS_TIERS[i].0);
|
||||
},
|
||||
);
|
||||
let hud_combo = setting_combo(ctx, scope, (rev, set_rev), hud_names, hud_i, |s, i| {
|
||||
s.set_stats_verbosity(STATS_TIERS[i].0);
|
||||
});
|
||||
|
||||
let licenses_button = {
|
||||
let ss = set_screen.clone();
|
||||
button("Third-party licenses").on_click(move || ss.call(Screen::Licenses))
|
||||
};
|
||||
let library_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
(rev, set_rev),
|
||||
"Show game library (experimental)",
|
||||
s.library_enabled,
|
||||
|s, on| s.library_enabled = on,
|
||||
);
|
||||
let library_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.library_enabled, |s, on| {
|
||||
s.library_enabled = on
|
||||
});
|
||||
// App identity + version at the top of the About card (the WinUI Settings convention; the About
|
||||
// screen previously showed no version at all). CARGO_PKG_VERSION is the workspace version, baked
|
||||
// in at compile time.
|
||||
@@ -1031,6 +928,7 @@ pub(crate) fn settings_page(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"resolution",
|
||||
"Resolution",
|
||||
over.resolution,
|
||||
res_combo,
|
||||
"The host drives a real virtual output at exactly this size \u{2014} true \
|
||||
@@ -1042,6 +940,7 @@ pub(crate) fn settings_page(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"refresh_hz",
|
||||
"Refresh rate",
|
||||
over.refresh_hz,
|
||||
hz_combo,
|
||||
"\u{201C}Native\u{201D} resolves to this display\u{2019}s refresh rate at \
|
||||
@@ -1057,6 +956,7 @@ pub(crate) fn settings_page(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"render_scale",
|
||||
"Render scale",
|
||||
over.render_scale,
|
||||
scale_combo,
|
||||
"Above native supersamples for sharpness; below renders lighter on the \
|
||||
@@ -1066,6 +966,7 @@ pub(crate) fn settings_page(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"bitrate_kbps",
|
||||
"Bitrate (Mb/s, 0 = automatic)",
|
||||
over.bitrate_kbps,
|
||||
bitrate_box,
|
||||
"0 lets the host decide (its default, clamped to what it supports). A \
|
||||
@@ -1075,6 +976,7 @@ pub(crate) fn settings_page(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"codec",
|
||||
"Video codec",
|
||||
over.codec,
|
||||
codec_combo,
|
||||
"A preference \u{2014} the host falls back if it can\u{2019}t encode it. \
|
||||
@@ -1086,6 +988,7 @@ pub(crate) fn settings_page(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"hdr_enabled",
|
||||
"HDR (10-bit, BT.2020 PQ)",
|
||||
over.hdr_enabled,
|
||||
hdr_toggle,
|
||||
"HDR10, when the host has HDR content and this display supports it. \
|
||||
@@ -1097,6 +1000,7 @@ pub(crate) fn settings_page(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"enable_444",
|
||||
"Full chroma (4:4:4)",
|
||||
over.enable_444,
|
||||
chroma_toggle,
|
||||
"Full-colour video: crisp small text and thin lines, at more \
|
||||
@@ -1111,14 +1015,16 @@ pub(crate) fn settings_page(
|
||||
if profile_mode {
|
||||
Vec::new()
|
||||
} else {
|
||||
let mut fields = vec![described(
|
||||
let mut fields = vec![described_labeled(
|
||||
"Video decoder",
|
||||
decoder_combo,
|
||||
"Automatic picks the hardware path this GPU does best \u{2014} Direct3D \
|
||||
11 on Intel, Vulkan Video on NVIDIA and AMD \u{2014} and falls back to \
|
||||
the CPU. Change it only when debugging.",
|
||||
)];
|
||||
if let Some(c) = gpu_combo {
|
||||
fields.push(described(
|
||||
fields.push(described_labeled(
|
||||
"GPU",
|
||||
c,
|
||||
"Which adapter decodes and presents the stream. Automatic uses the \
|
||||
GPU driving this window\u{2019}s display.",
|
||||
@@ -1134,6 +1040,7 @@ pub(crate) fn settings_page(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"compositor",
|
||||
"Host compositor",
|
||||
over.compositor,
|
||||
comp_combo,
|
||||
"The backend the host uses for its virtual output (Linux hosts only). A \
|
||||
@@ -1152,6 +1059,7 @@ pub(crate) fn settings_page(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"touch_mode",
|
||||
"Touch input",
|
||||
over.touch_mode,
|
||||
touch_combo,
|
||||
"How a touchscreen drives the host: Trackpad moves the host cursor like a \
|
||||
@@ -1167,6 +1075,7 @@ pub(crate) fn settings_page(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"mouse_mode",
|
||||
"Mouse input",
|
||||
over.mouse_mode,
|
||||
mouse_combo,
|
||||
"Capture locks the pointer to the stream and sends relative motion — \
|
||||
@@ -1178,6 +1087,7 @@ pub(crate) fn settings_page(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"inhibit_shortcuts",
|
||||
"Capture system shortcuts (Alt+Tab, Win, \u{2026})",
|
||||
over.inhibit_shortcuts,
|
||||
shortcuts_toggle,
|
||||
"Alt+Tab, the Windows key and friends reach the host while the stream \
|
||||
@@ -1187,6 +1097,7 @@ pub(crate) fn settings_page(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"invert_scroll",
|
||||
"Invert scroll direction",
|
||||
over.invert_scroll,
|
||||
invert_scroll_toggle,
|
||||
"Reverses the wheel and trackpad scroll direction sent to the host.",
|
||||
@@ -1206,7 +1117,8 @@ pub(crate) fn settings_page(
|
||||
// Which physical pad this device forwards is a device fact (tier G), so it
|
||||
// renders only in the defaults scope; the EMULATED type below is profileable.
|
||||
(!profile_mode).then(|| {
|
||||
described(
|
||||
described_labeled(
|
||||
"Forwarded controller",
|
||||
forward_combo,
|
||||
"Every connected controller is forwarded, each as its own player. Pick \
|
||||
one to force single-player \u{2014} only it reaches the host.",
|
||||
@@ -1216,6 +1128,7 @@ pub(crate) fn settings_page(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"gamepad",
|
||||
"Gamepad type",
|
||||
over.gamepad,
|
||||
pad_combo,
|
||||
"The virtual pad created on the host. Automatic matches your controller \
|
||||
@@ -1238,6 +1151,7 @@ pub(crate) fn settings_page(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"audio_channels",
|
||||
"Audio channels",
|
||||
over.audio_channels,
|
||||
channels_combo,
|
||||
"The speaker layout requested from the host. It downmixes if its own \
|
||||
@@ -1247,6 +1161,7 @@ pub(crate) fn settings_page(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"mic_enabled",
|
||||
"Stream microphone to the host",
|
||||
over.mic_enabled,
|
||||
mic_toggle,
|
||||
"This device\u{2019}s microphone feeds the host\u{2019}s virtual mic.",
|
||||
@@ -1271,6 +1186,7 @@ pub(crate) fn settings_page(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"fullscreen_on_stream",
|
||||
"Start streams fullscreen",
|
||||
over.fullscreen_on_stream,
|
||||
fullscreen_toggle,
|
||||
"Go fullscreen when a session starts; F11 or Alt+Enter switches back \
|
||||
@@ -1280,7 +1196,8 @@ pub(crate) fn settings_page(
|
||||
// Auto-wake is about this host and this network, not about "Game vs Work" —
|
||||
// it stays global in v1 (design §3, tier H/G).
|
||||
.chain((!profile_mode).then(|| {
|
||||
described(
|
||||
described_labeled(
|
||||
"Auto-wake on connect",
|
||||
auto_wake_toggle,
|
||||
"Connecting to a saved host that\u{2019}s offline sends Wake-on-LAN and \
|
||||
waits for it to boot. Turn off if hosts behind a VPN look offline when \
|
||||
@@ -1296,6 +1213,7 @@ pub(crate) fn settings_page(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"stats_verbosity",
|
||||
"Stats overlay (HUD)",
|
||||
over.stats_verbosity,
|
||||
hud_combo,
|
||||
"Live session stats in a corner overlay \u{2014} Compact is a one-line pill, \
|
||||
@@ -1310,7 +1228,8 @@ pub(crate) fn settings_page(
|
||||
if profile_mode {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![described(
|
||||
vec![described_labeled(
|
||||
"Show game library (experimental)",
|
||||
library_toggle,
|
||||
"Adds \u{201C}Browse library\u{2026}\u{201D} to paired hosts \u{2014} list \
|
||||
their Steam and custom games and launch one directly. No extra host setup.",
|
||||
@@ -1380,6 +1299,8 @@ pub(crate) fn settings_page(
|
||||
ComboBox::new(names.clone())
|
||||
.selected_index(scope_i as i32)
|
||||
.min_width(220.0)
|
||||
// The bar's controls share one height, or the row reads ragged.
|
||||
.height(36.0)
|
||||
.on_selection_changed({
|
||||
let (set_scope, set_edit, ids) =
|
||||
(set_scope.clone(), set_edit.clone(), ids.clone());
|
||||
@@ -1430,11 +1351,30 @@ pub(crate) fn settings_page(
|
||||
.with_key(format!("{scope}\u{1}{}", scope_names.join("\u{1}")))
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
];
|
||||
// The profile's colour, right where the choice is made (a ComboBox item is a plain
|
||||
// string in this toolkit, so the chip cannot ride inside the dropdown).
|
||||
if let Some(c) = active
|
||||
.as_ref()
|
||||
.and_then(|p| p.accent.as_deref())
|
||||
.and_then(hex_color)
|
||||
{
|
||||
row.push(
|
||||
border(vstack(Vec::<Element>::new()))
|
||||
.width(12.0)
|
||||
.height(12.0)
|
||||
.background(c)
|
||||
.corner_radius(6.0)
|
||||
.vertical_alignment(VerticalAlignment::Center)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if profile_mode {
|
||||
let set_edit = set_edit.clone();
|
||||
row.push(
|
||||
button("Edit profile\u{2026}")
|
||||
.icon(Symbol::Edit)
|
||||
// Matches the combo — the bar's controls share one height.
|
||||
.height(36.0)
|
||||
.on_click(move || set_edit.call(true))
|
||||
.vertical_alignment(VerticalAlignment::Center)
|
||||
.into(),
|
||||
|
||||
Reference in New Issue
Block a user