feat(client/windows): overridden rows are marked and resettable, and the speed test targets the right layer
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
The two remaining pieces of P1 on Windows, both closing the same kind of gap the Linux client closed earlier. Overridden rows in profile scope now say so — an accent-marked caption — and carry the only way back to inheriting. Without it a profile is a one-way door here too, because overrides are recorded on touch and never inferred from comparing values. Reset calls the shared `SettingsOverlay::clear`, so the list of what can be cleared stays in one place across both shells. It also bumps a revision counter: the catalog changes behind the controls and nothing the page reads as state does, and root state compares before re-rendering, so without the bump the row would keep showing the value it just dropped. The speed test writes to the layer the TESTED host reads bitrate from, not the global — measuring one host used to re-tune every other one. Resolved exactly the way a connect resolves it: the one-off the test was started with (a pinned tile carries one), else the host's binding, else the global; a dangling binding falls back. The button names its target, so it says where it will write before you press it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -255,6 +255,10 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
// The profile a Delete… click is asking about; `Some` renders the confirmation. Root state
|
||||
// because this page stays hook-free (its handlers are wired in the reactor backend).
|
||||
let (settings_delete, set_settings_delete) = cx.use_async_state(Option::<String>::None);
|
||||
// Bumped when a settings edit changes what the page should SHOW without changing any state
|
||||
// it already reads — resetting an override, which rewrites the catalog behind the controls.
|
||||
// Root state comparison makes same-value calls free, so a counter is what forces the pass.
|
||||
let (settings_rev, set_settings_rev) = cx.use_async_state(0u64);
|
||||
// Connected-controller count, mirrored from the gamepad service by a poll thread
|
||||
// (thread-driven state must be root state — see the module docs). Drives the hosts
|
||||
// page's "Open console UI" hint; the compare in `call` makes the steady state free.
|
||||
@@ -505,6 +509,8 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
&set_settings_scope,
|
||||
&settings_delete,
|
||||
&set_settings_delete,
|
||||
settings_rev,
|
||||
&set_settings_rev,
|
||||
nav_progress,
|
||||
),
|
||||
Screen::Licenses => licenses::licenses_page(&set_screen),
|
||||
|
||||
@@ -204,6 +204,56 @@ fn commit(ctx: &Arc<AppCtx>, scope: &str, edit: impl FnOnce(&mut Settings)) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which tier-P rows the profile in scope overrides. Plain bools rather than a lookup so the
|
||||
/// call sites read as `over.codec` — the row and its flag stay visibly paired.
|
||||
#[derive(Default)]
|
||||
struct OverrideFlags {
|
||||
resolution: bool,
|
||||
refresh_hz: bool,
|
||||
render_scale: bool,
|
||||
bitrate_kbps: bool,
|
||||
codec: bool,
|
||||
hdr_enabled: bool,
|
||||
compositor: bool,
|
||||
audio_channels: bool,
|
||||
mic_enabled: bool,
|
||||
touch_mode: bool,
|
||||
mouse_mode: bool,
|
||||
invert_scroll: bool,
|
||||
inhibit_shortcuts: bool,
|
||||
gamepad: bool,
|
||||
stats_verbosity: bool,
|
||||
fullscreen_on_stream: bool,
|
||||
}
|
||||
|
||||
impl OverrideFlags {
|
||||
fn of(profile: Option<&StreamProfile>) -> OverrideFlags {
|
||||
let Some(o) = profile.map(|p| &p.overrides) else {
|
||||
return OverrideFlags::default();
|
||||
};
|
||||
OverrideFlags {
|
||||
// One control drives the width/height/match-window tri-state, so any of the three
|
||||
// marks the row.
|
||||
resolution: o.width.is_some() || o.height.is_some() || o.match_window.is_some(),
|
||||
refresh_hz: o.refresh_hz.is_some(),
|
||||
render_scale: o.render_scale.is_some(),
|
||||
bitrate_kbps: o.bitrate_kbps.is_some(),
|
||||
codec: o.codec.is_some(),
|
||||
hdr_enabled: o.hdr_enabled.is_some(),
|
||||
compositor: o.compositor.is_some(),
|
||||
audio_channels: o.audio_channels.is_some(),
|
||||
mic_enabled: o.mic_enabled.is_some(),
|
||||
touch_mode: o.touch_mode.is_some(),
|
||||
mouse_mode: o.mouse_mode.is_some(),
|
||||
invert_scroll: o.invert_scroll.is_some(),
|
||||
inhibit_shortcuts: o.inhibit_shortcuts.is_some(),
|
||||
gamepad: o.gamepad.is_some(),
|
||||
stats_verbosity: o.stats_verbosity.is_some(),
|
||||
fullscreen_on_stream: o.fullscreen_on_stream.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layer the settings screen is editing, resolved for display: `None` = the defaults.
|
||||
fn active_profile(scope: &str) -> Option<StreamProfile> {
|
||||
(!scope.is_empty())
|
||||
@@ -261,6 +311,52 @@ 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
|
||||
/// says the profile changes this one, and the button is the only way back to inheriting —
|
||||
/// overrides are recorded on touch and never inferred from a value comparison, so "not
|
||||
/// overridden" needs an explicit act.
|
||||
fn described_overridable(
|
||||
rev: (u64, &AsyncSetState<u64>),
|
||||
scope: &str,
|
||||
field: &'static str,
|
||||
overridden: bool,
|
||||
control: impl Into<Element>,
|
||||
caption: &str,
|
||||
) -> Element {
|
||||
if scope.is_empty() || !overridden {
|
||||
return described(control, caption);
|
||||
}
|
||||
let (rev, set_rev) = (rev.0, rev.1.clone());
|
||||
let scope = scope.to_string();
|
||||
vstack((
|
||||
control.into(),
|
||||
hstack((
|
||||
text_block(format!("\u{25cf} Overridden here \u{00b7} {caption}"))
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::AccentText)
|
||||
.wrap()
|
||||
.max_width(360.0)
|
||||
.horizontal_alignment(HorizontalAlignment::Left),
|
||||
button("Reset").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);
|
||||
}),
|
||||
))
|
||||
.spacing(8.0),
|
||||
))
|
||||
.spacing(5.0)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn described(control: impl Into<Element>, caption: &str) -> Element {
|
||||
vstack((
|
||||
control.into(),
|
||||
@@ -329,6 +425,8 @@ pub(crate) fn settings_page(
|
||||
set_scope: &AsyncSetState<String>,
|
||||
delete_pending: &Option<String>,
|
||||
set_delete: &AsyncSetState<Option<String>>,
|
||||
rev: u64,
|
||||
set_rev: &AsyncSetState<u64>,
|
||||
progress: f64,
|
||||
) -> Element {
|
||||
// The layer being edited. A scope pointing at a deleted profile degrades to the defaults,
|
||||
@@ -339,8 +437,12 @@ pub(crate) fn settings_page(
|
||||
None => "",
|
||||
};
|
||||
let profile_mode = active.is_some();
|
||||
// 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.
|
||||
// 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`.
|
||||
let over = OverrideFlags::of(active.as_ref());
|
||||
let _ = rev; // read via the closures below; the value itself only forces the re-render
|
||||
// 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.
|
||||
let s = {
|
||||
let base = ctx.settings.lock().unwrap().clone();
|
||||
match &active {
|
||||
@@ -602,14 +704,22 @@ pub(crate) fn settings_page(
|
||||
let mut out = group(
|
||||
Some("Resolution"),
|
||||
vec![
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"resolution",
|
||||
over.resolution,
|
||||
res_combo,
|
||||
"The host drives a real virtual output at exactly this size \u{2014} true \
|
||||
pixels, no scaling. \u{201C}Native display\u{201D} follows the monitor this \
|
||||
window is on; \u{201C}Match window\u{201D} keeps the picture pixel-exact \
|
||||
(1:1) through every resize.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"refresh_hz",
|
||||
over.refresh_hz,
|
||||
hz_combo,
|
||||
"\u{201C}Native\u{201D} resolves to this display\u{2019}s refresh rate at \
|
||||
connect.",
|
||||
@@ -620,24 +730,40 @@ pub(crate) fn settings_page(
|
||||
out.extend(group(
|
||||
Some("Quality"),
|
||||
vec![
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"render_scale",
|
||||
over.render_scale,
|
||||
scale_combo,
|
||||
"Above native supersamples for sharpness; below renders lighter on the \
|
||||
host and the link. This device resamples the result to the window.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"bitrate_kbps",
|
||||
over.bitrate_kbps,
|
||||
bitrate_box,
|
||||
"0 lets the host decide (its default, clamped to what it supports). A \
|
||||
host card\u{2019}s context menu has a network speed test.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"codec",
|
||||
over.codec,
|
||||
codec_combo,
|
||||
"A preference \u{2014} the host falls back if it can\u{2019}t encode it. \
|
||||
PyroWave is the low-latency wavelet codec for a WIRED link: it trades \
|
||||
bitrate (hundreds of Mb/s) for near-zero decode time, so it wants \
|
||||
gigabit Ethernet.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"hdr_enabled",
|
||||
over.hdr_enabled,
|
||||
hdr_toggle,
|
||||
"HDR10, when the host has HDR content and this display supports it. \
|
||||
HEVC only; otherwise the stream stays SDR.",
|
||||
@@ -670,7 +796,11 @@ pub(crate) fn settings_page(
|
||||
));
|
||||
out.extend(group(
|
||||
Some("Host output"),
|
||||
vec![described(
|
||||
vec![described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"compositor",
|
||||
over.compositor,
|
||||
comp_combo,
|
||||
"The backend the host uses for its virtual output (Linux hosts only). A \
|
||||
specific choice falls back to auto-detection when that backend \
|
||||
@@ -684,7 +814,11 @@ pub(crate) fn settings_page(
|
||||
"input" => {
|
||||
let mut out = group(
|
||||
Some("Touch & pointer"),
|
||||
vec![described(
|
||||
vec![described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"touch_mode",
|
||||
over.touch_mode,
|
||||
touch_combo,
|
||||
"How a touchscreen drives the host: Trackpad moves the host cursor like a \
|
||||
laptop trackpad (tap to click), Direct pointer jumps the cursor to wherever \
|
||||
@@ -695,19 +829,31 @@ pub(crate) fn settings_page(
|
||||
out.extend(group(
|
||||
Some("Keyboard & mouse"),
|
||||
vec![
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"mouse_mode",
|
||||
over.mouse_mode,
|
||||
mouse_combo,
|
||||
"Capture locks the pointer to the stream and sends relative motion — \
|
||||
best for games. Desktop leaves the pointer free to enter and leave \
|
||||
the stream and sends absolute positions — best for remote desktop \
|
||||
work. Ctrl+Alt+Shift+M switches live.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"inhibit_shortcuts",
|
||||
over.inhibit_shortcuts,
|
||||
shortcuts_toggle,
|
||||
"Alt+Tab, the Windows key and friends reach the host while the stream \
|
||||
has input captured. Off, they act on this machine instead.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"invert_scroll",
|
||||
over.invert_scroll,
|
||||
invert_scroll_toggle,
|
||||
"Reverses the wheel and trackpad scroll direction sent to the host.",
|
||||
),
|
||||
@@ -732,7 +878,11 @@ pub(crate) fn settings_page(
|
||||
one to force single-player \u{2014} only it reaches the host.",
|
||||
)
|
||||
}),
|
||||
Some(described(
|
||||
Some(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"gamepad",
|
||||
over.gamepad,
|
||||
pad_combo,
|
||||
"The virtual pad created on the host. Automatic matches your controller \
|
||||
\u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \
|
||||
@@ -750,12 +900,20 @@ pub(crate) fn settings_page(
|
||||
group(
|
||||
None,
|
||||
vec![
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"audio_channels",
|
||||
over.audio_channels,
|
||||
channels_combo,
|
||||
"The speaker layout requested from the host. It downmixes if its own \
|
||||
output has fewer channels.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"mic_enabled",
|
||||
over.mic_enabled,
|
||||
mic_toggle,
|
||||
"This device\u{2019}s microphone feeds the host\u{2019}s virtual mic.",
|
||||
),
|
||||
@@ -775,7 +933,11 @@ pub(crate) fn settings_page(
|
||||
_ => {
|
||||
let mut out = group(
|
||||
Some("Session"),
|
||||
vec![described(
|
||||
vec![described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"fullscreen_on_stream",
|
||||
over.fullscreen_on_stream,
|
||||
fullscreen_toggle,
|
||||
"Go fullscreen when a session starts; F11 or Alt+Enter switches back \
|
||||
live.",
|
||||
@@ -796,7 +958,11 @@ pub(crate) fn settings_page(
|
||||
);
|
||||
out.extend(group(
|
||||
Some("Statistics"),
|
||||
vec![described(
|
||||
vec![described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"stats_verbosity",
|
||||
over.stats_verbosity,
|
||||
hud_combo,
|
||||
"Live session stats in a corner overlay \u{2014} Compact is a one-line pill, \
|
||||
Detailed adds the latency stage breakdown. Ctrl+Alt+Shift+S cycles the \
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
use super::style::*;
|
||||
use super::{Screen, Svc};
|
||||
use crate::probe::run_speed_probe;
|
||||
use crate::trust::KnownHosts;
|
||||
use pf_client_core::profiles::ProfilesFile;
|
||||
use windows_reactor::*;
|
||||
|
||||
/// Speed-test lifecycle. Held as ROOT state (the probe worker completes it via
|
||||
@@ -122,17 +124,54 @@ pub(crate) fn speed_page(props: &SpeedProps, cx: &mut RenderCx) -> Element {
|
||||
recommended_kbps,
|
||||
} => {
|
||||
let recommended_mbps = f64::from(*recommended_kbps) / 1000.0;
|
||||
// A measured bitrate belongs in the layer the TESTED host actually reads it from
|
||||
// (design/client-settings-profiles.md §5.3) — writing the global here is what made
|
||||
// measuring one host re-tune every other one. Resolved the way a connect resolves
|
||||
// it: the one-off this test was started with, else the host's binding.
|
||||
let target = ctx.shared.target.lock().unwrap().clone();
|
||||
let bound = KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == target.addr && h.port == target.port)
|
||||
.and_then(|h| h.profile_id.clone());
|
||||
let profile = match target.profile.as_deref() {
|
||||
Some("") => None,
|
||||
Some(id) => Some(id.to_string()),
|
||||
None => bound,
|
||||
}
|
||||
.and_then(|reference| ProfilesFile::load().resolve(&reference).0.cloned());
|
||||
let apply_btn = {
|
||||
let (ctx, ss, kbps) = (ctx.clone(), set_screen.clone(), *recommended_kbps);
|
||||
button(format!("Use {recommended_mbps:.0} Mb/s"))
|
||||
.accent()
|
||||
.icon(Symbol::Accept)
|
||||
.on_click(move || {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
s.bitrate_kbps = kbps;
|
||||
s.save();
|
||||
ss.call(Screen::Hosts);
|
||||
})
|
||||
let profile = profile.clone();
|
||||
button(match &profile {
|
||||
Some(p) => format!(
|
||||
"Set {recommended_mbps:.0} Mb/s in \u{201c}{}\u{201d}",
|
||||
p.name
|
||||
),
|
||||
None => format!("Use {recommended_mbps:.0} Mb/s"),
|
||||
})
|
||||
.accent()
|
||||
.icon(Symbol::Accept)
|
||||
.on_click(move || {
|
||||
match &profile {
|
||||
Some(p) => {
|
||||
let mut catalog = ProfilesFile::load();
|
||||
if let Some(slot) = catalog.profiles.iter_mut().find(|x| x.id == p.id) {
|
||||
slot.overrides.bitrate_kbps = Some(kbps);
|
||||
if let Err(e) = catalog.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"),
|
||||
"saving the measured bitrate");
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
s.bitrate_kbps = kbps;
|
||||
s.save();
|
||||
}
|
||||
}
|
||||
ss.call(Screen::Hosts);
|
||||
})
|
||||
};
|
||||
let results = card(
|
||||
vstack((
|
||||
|
||||
Reference in New Issue
Block a user