From 6703be377ff79eeee32ed7ee1c87f0383f783bf1 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 29 Jul 2026 18:07:59 +0200 Subject: [PATCH] feat(client/windows): pinning moves to the tile menu, and the chrome quiets down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin/unpin lives in the tile's "…" menu now (review decision, reversing the editor-owns-it rule): one flat prefix-matched entry per profile — "Pin tile: X" / "Unpin tile: X" — beside Copy link and Create shortcut, the other tile-shaped actions. The write is paired with a new `hosts_rev` bump (the hosts-page mirror of `settings_rev`), so the pinned tile appears — or vanishes — in the same gesture instead of on the next discovery tick. UIA-verified: menu -> Pin tile: Work -> the pin is in the store and the second tile is on the grid immediately. The rest is the review list: * Both sheets (host editor, profile) put their content in a scroll_view — a window shorter than the card scrolls it instead of clipping the bottom controls. * The home header keeps ONE labelled action: Add host, in accent. Console/Shortcuts/ Settings drop to icons with tooltips — four written-out buttons read as four competing calls to action, and icon-only removes the compact-width special case too. * The profile switcher becomes one combined element: combo + pencil (icon only, the label is gone) inside a shared control-look wrapper — stock 4-epx radius, CardStroke outline, the pencil a borderless segment. In the defaults scope the bare combo stands alone. Co-Authored-By: Claude Fable 5 --- clients/windows/src/app/hosts.rs | 162 +++++++++++++--------------- clients/windows/src/app/mod.rs | 6 ++ clients/windows/src/app/settings.rs | 75 ++++++++----- 3 files changed, 127 insertions(+), 116 deletions(-) diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs index 8901d8f0..f53e26f4 100644 --- a/clients/windows/src/app/hosts.rs +++ b/clients/windows/src/app/hosts.rs @@ -26,6 +26,10 @@ const MENU_EDIT: &str = "Edit\u{2026}"; const MENU_WITH: &str = "Connect with: "; const MENU_COPY_LINK: &str = "Copy link"; const MENU_SHORTCUT: &str = "Create shortcut\u{2026}"; +/// Pin/unpin a profile's one-click tile for this host — dynamic per profile, prefix-matched +/// like [`MENU_WITH`] (same flat-flyout constraint, same collision-proof trailing space). +const MENU_PIN: &str = "Pin tile: "; +const MENU_UNPIN: &str = "Unpin tile: "; const MENU_FORGET: &str = "Forget\u{2026}"; /// Whether the console (gamepad) UI is available in this build: the session binary ships @@ -71,10 +75,15 @@ pub(crate) struct HostsProps { /// The hovered tile's stable id (saved: fp_hex, discovered: `addr:port`) — root state because /// the pointer enter/exit handlers bypass the reconciler flush, like the flyout clicks above. pub(crate) hover: Option, + /// Bumped when a menu action changes what the page should SHOW without changing any + /// state it already reads — pinning/unpinning a profile tile, which rewrites the + /// known-hosts store behind the tiles (the hosts-page mirror of `settings_rev`). + pub(crate) hosts_rev: u64, pub(crate) set_forget: AsyncSetState>, pub(crate) set_rename: AsyncSetState>, pub(crate) set_show_add: AsyncSetState, pub(crate) set_hover: AsyncSetState>, + pub(crate) set_hosts_rev: AsyncSetState, } impl PartialEq for HostsProps { @@ -90,6 +99,7 @@ impl PartialEq for HostsProps { && self.show_add == other.show_add && self.add_anim == other.add_anim && self.hover == other.hover + && self.hosts_rev == other.hosts_rev } } @@ -362,58 +372,6 @@ fn edit_editor( } }) }; - // Pinned tiles: which profiles get their own one-click tile for this host. They used to be - // two more flat entries in the tile's flyout, which is what tipped that menu over — and this - // is where they belong anyway, beside the default they sit next to (design §5.2a). Each - // switch writes immediately, like the profile picker above and unlike the text fields, which - // need a Save because they have drafts. - let pin_switches: Element = { - let catalog = pf_client_core::profiles::ProfilesFile::load(); - let stored = KnownHosts::load() - .hosts - .iter() - .find(|h| h.fp_hex == fp) - .cloned(); - if catalog.profiles.is_empty() { - vstack(Vec::::new()).into() - } else { - let mut rows: Vec = vec![text_block("Pinned tiles") - .font_size(12.0) - .foreground(ThemeRef::SecondaryText) - .horizontal_alignment(HorizontalAlignment::Left) - .into()]; - for p in &catalog.profiles { - let on = stored - .as_ref() - .is_some_and(|h| h.pinned_profiles.iter().any(|id| id == &p.id)); - let (fp, id) = (fp.to_string(), p.id.clone()); - rows.push( - ToggleSwitch::new(on) - .header(&p.name) - .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()); - } - 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(), - ); - } - vstack(rows).spacing(6.0).into() - } - }; - let field = |label: &str, value: String, placeholder: &str, draft: HookRef| { vstack(( text_block(label) @@ -434,12 +392,11 @@ fn edit_editor( *clip_draft.borrow(), ); // 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( + // grid cell in the middle of the page, and on an ordinary window its lower half sat + // 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 + // sits in a scroll_view, so a short window scrolls the card instead of clipping it. + let modal = dialog_surface(scroll_view( vstack(( text_block(format!("Edit \u{201c}{initial_name}\u{201d}")) .font_size(20.0) @@ -465,7 +422,6 @@ fn edit_editor( .horizontal_alignment(HorizontalAlignment::Left), )) .spacing(4.0), - pin_switches, vstack(( ToggleSwitch::new(clip0) .header("Share clipboard with this host") @@ -495,7 +451,7 @@ fn edit_editor( .horizontal_alignment(HorizontalAlignment::Right), )) .spacing(10.0), - ) + )) .max_width(460.0) .horizontal_alignment(HorizontalAlignment::Center) .vertical_alignment(VerticalAlignment::Center) @@ -585,20 +541,14 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { let window = cx.use_inner_size(); let content_w = (window.width - 64.0).clamp(TILE_MIN_WIDTH, 1120.0); let cols = (((content_w + TILE_GAP) / (TILE_MIN_WIDTH + TILE_GAP)).floor() as usize).max(1); - // Compact header: below this the three labelled buttons would collide with the title - // (the Auto grid column can't shrink), so they drop to icon-only with tooltips. - let compact = window.width < 700.0; - let header_btn = |label: &str, sym: Symbol| { - if compact { - button("").icon(sym).tooltip(label).automation_name(label) - } else { - button(label).icon(sym) - } - }; - let mut body: Vec = Vec::new(); - // Header: title block + Add host / Help / Settings. + // Header: title block + the page actions. ONE labelled primary — Add host, in accent — + // and the rest icon-only with tooltips: four written-out buttons in a row read as four + // competing calls to action (review feedback), and icon-only needs no compact-width + // special case either. + let icon_btn = + |label: &str, sym: Symbol| button("").icon(sym).tooltip(label).automation_name(label); body.push( grid(( vstack(( @@ -611,7 +561,8 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { .grid_column(0) .vertical_alignment(VerticalAlignment::Center), hstack({ - let mut actions: Vec = vec![header_btn("Add host", Symbol::Add) + let mut actions: Vec = vec![button("Add host") + .icon(Symbol::Add) .accent() .on_click({ let sa = set_show_add.clone(); @@ -622,23 +573,21 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { // where the session binary ships without its Skia console. if CONSOLE_UI_AVAILABLE { actions.push( - header_btn("Console UI", Symbol::Play) - .tooltip( - "The controller-driven couch interface \u{2014} host list, \ - pairing and libraries, launching streams in the same window.", - ) - .on_click({ - let (c, ss, st) = - (ctx.clone(), set_screen.clone(), set_status.clone()); - // No target: the console opens its OWN host view rather than - // one host's library — the couch counterpart of this page. - move || open_console(&c, None, &ss, &st) - }) - .into(), + icon_btn( + "Console UI \u{2014} the controller-driven couch interface", + Symbol::Play, + ) + .on_click({ + let (c, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone()); + // No target: the console opens its OWN host view rather than + // one host's library — the couch counterpart of this page. + move || open_console(&c, None, &ss, &st) + }) + .into(), ); } actions.push( - header_btn("Shortcuts", Symbol::Keyboard) + icon_btn("Keyboard shortcuts", Symbol::Keyboard) .on_click({ let ss = set_screen.clone(); move || ss.call(Screen::Help) @@ -646,7 +595,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { .into(), ); actions.push( - header_btn("Settings", Symbol::Setting) + icon_btn("Settings", Symbol::Setting) .on_click({ let ss = set_screen.clone(); move || ss.call(Screen::Settings) @@ -723,6 +672,8 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { let (sf, sr) = (set_forget.clone(), set_rename.clone()); let (fp, name) = (k.fp_hex.clone(), k.name.clone()); let menu_profiles = profiles.clone(); + let pinned_now = k.pinned_profiles.clone(); + let (hosts_rev, set_hosts_rev) = (props.hosts_rev, props.set_hosts_rev.clone()); let (link_host, link_profile) = (k.clone(), None::); let shortcut_host = k.clone(); button("") @@ -764,6 +715,15 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { items.push(menu_separator()); items.push(menu_item(MENU_COPY_LINK)); items.push(menu_item(MENU_SHORTCUT)); + // Pin/unpin a profile's one-click tile, beside the other tile-shaped + // shortcuts (the review moved these here from the editor). + for (id, name, _) in profiles.iter() { + let pinned = pinned_now.iter().any(|x| x == id); + items.push(menu_item(format!( + "{}{name}", + if pinned { MENU_UNPIN } else { MENU_PIN } + ))); + } items.push(menu_separator()); items.push(menu_item(MENU_EDIT)); @@ -773,6 +733,32 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { .on_item_clicked(move |item: String| match item.as_str() { // The profile items are dynamic, so they are matched by prefix before // the fixed ones. + _ if item.starts_with(MENU_PIN) || item.starts_with(MENU_UNPIN) => { + let (on, name) = if let Some(n) = item.strip_prefix(MENU_PIN) { + (true, n) + } else { + (false, item.trim_start_matches(MENU_UNPIN)) + }; + let Some((id, ..)) = menu_profiles.iter().find(|(_, n, _)| n == name) + else { + return; + }; + tracing::info!(pin = %id, host = %fp, on, "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 on { + h.pinned_profiles.push(id.clone()); + } + if let Err(e) = known.save() { + tracing::warn!(error = %format!("{e:#}"), "saving a pin"); + } + } + // The store changed behind the tiles and nothing the page reads + // as state did — the bump is what makes the pinned tile appear + // (or vanish) NOW, not on the next discovery tick. + set_hosts_rev.call(hosts_rev + 1); + } _ if item.starts_with(MENU_WITH) => { let name = item.trim_start_matches(MENU_WITH); let mut target = target.clone(); diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs index 9b16b9a7..f0176769 100644 --- a/clients/windows/src/app/mod.rs +++ b/clients/windows/src/app/mod.rs @@ -298,6 +298,10 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { // its marker as immediately as resetting one clears it), a reset, a profile colour change. // 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); + // The hosts page's mirror of the same idea: pin/unpin from a tile's menu rewrites the + // known-hosts store behind the tiles, and the bump is what makes the pinned tile appear + // in the same gesture instead of on the next discovery tick. + let (hosts_rev, set_hosts_rev) = cx.use_async_state(0u64); // `punktfunk://` links: the receiver thread queues them (from this launch's argv, or from a // later instance over WM_COPYDATA) and this poll pulls them onto the UI thread. Thread-fed // state must be root state, like the pad count below. @@ -620,10 +624,12 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { show_add, add_anim, hover, + hosts_rev, set_forget, set_rename, set_show_add, set_hover, + set_hosts_rev: set_hosts_rev.clone(), }, ), // connecting_page / request_access_page / waking_page / settings_page / licenses_page / diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index 0e8b3a22..48fdc3a6 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -332,7 +332,9 @@ fn edit_profile_modal( .margin(edges(0.0, 6.0, 0.0, 0.0)) .into(), ); - let modal = dialog_surface(vstack(rows).spacing(12.0)) + // 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. + let modal = dialog_surface(scroll_view(vstack(rows).spacing(12.0))) .max_width(420.0) .horizontal_alignment(HorizontalAlignment::Center) .vertical_alignment(VerticalAlignment::Center) @@ -1336,21 +1338,41 @@ pub(crate) fn settings_page( // Every piece is vertically CENTRED against the combo (the tallest control), and // the row shares the content column's 24-left / 28-right page margins, so the bar // reads as part of the page grid rather than floating chrome. - let mut row: Vec = vec![ - text_block("Editing") - .font_size(13.0) - .foreground(ThemeRef::SecondaryText) - .vertical_alignment(VerticalAlignment::Center) - .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 hstack's child list takes the keyed - // path (a panel). - Element::from(make_switcher()) - .with_key(format!("{scope}\u{1}{}", scope_names.join("\u{1}"))) - .vertical_alignment(VerticalAlignment::Center), - ]; + // The switcher is ONE combined element (review feedback): the combo and the pencil + // share a control-look wrapper — the stock 4-epx control radius and a CardStroke + // outline — with the pencil as a borderless icon segment. Only a Border can carry a + // corner radius in this toolkit, so the wrapper supplies the joint chrome. In the + // defaults scope there is nothing to edit and the bare combo stands alone. + let switcher_key = format!("{scope}\u{1}{}", scope_names.join("\u{1}")); + let combined: Element = if profile_mode { + let set_edit = set_edit.clone(); + border( + hstack(vec![ + Element::from(make_switcher()), + button("") + .icon(Symbol::Edit) + .subtle() + .height(36.0) + .width(40.0) + .tooltip("Edit profile\u{2026}") + .automation_name("Edit profile\u{2026}") + .on_click(move || set_edit.call(true)) + .into(), + ]) + .spacing(0.0), + ) + .corner_radius(4.0) + .border_brush(ThemeRef::CardStroke) + .border_thickness(uniform(1.0)) + .into() + } else { + make_switcher().into() + }; + let mut row: Vec = vec![text_block("Editing") + .font_size(13.0) + .foreground(ThemeRef::SecondaryText) + .vertical_alignment(VerticalAlignment::Center) + .into()]; // 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 @@ -1368,18 +1390,15 @@ pub(crate) fn settings_page( .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(), - ); - } + // 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 hstack's child list takes the keyed path. + row.push( + combined + .with_key(switcher_key) + .vertical_alignment(VerticalAlignment::Center), + ); hstack(row) .spacing(12.0) .margin(edges(24.0, 12.0, 28.0, 8.0))