feat(client/windows): pinning moves to the tile menu, and the chrome quiets down

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 18:33:11 +02:00
co-authored by Claude Fable 5
parent 8783f11e90
commit 6703be377f
3 changed files with 127 additions and 116 deletions
+67 -81
View File
@@ -26,6 +26,10 @@ const MENU_EDIT: &str = "Edit\u{2026}";
const MENU_WITH: &str = "Connect with: "; const MENU_WITH: &str = "Connect with: ";
const MENU_COPY_LINK: &str = "Copy link"; const MENU_COPY_LINK: &str = "Copy link";
const MENU_SHORTCUT: &str = "Create shortcut\u{2026}"; 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}"; const MENU_FORGET: &str = "Forget\u{2026}";
/// Whether the console (gamepad) UI is available in this build: the session binary ships /// 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 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. /// the pointer enter/exit handlers bypass the reconciler flush, like the flyout clicks above.
pub(crate) hover: Option<String>, pub(crate) hover: Option<String>,
/// 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<Option<(String, String)>>, pub(crate) set_forget: AsyncSetState<Option<(String, String)>>,
pub(crate) set_rename: AsyncSetState<Option<(String, String)>>, pub(crate) set_rename: AsyncSetState<Option<(String, String)>>,
pub(crate) set_show_add: AsyncSetState<bool>, pub(crate) set_show_add: AsyncSetState<bool>,
pub(crate) set_hover: AsyncSetState<Option<String>>, pub(crate) set_hover: AsyncSetState<Option<String>>,
pub(crate) set_hosts_rev: AsyncSetState<u64>,
} }
impl PartialEq for HostsProps { impl PartialEq for HostsProps {
@@ -90,6 +99,7 @@ impl PartialEq for HostsProps {
&& self.show_add == other.show_add && self.show_add == other.show_add
&& self.add_anim == other.add_anim && self.add_anim == other.add_anim
&& self.hover == other.hover && 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::<Element>::new()).into()
} else {
let mut rows: Vec<Element> = 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<String>| { let field = |label: &str, value: String, placeholder: &str, draft: HookRef<String>| {
vstack(( vstack((
text_block(label) text_block(label)
@@ -434,12 +392,11 @@ fn edit_editor(
*clip_draft.borrow(), *clip_draft.borrow(),
); );
// A centred SHEET (scrim + card), not an in-grid tile: as a tile the editor inherited a // 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 // grid cell in the middle of the page, and on an ordinary window its lower half sat
// pin switches especially — sat below the fold with nothing hinting at it ("can't pin // below the fold with nothing hinting at it (live-diagnosed 2026-07-29: a control's
// hosts", live-diagnosed 2026-07-29: the switch's visible rect was a 9-px sliver). A // visible rect was a 9-px sliver). A sheet centres at its own height — and its content
// sheet centres at its own height, scrolls internally when it must, and matches where // sits in a scroll_view, so a short window scrolls the card instead of clipping it.
// every other edit flow lives. let modal = dialog_surface(scroll_view(
let modal = dialog_surface(
vstack(( vstack((
text_block(format!("Edit \u{201c}{initial_name}\u{201d}")) text_block(format!("Edit \u{201c}{initial_name}\u{201d}"))
.font_size(20.0) .font_size(20.0)
@@ -465,7 +422,6 @@ fn edit_editor(
.horizontal_alignment(HorizontalAlignment::Left), .horizontal_alignment(HorizontalAlignment::Left),
)) ))
.spacing(4.0), .spacing(4.0),
pin_switches,
vstack(( vstack((
ToggleSwitch::new(clip0) ToggleSwitch::new(clip0)
.header("Share clipboard with this host") .header("Share clipboard with this host")
@@ -495,7 +451,7 @@ fn edit_editor(
.horizontal_alignment(HorizontalAlignment::Right), .horizontal_alignment(HorizontalAlignment::Right),
)) ))
.spacing(10.0), .spacing(10.0),
) ))
.max_width(460.0) .max_width(460.0)
.horizontal_alignment(HorizontalAlignment::Center) .horizontal_alignment(HorizontalAlignment::Center)
.vertical_alignment(VerticalAlignment::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 window = cx.use_inner_size();
let content_w = (window.width - 64.0).clamp(TILE_MIN_WIDTH, 1120.0); 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); 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<Element> = Vec::new(); let mut body: Vec<Element> = 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( body.push(
grid(( grid((
vstack(( vstack((
@@ -611,7 +561,8 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
.grid_column(0) .grid_column(0)
.vertical_alignment(VerticalAlignment::Center), .vertical_alignment(VerticalAlignment::Center),
hstack({ hstack({
let mut actions: Vec<Element> = vec![header_btn("Add host", Symbol::Add) let mut actions: Vec<Element> = vec![button("Add host")
.icon(Symbol::Add)
.accent() .accent()
.on_click({ .on_click({
let sa = set_show_add.clone(); let sa = set_show_add.clone();
@@ -622,14 +573,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
// where the session binary ships without its Skia console. // where the session binary ships without its Skia console.
if CONSOLE_UI_AVAILABLE { if CONSOLE_UI_AVAILABLE {
actions.push( actions.push(
header_btn("Console UI", Symbol::Play) icon_btn(
.tooltip( "Console UI \u{2014} the controller-driven couch interface",
"The controller-driven couch interface \u{2014} host list, \ Symbol::Play,
pairing and libraries, launching streams in the same window.",
) )
.on_click({ .on_click({
let (c, ss, st) = let (c, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
(ctx.clone(), set_screen.clone(), set_status.clone());
// No target: the console opens its OWN host view rather than // No target: the console opens its OWN host view rather than
// one host's library — the couch counterpart of this page. // one host's library — the couch counterpart of this page.
move || open_console(&c, None, &ss, &st) move || open_console(&c, None, &ss, &st)
@@ -638,7 +587,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
); );
} }
actions.push( actions.push(
header_btn("Shortcuts", Symbol::Keyboard) icon_btn("Keyboard shortcuts", Symbol::Keyboard)
.on_click({ .on_click({
let ss = set_screen.clone(); let ss = set_screen.clone();
move || ss.call(Screen::Help) move || ss.call(Screen::Help)
@@ -646,7 +595,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
.into(), .into(),
); );
actions.push( actions.push(
header_btn("Settings", Symbol::Setting) icon_btn("Settings", Symbol::Setting)
.on_click({ .on_click({
let ss = set_screen.clone(); let ss = set_screen.clone();
move || ss.call(Screen::Settings) 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 (sf, sr) = (set_forget.clone(), set_rename.clone());
let (fp, name) = (k.fp_hex.clone(), k.name.clone()); let (fp, name) = (k.fp_hex.clone(), k.name.clone());
let menu_profiles = profiles.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::<String>); let (link_host, link_profile) = (k.clone(), None::<String>);
let shortcut_host = k.clone(); let shortcut_host = k.clone();
button("") button("")
@@ -764,6 +715,15 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
items.push(menu_separator()); items.push(menu_separator());
items.push(menu_item(MENU_COPY_LINK)); items.push(menu_item(MENU_COPY_LINK));
items.push(menu_item(MENU_SHORTCUT)); 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_separator());
items.push(menu_item(MENU_EDIT)); 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() { .on_item_clicked(move |item: String| match item.as_str() {
// The profile items are dynamic, so they are matched by prefix before // The profile items are dynamic, so they are matched by prefix before
// the fixed ones. // 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) => { _ if item.starts_with(MENU_WITH) => {
let name = item.trim_start_matches(MENU_WITH); let name = item.trim_start_matches(MENU_WITH);
let mut target = target.clone(); let mut target = target.clone();
+6
View File
@@ -298,6 +298,10 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
// its marker as immediately as resetting one clears it), a reset, a profile colour change. // 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. // 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); 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 // `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 // 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. // state must be root state, like the pad count below.
@@ -620,10 +624,12 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
show_add, show_add,
add_anim, add_anim,
hover, hover,
hosts_rev,
set_forget, set_forget,
set_rename, set_rename,
set_show_add, set_show_add,
set_hover, set_hover,
set_hosts_rev: set_hosts_rev.clone(),
}, },
), ),
// connecting_page / request_access_page / waking_page / settings_page / licenses_page / // connecting_page / request_access_page / waking_page / settings_page / licenses_page /
+42 -23
View File
@@ -332,7 +332,9 @@ fn edit_profile_modal(
.margin(edges(0.0, 6.0, 0.0, 0.0)) .margin(edges(0.0, 6.0, 0.0, 0.0))
.into(), .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) .max_width(420.0)
.horizontal_alignment(HorizontalAlignment::Center) .horizontal_alignment(HorizontalAlignment::Center)
.vertical_alignment(VerticalAlignment::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 // 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 // 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. // reads as part of the page grid rather than floating chrome.
let mut row: Vec<Element> = vec![ // The switcher is ONE combined element (review feedback): the combo and the pencil
text_block("Editing") // 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<Element> = vec![text_block("Editing")
.font_size(13.0) .font_size(13.0)
.foreground(ThemeRef::SecondaryText) .foreground(ThemeRef::SecondaryText)
.vertical_alignment(VerticalAlignment::Center) .vertical_alignment(VerticalAlignment::Center)
.into(), .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 profile's colour, right where the choice is made (a ComboBox item is a plain // 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). // string in this toolkit, so the chip cannot ride inside the dropdown).
if let Some(c) = active if let Some(c) = active
@@ -1368,18 +1390,15 @@ pub(crate) fn settings_page(
.into(), .into(),
); );
} }
if profile_mode { // Keyed by scope + the name list: a rename/create/delete changes the ComboBox's
let set_edit = set_edit.clone(); // 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( row.push(
button("Edit profile\u{2026}") combined
.icon(Symbol::Edit) .with_key(switcher_key)
// Matches the combo — the bar's controls share one height. .vertical_alignment(VerticalAlignment::Center),
.height(36.0)
.on_click(move || set_edit.call(true))
.vertical_alignment(VerticalAlignment::Center)
.into(),
); );
}
hstack(row) hstack(row)
.spacing(12.0) .spacing(12.0)
.margin(edges(24.0, 12.0, 28.0, 8.0)) .margin(edges(24.0, 12.0, 28.0, 8.0))