feat(client/windows): the sheets learn to be dismissed, and the profile sheet says Save

Escape closes every overlay — the profile sheet, the host editor, the add-host modal —
and so does a tap on the scrim. The scrim-tap needed one trick: WinUI bubbles `Tapped`
out of the card into the scrim (reactor cannot mark it handled), so the card raises a
shared flag first and the scrim's handler swallows exactly that tap; only a genuine
outside tap dismisses. The profile sheet's dismiss button reads Save (with the Save
glyph) instead of Close — every field commits as you type, so Save is the promise the
button already kept, and a sheet full of edits wants a verb.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 18:33:11 +02:00
co-authored by Claude Fable 5
parent 9614e1a92e
commit a9d3ca2fb1
2 changed files with 103 additions and 31 deletions
+50 -5
View File
@@ -396,6 +396,13 @@ fn edit_editor(
// below the fold with nothing hinting at it (live-diagnosed 2026-07-29: a control's // below the fold with nothing hinting at it (live-diagnosed 2026-07-29: a control's
// visible rect was a 9-px sliver). A sheet centres at its own height — and its content // visible rect was a 9-px sliver). A sheet centres at its own height — and its content
// sits in a scroll_view, so a short window scrolls the card instead of clipping it. // sits in a scroll_view, so a short window scrolls the card instead of clipping it.
// A tap on the scrim, or Escape, cancels (a tap INSIDE the card bubbles to the scrim —
// the flag makes the scrim swallow exactly that one).
let inside_tap = std::rc::Rc::new(std::cell::Cell::new(false));
let cancel = {
let se = set_edit.clone();
move || se.call(None)
};
let modal = dialog_surface(scroll_view( let modal = dialog_surface(scroll_view(
vstack(( vstack((
text_block(format!("Edit \u{201c}{initial_name}\u{201d}")) text_block(format!("Edit \u{201c}{initial_name}\u{201d}"))
@@ -452,11 +459,16 @@ fn edit_editor(
)) ))
.spacing(10.0), .spacing(10.0),
)) ))
.on_tapped({
let inside_tap = inside_tap.clone();
move || inside_tap.set(true)
})
.max_width(460.0) .max_width(460.0)
.horizontal_alignment(HorizontalAlignment::Center) .horizontal_alignment(HorizontalAlignment::Center)
.vertical_alignment(VerticalAlignment::Center) .vertical_alignment(VerticalAlignment::Center)
.margin(uniform(24.0)); .margin(uniform(24.0));
// The scrim: blocks the page, closes only via Save/Cancel (same rules as the add modal). let scrim_cancel = cancel.clone();
Element::from(
border(modal) border(modal)
.background(Color { .background(Color {
a: 140, a: 140,
@@ -464,7 +476,18 @@ fn edit_editor(
g: 0, g: 0,
b: 0, b: 0,
}) })
.into() .on_tapped(move || {
if inside_tap.replace(false) {
return;
}
scrim_cancel();
}),
)
.keyboard_accelerator(KeyboardAccelerator::new(
VirtualKey::Escape,
VirtualKeyModifiers::None,
cancel,
))
} }
pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
@@ -1074,16 +1097,38 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
// The scrim fades in with the same tween. Its layer slot is STABLE (a same-kind, // The scrim fades in with the same tween. Its layer slot is STABLE (a same-kind,
// background-less Border when closed — invisible and not hit-testable) so the layer // background-less Border when closed — invisible and not hit-testable) so the layer
// list never changes shape around the always-mounted dialog after it. // list never changes shape around the always-mounted dialog after it. A tap on the
// scrim, or Escape, cancels — with the same bubble-swallow flag the sheets use.
let add_slot: Element = if show_add { let add_slot: Element = if show_add {
border(modal) let inside_tap = std::rc::Rc::new(std::cell::Cell::new(false));
let cancel = {
let sa = set_show_add.clone();
move || sa.call(false)
};
let scrim_cancel = cancel.clone();
Element::from(
border(Element::from(modal).on_tapped({
let inside_tap = inside_tap.clone();
move || inside_tap.set(true)
}))
.background(Color { .background(Color {
a: (140.0 * props.add_anim) as u8, a: (140.0 * props.add_anim) as u8,
r: 0, r: 0,
g: 0, g: 0,
b: 0, b: 0,
}) })
.into() .on_tapped(move || {
if inside_tap.replace(false) {
return;
}
scrim_cancel();
}),
)
.keyboard_accelerator(KeyboardAccelerator::new(
VirtualKey::Escape,
VirtualKeyModifiers::None,
cancel,
))
} else { } else {
border(vstack(Vec::<Element>::new())).into() border(vstack(Vec::<Element>::new())).into()
}; };
+38 -11
View File
@@ -303,18 +303,25 @@ fn edit_profile_modal(
.into(), .into(),
); );
} }
buttons.push( // "Save", not "Close": every field in the sheet commits as you type, so this is really
{ // "done" — but the review is right that a sheet full of edits wants a verb, and Save
// is the promise the button already keeps.
let close_sheet = {
let (set_edit, set_rev) = (set_edit.clone(), set_rev.clone()); let (set_edit, set_rev) = (set_edit.clone(), set_rev.clone());
button("Close") move || {
.accent()
.icon(Symbol::Accept)
.on_click(move || {
set_edit.call(false); set_edit.call(false);
// The deferred repaint: the pane dropdown (and any pinned tiles) pick up the // The deferred repaint: the bar dropdown (and any pinned tiles) pick up the
// rename now, in one pass, instead of remounting per keystroke. // rename now, in one pass, instead of remounting per keystroke.
set_rev.call(rev + 1); set_rev.call(rev + 1);
}) }
};
buttons.push(
{
let close_sheet = close_sheet.clone();
button("Save")
.accent()
.icon(Symbol::Save)
.on_click(close_sheet)
} }
.into(), .into(),
); );
@@ -327,13 +334,22 @@ fn edit_profile_modal(
); );
// The content scrolls when the window is shorter than the sheet (same rule as the host // The content scrolls when the window is shorter than the sheet (same rule as the host
// editor) — a sheet must never clip its own controls. // editor) — a sheet must never clip its own controls.
// A tap INSIDE the card bubbles up to the scrim (WinUI bubbles `Tapped`; reactor can't
// mark it handled), so the card raises this flag first and the scrim's handler swallows
// exactly that tap — a tap on the scrim itself, and Escape, dismiss the sheet.
let inside_tap = std::rc::Rc::new(std::cell::Cell::new(false));
let modal = dialog_surface(scroll_view(vstack(rows).spacing(12.0))) let modal = dialog_surface(scroll_view(vstack(rows).spacing(12.0)))
.on_tapped({
let inside_tap = inside_tap.clone();
move || inside_tap.set(true)
})
.max_width(420.0) .max_width(420.0)
.horizontal_alignment(HorizontalAlignment::Center) .horizontal_alignment(HorizontalAlignment::Center)
.vertical_alignment(VerticalAlignment::Center) .vertical_alignment(VerticalAlignment::Center)
.margin(uniform(24.0)); .margin(uniform(24.0));
// The scrim fills the cell and is hit-testable, so it blocks the page behind; it closes let scrim_close = close_sheet.clone();
// only via the buttons (a scrim tap would bubble `Tapped` up from the card too). let esc_close = close_sheet;
Element::from(
border(modal) border(modal)
.background(Color { .background(Color {
a: 140, a: 140,
@@ -341,7 +357,18 @@ fn edit_profile_modal(
g: 0, g: 0,
b: 0, b: 0,
}) })
.into() .on_tapped(move || {
if inside_tap.replace(false) {
return;
}
scrim_close();
}),
)
.keyboard_accelerator(KeyboardAccelerator::new(
VirtualKey::Escape,
VirtualKeyModifiers::None,
esc_close,
))
} }
/// Persist one control's edit into the layer being edited. /// Persist one control's edit into the layer being edited.