Take the group headings out of the host grid, and drop onto a group card

A first group turned the wrap of host cards into an accordion: SidebarRows
interleaves a full-width fold-away heading — chevron, name, count — between the
cards, and in a grid that reads as a dropdown somebody left open. The desktop
grid binds VisibleHosts now. The headings and the fold stay for the phone, whose
list has no room for the row of group cards the desktop draws above the grid.

What a heading said, the card says: HostRowViewModel.GroupLabel, resolved once
per reload like the tag names, drawn as an accent chip and absent from a host in
no group — or in one that has been deleted, which is the same thing to look at.

What a heading also was is the drop target, and that moves to the group cards.
Two things go with it. A host dropped onto another host card used to be filed
beside it, which was legible while a heading named the band of cards it landed
in and is guesswork now; it is refused. And UNGROUPED was how a host was dragged
back out of a group; the way out is the picker in its own editor, which is the
one place "no group" can be said in words.

A drag held at either edge of the grid scrolls it. Without that the gesture only
works for whoever can see both ends of it: the group cards are the first thing in
the scroller, the host may be the fortieth card down, and a drag cannot use the
wheel. A step per drag event rather than a timer, so it follows the pointer and
stops when it stops.

The two heading-shaped tests are replaced. TheHostsGridHoldsCardsAndNoGroupHeadings
asserts the grid's contents rather than only measuring them, because a heading
that came back would lay out perfectly cleanly. TheGroupCardsAreWhatAcceptsADroppedHost
raises a real DragOver over both kinds of card and checks the effects and the
mark — the nearest a headless test gets to a gesture no headless test can
synthesise. manual-checks 3.1-3.2 and 7.6-7.9 follow.
This commit is contained in:
2026-08-03 15:49:58 +02:00
parent 1b7df47537
commit f9d08b738c
8 changed files with 432 additions and 147 deletions
+68 -40
View File
@@ -151,8 +151,17 @@
</StackPanel>
<!-- ============ THE GRID ============ -->
<ScrollViewer Grid.Row="3" HorizontalScrollBarVisibility="Disabled">
<!--
============ THE GRID ============
A drop target that takes nothing itself. Everything the pointer can be over during a drag is inside
this control, and it has to accept one so that the group cards at the top of it stay reachable from
the bottom of a long grid: a host dragged off the fortieth card cannot be dropped on a group card
that has scrolled out of sight, so a drag held at either edge scrolls this while it is in flight.
See HostsScreen.axaml.cs.
-->
<ScrollViewer Grid.Row="3" x:Name="Scroll" HorizontalScrollBarVisibility="Disabled"
DragDrop.AllowDrop="True">
<StackPanel Margin="16,14" Spacing="16">
<!-- ============ GROUPS ============ -->
@@ -198,10 +207,18 @@
SelectedItem is GroupFilter and not SelectedGroup. The two are nearly the same thing here —
GroupFilter assigns SelectedGroup — but only one of them may narrow the grid; see the property
for why the phone's own use of SelectedGroup must not.
◆ THESE CARDS ARE THE DROP TARGET. A host card dragged onto one is filed under that group, and
that is the whole of what a drag does on this screen. It used to be a heading inside the host
grid, and the headings are gone — see the note on the host grid below. A group is a thing that
is drawn once, at the top, where the eye already goes to narrow the grid; a target that is also
the label of what you are aiming at needs no explaining. See the code-behind for the gesture.
-->
<ListBox Classes="tiles" x:Name="GroupGrid"
DragDrop.AllowDrop="True"
ItemsSource="{Binding Groups}"
SelectedItem="{Binding GroupFilter}">
SelectedItem="{Binding GroupFilter}"
ToolTip.Tip="Press a group to show only its hosts. Drag a host card onto one to file it there.">
<ListBox.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ListBox.ItemsPanel>
@@ -257,15 +274,31 @@
focus off the terminal's child window and then calls Focus() on something that refuses it,
leaving the window with nothing focused and the keystrokes going nowhere.
It binds SidebarRows rather than VisibleHosts, so the group headings are still in it. Two
reasons, and neither is decoration: a grid showing every group at once has to say which cards
belong to which, and a heading is the drop target that takes a host out of one group and into
another. See VaultViewModel.RebuildSidebarRows for why a heading is a row rather than a
container, and the code-behind for what a drag does with one.
◆ CARDS ONLY, AND NO HEADINGS. It binds VisibleHosts rather than SidebarRows, which is the
collection with the group headings folded into it. Those headings were a full-width fold-away
bar between the cards — a chevron, a name and a count — and in a wrap of cards they read as a
collapsed dropdown that had somehow been dealt the hosts: the moment a first group existed the
grid stopped being a grid. Two controls saying "group" on one screen was one too many, and the
cards above are the better of the two. SidebarRows and the fold are still the phone's, whose
list has no room for a row of group cards; see Android's HostsScreen.
What a heading also was is the drop target, and that moved to the group cards rather than
going away with it — see the note on them above, and the code-behind for the gesture. A host is
taken back out of a group through its own editor, which is the one place "no group" can be said
plainly.
Which group a card is in is on the card, as a chip. That is what the heading above it used to
say, and one chip per card says it without an accordion.
SelectedItem is still SelectedSidebarRow and deliberately not SelectedHost, though every item
here is now a host. That property swallows the null the ListBox writes back whenever the
collection is rebuilt — which is every filter keystroke and every background sync — where
binding the host selection directly would let that null through, and a null host selection
closes the drawer and disarms a delete somebody is in the middle of confirming. See the
property, and RebuildVisibleHosts on where the real clearing is done.
-->
<ListBox Classes="tiles" x:Name="HostGrid" Focusable="True"
DragDrop.AllowDrop="True"
ItemsSource="{Binding SidebarRows}"
ItemsSource="{Binding VisibleHosts}"
SelectedItem="{Binding SelectedSidebarRow}">
<ListBox.ItemsPanel>
@@ -279,7 +312,8 @@
one per card: a ContextMenu inside a DataTemplate would have the row for its data context,
and the commands here are the vault's. The code-behind selects whatever was right-clicked
before the menu opens, so the selection-based commands act on the card under the pointer,
and cancels the menu outright over a group heading, which has no host to act on.
and cancels the menu outright over the space around the cards, where there is no host to act
on.
-->
<ListBox.ContextMenu>
<ContextMenu>
@@ -290,35 +324,9 @@
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.DataTemplates>
<ListBox.ItemTemplate>
<!--
A heading, and it takes a whole row of the wrap rather than sitting in the flow as another
card: it names the run of cards under it, and a heading the width of one card would read
as the first of them. The stretch is what MinWidth buys — a WrapPanel gives a child the
width it asks for and starts a new line when the line cannot hold it, so a child asking
for more than one line holds is alone on its own.
-->
<DataTemplate DataType="vm:SidebarGroupHeader">
<Button Classes="flat grouphead" MinWidth="640"
Command="{Binding $parent[ListBox].((vm:VaultViewModel)DataContext).ToggleGroupCommand}"
CommandParameter="{Binding}"
HorizontalContentAlignment="Stretch">
<Grid ColumnDefinitions="Auto,Auto,*">
<TextBlock Grid.Column="0" Text="{Binding Chevron}"
Foreground="{StaticResource TextFaint}" FontSize="9"
VerticalAlignment="Center" Margin="0,0,6,0" />
<TextBlock Grid.Column="1" Classes="label" Text="{Binding Label}"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Count}" FontSize="11"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Margin="8,0,0,0" HorizontalAlignment="Left" />
</Grid>
</Button>
</DataTemplate>
<DataTemplate DataType="vm:HostRowViewModel">
<DataTemplate x:DataType="vm:HostRowViewModel">
<Border Classes="tile">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="Auto,*,Auto">
@@ -389,6 +397,26 @@
VerticalAlignment="Top" Margin="6,3,0,0" />
</Grid>
<!--
The group this host is filed under, and only when it is filed under one — which is
the same rule the group cards above follow, and the reason a keychain nobody has
filed anything in looks exactly as it did before groups existed.
This is what the fold-away heading over a run of cards used to say. A chip on the
card says it per host instead, which is the shape the rest of this card already is
and the shape a wrap of cards can carry: it survives the cards being reflowed, it
needs no row of its own, and it is what changes when a card is dropped onto a group.
Accent rather than plain, so the one chip that is a *place* is not read as another
tag. A group whose name the vault cannot resolve leaves this empty rather than
printing an id, for the reason a tag does. See HostRowViewModel.GroupLabel.
-->
<Border Classes="chip accent" Padding="5,0" HorizontalAlignment="Left"
IsVisible="{Binding HasGroup}">
<TextBlock Text="{Binding GroupLabel}" FontSize="9.5"
TextTrimming="CharacterEllipsis" />
</Border>
<!--
The tags this host wears. Under the summary rather than beside it, because a host
can wear several and the line above is three fixed-width facts where these are not.
@@ -412,7 +440,7 @@
</Border>
</DataTemplate>
</ListBox.DataTemplates>
</ListBox.ItemTemplate>
</ListBox>
<!--