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:
@@ -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<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_rename: AsyncSetState<Option<(String, String)>>,
|
||||
pub(crate) set_show_add: AsyncSetState<bool>,
|
||||
pub(crate) set_hover: AsyncSetState<Option<String>>,
|
||||
pub(crate) set_hosts_rev: AsyncSetState<u64>,
|
||||
}
|
||||
|
||||
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::<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>| {
|
||||
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<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(
|
||||
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<Element> = vec![header_btn("Add host", Symbol::Add)
|
||||
let mut actions: Vec<Element> = 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::<String>);
|
||||
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();
|
||||
|
||||
@@ -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.
|
||||
// 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<AppCtx>) -> 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 /
|
||||
|
||||
@@ -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<Element> = 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<Element> = 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))
|
||||
|
||||
Reference in New Issue
Block a user