fix(client/windows): the switcher footer survives the pane clip instead of guessing at it

Binding the footer's form to the WINDOW width was wrong twice over: the pane's open state
belongs to the user (the hamburger), so a wide window with a closed pane still clipped the
combo, and a narrow window with the pane opened still showed the compact disc. Reactor
exposes no pane-opened/closed event — so the footer now needs no pane state at all: its
LEADING 48 epx is the scope's monogram disc (profile accent when set) and the combo + Edit
button sit after it, so the compact rail's clip line falls exactly between them. Closed
pane → a clean disc (which opens the profile sheet); open pane → disc + combo. The same
trick NavigationViewItems use, at every width and in every mode — the display mode goes
back to Auto, the width threshold and the root window-size read are gone.

The sheet is now self-sufficient (title "Profiles"): it always carries the scope combo —
whether the pane's own combo is visible depends on state this page cannot observe — plus
the name/colour/duplicate/delete rows whenever a profile is in scope, so the disc is a
complete profile entry point from the bare rail.

UIA-verified: scope pick from the pane combo, Overridden marker 5 -> 6 in place, the
sheet opens from Edit profile… and closes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 18:31:49 +02:00
co-authored by Claude Fable 5
parent 4b5bca93a0
commit 09d1691af4
2 changed files with 47 additions and 58 deletions
-6
View File
@@ -293,11 +293,6 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
// Whether the Edit-profile modal is up. Root state for the reactor-backend-handler reason
// above; guarded in the page so it only renders while a profile is actually in scope.
let (settings_edit, set_settings_edit) = cx.use_async_state(false);
// Window size, read at ROOT: the settings screen places its scope switcher by the same
// width threshold WinUI's NavigationView uses to collapse its pane (reactor exposes no
// pane-opened/closed event). Registering the size here re-renders the tree on resize,
// which the per-screen `use_inner_size` readers (hosts, library) already caused anyway.
let window = cx.use_inner_size();
// Bumped when a settings edit changes what the page should SHOW without changing any state
// it already reads — ANY edit through `settings::commit` (creating an override must surface
// its marker as immediately as resetting one clears it), a reset, a profile colour change.
@@ -650,7 +645,6 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
settings_rev,
&set_settings_rev,
nav_progress,
window.width,
),
Screen::Licenses => licenses::licenses_page(&set_screen),
Screen::Help => help::help_page(&set_screen),
+47 -52
View File
@@ -618,7 +618,6 @@ pub(crate) fn settings_page(
rev: u64,
set_rev: &AsyncSetState<u64>,
progress: f64,
window_width: f64,
) -> Element {
// The layer being edited. A scope pointing at a deleted profile degrades to the defaults,
// the same rule a dangling host binding follows.
@@ -1319,15 +1318,15 @@ pub(crate) fn settings_page(
// the whole surface read as clutter. Editing the profile itself (name, colour, delete)
// is a modal behind the "Edit profile…" button, not inline chrome.
//
// And it stays in the nav at EVERY width. WinUI's Auto mode collapses the pane below
// 1008 epx (under the app's own 1000-wide default window) and CLIPS pane-footer content
// to the rail — so the page forces the mode instead: `Left` (always expanded, 280 long)
// while the content column still gets a workable width, `LeftCompact` (the 48 rail,
// footer still visible) below that. In the rail the footer swaps to a fitting compact
// form — a monogram disc for the scope, which opens the profile sheet — rather than a
// clipped ComboBox. Reactor exposes no pane-opened/closed event, so both the mode and
// the footer form ride this one width threshold.
let pane_expanded = window_width >= 720.0;
// And it must look right in EVERY pane state — open, compact rail, or minimal — even
// though reactor exposes no pane-opened/closed event and the USER drives the toggle.
// So the footer is built to survive the clip instead of dodging it: WinUI clips
// pane-footer content to the compact rail (~48 epx), so the footer's LEADING 48 epx is
// a monogram disc for the scope (the compact form, opens the profile sheet) and the
// combo + edit button sit after it. Closed pane → the clip line falls between disc and
// combo, the rail shows a clean disc. Open pane → disc + combo. No width thresholds,
// no pane-state guessing — the same trick NavigationViewItems use (icon leading, label
// clipped away).
let catalog = ProfilesFile::load();
let mut scope_names = vec!["Default settings".to_string()];
let mut scope_ids: Vec<String> = vec![String::new()];
@@ -1348,7 +1347,7 @@ pub(crate) fn settings_page(
ComboBox::new(names.clone())
.header("Editing")
.selected_index(scope_i as i32)
.min_width(216.0)
.min_width(200.0)
.on_selection_changed({
let (set_scope, set_edit, ids) =
(set_scope.clone(), set_edit.clone(), ids.clone());
@@ -1380,32 +1379,9 @@ pub(crate) fn settings_page(
})
}
};
let footer: Element = if pane_expanded {
// Keyed by scope + the name list: a rename/create/delete changes the ComboBox's
// items, and an in-place diff re-sets items (clearing WinUI's selection) while
// skipping `selected_index` when it compares equal — the combo then renders blank.
// A remount applies every prop. The keyed child sits inside a plain vstack because
// only a panel's child list takes the keyed path (same rule as the content column).
let switcher_key = format!("{scope}\u{1}{}", scope_names.join("\u{1}"));
let mut rows: Vec<Element> = vec![make_switcher().into()];
if profile_mode {
let set_edit = set_edit.clone();
rows.push(
button("Edit profile\u{2026}")
.on_click(move || set_edit.call(true))
.into(),
);
}
vstack(vec![vstack(rows)
.spacing(8.0)
.with_key(switcher_key)
.into()])
.margin(edges(16.0, 0.0, 16.0, 12.0))
.into()
} else {
// The compact rail: a 40-square monogram disc for the scope (profile accent when it
// has one), opening the profile sheet. Plain prop diffs — no combo in the rail, so
// no key needed.
let footer: Element = {
// The leading 48 epx: the monogram disc — the scope's compact form, and the whole
// footer a closed pane shows. Profile accent when set; opens the profile sheet.
let scope_label = match &active {
Some(p) => p.name.clone(),
None => "Default settings".to_string(),
@@ -1431,18 +1407,41 @@ pub(crate) fn settings_page(
.corner_radius(10.0)
.width(40.0)
.height(40.0)
.tooltip(format!("Editing: {scope_label}"))
.tooltip(format!("Editing: {scope_label} \u{2014} profiles\u{2026}"))
.on_tapped({
let set_edit = set_edit.clone();
move || set_edit.call(true)
});
})
.vertical_alignment(VerticalAlignment::Bottom);
let disc = match accent {
Some(c) => disc.background(c),
None => disc.background(ThemeRef::Accent),
};
vstack(vec![disc.into()])
.horizontal_alignment(HorizontalAlignment::Center)
.margin(edges(4.0, 0.0, 4.0, 12.0))
// After the clip line: the combo (+ Edit… when a profile is in scope), visible only
// with the pane open. Keyed by scope + the name list: a rename/create/delete changes
// the ComboBox's items, and an in-place diff re-sets items (clearing WinUI's
// selection) while skipping `selected_index` when it compares equal — the combo then
// renders blank. A remount applies every prop; the keyed child sits inside a plain
// panel because only a panel's child list takes the keyed path.
let switcher_key = format!("{scope}\u{1}{}", scope_names.join("\u{1}"));
let mut rows: Vec<Element> = vec![make_switcher().into()];
if profile_mode {
let set_edit = set_edit.clone();
rows.push(
button("Edit profile\u{2026}")
.on_click(move || set_edit.call(true))
.into(),
);
}
let open_part = vstack(vec![vstack(rows)
.spacing(8.0)
.with_key(switcher_key)
.into()]);
// Disc at x 4..44 — dead centre of the 48 rail; the combo column starts past the
// clip line, so a closed pane shows exactly the disc and nothing else.
hstack(vec![disc.into(), open_part.into()])
.spacing(12.0)
.margin(edges(4.0, 0.0, 12.0, 12.0))
.into()
};
@@ -1543,11 +1542,6 @@ pub(crate) fn settings_page(
});
let mut nav = NavigationView::new(items, content)
.pane_title("Settings")
.pane_display_mode(if pane_expanded {
NavigationViewPaneDisplayMode::Left
} else {
NavigationViewPaneDisplayMode::LeftCompact
})
.open_pane_length(280.0)
.selected_tag(section)
.on_selection_changed({
@@ -1567,13 +1561,14 @@ pub(crate) fn settings_page(
// its own WinUI layer, so its slot in the cell is irrelevant — it just needs to be in
// the tree).
let mut layers: Vec<Element> = vec![nav.into()];
// The profile sheet: reachable via "Edit profile…" (expanded pane, profile in scope)
// or the rail's monogram disc (compact — then it carries the scope combo too, since
// the rail has no room for one).
if edit_open && (profile_mode || !pane_expanded) {
// The profile sheet: reachable via "Edit profile…" (open pane, profile in scope) or
// the footer's monogram disc (any pane state). It always carries the scope combo
// whether the pane's own combo is visible depends on a pane state this page cannot
// observe, so the sheet is self-sufficient.
if edit_open {
layers.push(edit_profile_modal(
active.as_ref(),
(!pane_expanded).then(&make_switcher),
Some(make_switcher()),
set_scope,
set_delete,
set_edit,