fix(client/windows): the scope switcher stays in the nav at every width

The width-adaptive fallback moved the switcher OUT of the pane on narrow windows, and in
the pane it could still clip — both read as broken. Now the nav is the switcher's only
home: `Left` mode (always-expanded 280 pane, combo sized to fit) while the content column
gets a workable width, and below that a `LeftCompact` rail whose footer swaps to a fitting
compact form — a 40-square monogram disc for the scope (profile accent when set) instead
of a clipped ComboBox. The disc opens the profile sheet, which in compact mode carries the
scope combo too (keyed per scope against the items/selected_index diff hazard), so
switching, creating, and editing profiles all stay reachable from the rail. The inline
content-column fallback is gone.

UIA-verified on the expanded path: scope pick from the pane, Overridden marker 5 -> 6 in
place, sheet opens 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 2409ec9a4b
commit 4b5bca93a0
+248 -183
View File
@@ -201,95 +201,133 @@ fn colour_swatches(profile: &StreamProfile, rev: u64, set_rev: &AsyncSetState<u6
/// scope dropdown refreshes on Close (one revision bump), so the ComboBox is not remounted
/// under the user mid-keystroke.
fn edit_profile_modal(
profile: &StreamProfile,
profile: Option<&StreamProfile>,
switcher: Option<ComboBox>,
set_scope: &AsyncSetState<String>,
set_delete: &AsyncSetState<Option<String>>,
set_edit: &AsyncSetState<bool>,
rev: u64,
set_rev: &AsyncSetState<u64>,
) -> Element {
let id = profile.id.clone();
let name_box = {
let id = id.clone();
text_box(&profile.name)
.header("Name")
.placeholder_text("Profile name")
.on_text_changed(move |t: String| {
let name = t.trim().to_string();
if name.is_empty() {
return;
}
let mut catalog = ProfilesFile::load();
// Names are unique case-insensitively — menus keyed by name are ambiguous
// otherwise. A collision simply doesn't commit; the box keeps what was typed.
if catalog.name_taken(&name, Some(&id)) {
return;
}
if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) {
p.name = name;
let _ = catalog.save();
}
})
};
let duplicate = {
let (id, set_scope) = (id.clone(), set_scope.clone());
button("Duplicate").on_click(move || {
let mut catalog = ProfilesFile::load();
let Some(source) = catalog.find_by_id(&id).cloned() else {
return;
};
let name = (2..)
.map(|n| format!("{} {n}", source.name))
.find(|n| !catalog.name_taken(n, None))
.unwrap_or_else(|| source.name.clone());
let mut copy = StreamProfile::new(name);
copy.overrides = source.overrides.clone();
copy.accent = source.accent.clone();
let new_id = copy.id.clone();
catalog.profiles.push(copy);
if catalog.save().is_ok() {
// The modal stays open and now edits the copy — scope follows it.
set_scope.call(new_id);
let mut rows: Vec<Element> = vec![text_block(if switcher.is_some() {
"Profiles"
} else {
"Edit profile"
})
.font_size(20.0)
.bold()
.into()];
if let Some(sw) = switcher {
// Keyed by scope: an in-sheet scope switch re-renders this combo with a different
// selection, and the in-place diff would leave it blank (the documented
// items/selected_index hazard) — a remount applies every prop.
rows.push(
vstack(vec![Element::from(sw)])
.with_key(format!(
"sheet-scope-{}",
profile.map(|p| p.id.as_str()).unwrap_or("")
))
.into(),
);
}
if let Some(profile) = profile {
let id = profile.id.clone();
let name_box = {
let id = id.clone();
text_box(&profile.name)
.header("Name")
.placeholder_text("Profile name")
.on_text_changed(move |t: String| {
let name = t.trim().to_string();
if name.is_empty() {
return;
}
let mut catalog = ProfilesFile::load();
// Names are unique case-insensitively — menus keyed by name are ambiguous
// otherwise. A collision simply doesn't commit; the box keeps what was typed.
if catalog.name_taken(&name, Some(&id)) {
return;
}
if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) {
p.name = name;
let _ = catalog.save();
}
})
};
rows.push(name_box.into());
rows.push(colour_swatches(profile, rev, set_rev));
}
rows.push(
text_block(
"A profile overrides only what you change while it is selected; everything \
else follows Default settings. Renaming applies as you type. Deleting leaves \
hosts that used it on Default settings.",
)
.font_size(12.0)
.wrap()
.foreground(ThemeRef::SecondaryText)
.into(),
);
let mut buttons: Vec<Element> = Vec::new();
if let Some(p) = profile {
let id = p.id.clone();
buttons.push(
{
let (id, set_scope) = (id.clone(), set_scope.clone());
button("Duplicate").on_click(move || {
let mut catalog = ProfilesFile::load();
let Some(source) = catalog.find_by_id(&id).cloned() else {
return;
};
let name = (2..)
.map(|n| format!("{} {n}", source.name))
.find(|n| !catalog.name_taken(n, None))
.unwrap_or_else(|| source.name.clone());
let mut copy = StreamProfile::new(name);
copy.overrides = source.overrides.clone();
copy.accent = source.accent.clone();
let new_id = copy.id.clone();
catalog.profiles.push(copy);
if catalog.save().is_ok() {
// The sheet stays open and now edits the copy — scope follows it.
set_scope.call(new_id);
}
})
}
})
};
let delete = {
let (id, set_delete) = (id.clone(), set_delete.clone());
button("Delete\u{2026}").on_click(move || set_delete.call(Some(id.clone())))
};
let close = {
let (set_edit, set_rev) = (set_edit.clone(), set_rev.clone());
button("Close").accent().on_click(move || {
set_edit.call(false);
// The deferred repaint: the pane dropdown (and any pinned tiles) pick up the
// rename now, in one pass, instead of remounting per keystroke.
set_rev.call(rev + 1);
})
};
let modal = dialog_surface(
vstack((
text_block("Edit profile").font_size(20.0).bold(),
name_box,
colour_swatches(profile, rev, set_rev),
text_block(
"A profile overrides only what you change while it is selected; everything \
else follows Default settings. Renaming applies as you type. Deleting leaves \
hosts that used it on Default settings.",
)
.font_size(12.0)
.wrap()
.foreground(ThemeRef::SecondaryText),
hstack((duplicate, delete, close))
.spacing(8.0)
.horizontal_alignment(HorizontalAlignment::Right)
.margin(edges(0.0, 6.0, 0.0, 0.0)),
))
.spacing(12.0),
)
.max_width(420.0)
.horizontal_alignment(HorizontalAlignment::Center)
.vertical_alignment(VerticalAlignment::Center)
.margin(uniform(24.0));
.into(),
);
buttons.push(
{
let set_delete = set_delete.clone();
button("Delete\u{2026}").on_click(move || set_delete.call(Some(id.clone())))
}
.into(),
);
}
buttons.push(
{
let (set_edit, set_rev) = (set_edit.clone(), set_rev.clone());
button("Close").accent().on_click(move || {
set_edit.call(false);
// The deferred repaint: the pane dropdown (and any pinned tiles) pick up the
// rename now, in one pass, instead of remounting per keystroke.
set_rev.call(rev + 1);
})
}
.into(),
);
rows.push(
hstack(buttons)
.spacing(8.0)
.horizontal_alignment(HorizontalAlignment::Right)
.margin(edges(0.0, 6.0, 0.0, 0.0))
.into(),
);
let modal = dialog_surface(vstack(rows).spacing(12.0))
.max_width(420.0)
.horizontal_alignment(HorizontalAlignment::Center)
.vertical_alignment(VerticalAlignment::Center)
.margin(uniform(24.0));
// The scrim fills the cell and is hit-testable, so it blocks the page behind; it closes
// only via the buttons (a scrim tap would bubble `Tapped` up from the card too).
border(modal)
@@ -1281,12 +1319,14 @@ 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.
//
// But ONLY while the pane is expanded — pane-footer content is CLIPPED to the compact
// rail, not adapted. WinUI's Auto mode would collapse the pane below 1008 epx (under the
// app's own 1000-wide default window!), which kept booting the switcher out of the nav,
// so the page FORCES the pane: expanded (`Left`, at a tighter 250 length) whenever the
// content column still gets a workable width, minimal below that. Reactor exposes no
// pane-opened/closed event, so the switcher placement rides the same threshold.
// 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;
let catalog = ProfilesFile::load();
let mut scope_names = vec!["Default settings".to_string()];
@@ -1298,83 +1338,112 @@ pub(crate) fn settings_page(
scope_names.push("New profile\u{2026}".to_string());
scope_ids.push(NEW_PROFILE.to_string());
let scope_i = scope_ids.iter().position(|i| i == scope).unwrap_or(0);
let switcher = ComboBox::new(scope_names.clone())
.header("Editing")
.selected_index(scope_i as i32)
.min_width(200.0)
.on_selection_changed({
let (set_scope, set_edit, ids) = (set_scope.clone(), set_edit.clone(), scope_ids);
move |i: i32| {
let Some(id) = ids.get(i.max(0) as usize) else {
return;
};
if id == NEW_PROFILE {
// A new profile takes an auto-numbered name and lands straight in the
// Edit modal to be named — creation and naming are one gesture, and
// there is no half-created state a Cancel would have to unwind.
let mut catalog = ProfilesFile::load();
let name = (1..)
.map(|n| format!("Profile {n}"))
.find(|n| !catalog.name_taken(n, None))
.unwrap_or_else(|| "Profile".to_string());
let profile = StreamProfile::new(name);
let new_id = profile.id.clone();
catalog.profiles.push(profile);
if catalog.save().is_ok() {
set_scope.call(new_id);
set_edit.call(true);
// A factory, not a value: the combo is placed in the pane footer when the pane is
// expanded, and inside the profile sheet when the rail's monogram opens it — one
// builder, instantiated for whichever surface renders this pass.
let make_switcher = {
let (set_scope, set_edit) = (set_scope.clone(), set_edit.clone());
let (names, ids) = (scope_names.clone(), scope_ids);
move || {
ComboBox::new(names.clone())
.header("Editing")
.selected_index(scope_i as i32)
.min_width(216.0)
.on_selection_changed({
let (set_scope, set_edit, ids) =
(set_scope.clone(), set_edit.clone(), ids.clone());
move |i: i32| {
let Some(id) = ids.get(i.max(0) as usize) else {
return;
};
if id == NEW_PROFILE {
// A new profile takes an auto-numbered name and lands straight
// in the sheet to be named — creation and naming are one
// gesture, and there is no half-created state a Cancel would
// have to unwind.
let mut catalog = ProfilesFile::load();
let name = (1..)
.map(|n| format!("Profile {n}"))
.find(|n| !catalog.name_taken(n, None))
.unwrap_or_else(|| "Profile".to_string());
let profile = StreamProfile::new(name);
let new_id = profile.id.clone();
catalog.profiles.push(profile);
if catalog.save().is_ok() {
set_scope.call(new_id);
set_edit.call(true);
}
return;
}
set_scope.call(id.clone());
}
return;
}
set_scope.call(id.clone());
}
});
let edit_button: Option<Element> = profile_mode.then(|| {
let set_edit = set_edit.clone();
button("Edit profile\u{2026}")
.on_click(move || set_edit.call(true))
.into()
});
// 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 below).
let switcher_key = format!("{scope}\u{1}{}", scope_names.join("\u{1}"));
// Expanded pane: combo over button, in the footer. Collapsed: one horizontal row that
// leads the content column (bottom-aligned so the combo's "Editing" header doesn't push
// the button off its baseline).
let (footer, inline_switcher): (Option<Element>, Option<Element>) = if pane_expanded {
let rows: Vec<Element> = std::iter::once(Element::from(switcher))
.chain(edit_button)
.collect();
(
Some(
vstack(vec![vstack(rows)
.spacing(8.0)
.with_key(switcher_key)
.into()])
.margin(edges(16.0, 0.0, 16.0, 12.0))
.into(),
),
None,
)
})
}
};
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 {
let row: Vec<Element> = std::iter::once(Element::from(switcher))
.chain(edit_button)
.collect();
(
None,
Some(
vstack(vec![hstack(row)
.spacing(8.0)
.vertical_alignment(VerticalAlignment::Bottom)
.with_key(switcher_key)
.into()])
.margin(edges(24.0, 12.0, 28.0, 0.0))
.into(),
),
// 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 scope_label = match &active {
Some(p) => p.name.clone(),
None => "Default settings".to_string(),
};
let initial = scope_label
.chars()
.find(|c| c.is_alphanumeric())
.map(|c| c.to_uppercase().to_string())
.unwrap_or_else(|| "?".into());
let accent = active
.as_ref()
.and_then(|p| p.accent.as_deref())
.and_then(hex_color);
let disc = border(
text_block(initial)
.font_size(17.0)
.semibold()
// The on-accent brush, not AccentText — see `style::avatar`.
.foreground(ThemeRef::custom("TextOnAccentFillColorPrimaryBrush"))
.horizontal_alignment(HorizontalAlignment::Center)
.vertical_alignment(VerticalAlignment::Center),
)
.corner_radius(10.0)
.width(40.0)
.height(40.0)
.tooltip(format!("Editing: {scope_label}"))
.on_tapped({
let set_edit = set_edit.clone();
move || set_edit.call(true)
});
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))
.into()
};
let titled: Vec<Element> = std::iter::once(
@@ -1409,16 +1478,7 @@ pub(crate) fn settings_page(
)
.opacity(progress)
.margin(edges(0.0, (1.0 - progress) * 22.0, 0.0, 0.0));
// With the pane collapsed the switcher leads the content column, above the scrolling
// region so it stays put — it is still chrome, not a settings row. An Auto/Star grid,
// not a vstack: a StackPanel would hand the scroll_view its DESIRED height and the
// viewport (and scrolling) would be gone.
let content: Element = match inline_switcher {
Some(row) => grid(vec![row.grid_row(0), Element::from(scrolled).grid_row(1)])
.rows([GridLength::Auto, GridLength::STAR])
.into(),
None => scrolled.into(),
};
let content: Element = scrolled.into();
// The delete confirmation, when armed. Declarative, like every other dialog in this shell:
// it is an element in the tree with `is_open`, not a call.
let confirm: Option<Element> = delete_pending.as_ref().and_then(|id| {
@@ -1486,9 +1546,9 @@ pub(crate) fn settings_page(
.pane_display_mode(if pane_expanded {
NavigationViewPaneDisplayMode::Left
} else {
NavigationViewPaneDisplayMode::LeftMinimal
NavigationViewPaneDisplayMode::LeftCompact
})
.open_pane_length(250.0)
.open_pane_length(280.0)
.selected_tag(section)
.on_selection_changed({
let ss = set_section.clone();
@@ -1500,21 +1560,26 @@ pub(crate) fn settings_page(
let ss = set_screen.clone();
move || ss.call(Screen::Hosts)
});
if let Some(f) = footer {
nav = nav.pane_footer(f);
}
nav = nav.pane_footer(footer);
// One grid, so every layer FILLS the window (a vstack would hand the NavigationView its
// desired height — clipped when the window is short, floating when it is tall): the nav,
// the Edit-profile scrim + card over it, and the delete confirmation (a ContentDialog,
// 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()];
if edit_open {
if let Some(p) = &active {
layers.push(edit_profile_modal(
p, set_scope, set_delete, set_edit, rev, set_rev,
));
}
// 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) {
layers.push(edit_profile_modal(
active.as_ref(),
(!pane_expanded).then(&make_switcher),
set_scope,
set_delete,
set_edit,
rev,
set_rev,
));
}
if let Some(dialog) = confirm {
layers.push(dialog);