Public Access
Open a group by double-clicking it, and say where you are
ONE PRESS WAS DOING TWO JOBS. A group card was the only place a group could be selected — it is what EDIT and DELETE aim at — and it was also the control that narrowed the grid to that group. So there was no way to name a group in order to rename it without every host outside it leaving the screen at the same moment, and no way back except a SHOW ALL button that appeared beside the heading. Two gestures instead. A click selects and does nothing else; a double-click opens, which is what the host cards below already do to get a shell and what the transfers screen's directories already do to go inside one. The grid now has one vocabulary rather than one per list. The gesture is wired in the code-behind beside the host one, and guarded the same way: a double-click on the space around the cards must not open whichever group happened to be selected. THE CARDS ARE ONE LEVEL NOW, not every group in the keychain. Groups nest, and drawing all of them flat was the only honest thing to do while a card was a filter — a filter nobody can see is a filter nobody can turn off. Once opening one became navigation the cards became its contents, and VisibleGroups is that level beside Groups the way VisibleHosts sits beside Hosts. Groups itself is untouched: it is what every lookup reads and what the phone's headings are built from, and the phone binds none of the new members. Which is what the trail is for. A level with no name and no way out is a grid that has quietly hidden things, so a breadcrumb sits above the cards — drawn exactly as the transfers screen draws a directory path, same flat crumbs and same separator, because it is the same control answering the same question and a window with two breadcrumbs that look different has two ideas of what a path is. The first crumb is always there and always goes back to every host, which is what SHOW ALL was; that button went with it, because a control that only says "stop" beside a trail that says where you are is a second control for one job, and this one also gets you back one level rather than all the way. EDIT AND DELETE AIM AT GroupTarget: the selected card, or the open group when no card is selected. Without the fallback a group with nothing inside it could be opened and then never edited, because opening a group is exactly what takes its own card off the screen. It is also what a file manager does — act on the selection, and on the current folder when there is none — and the pair is now hidden with nothing to act on rather than shown doing nothing. A DANGLING PARENT AND A CYCLE BOTH HAD TO END UP SOMEWHERE REACHABLE. Neither is prevented: a parent id may point at a group deleted on another machine, and two clients can each re-parent A under B and B under A while offline, which no merge can see because the pointer is inside the payload. EffectiveParents promotes both to the outermost level, which is the same degradation the resolver's visited set produces for inheritance. The repair for either is the group's own editor and the editor is opened from its card, so a group drawn nowhere would be a broken state with the fix locked inside it. Three tests in HostGridTests: the split rule through the properties the cards bind, the pointer gesture itself in two windows so that "one press still only selects" is asserted separately from the pair, and a nested group opened, emptied of cards and walked back out of one level. The last presses the trail as it is actually rendered rather than calling the command, because a crumb reaches the vault through a $parent binding — a string that compiles whether or not it resolves, and would otherwise leave a row of buttons that do nothing. 85 layout tests and 234 shell-flow tests pass. Manual-checks 3.2, 7.6 and 7.7 follow the new gestures, and 3.2a and 3.4a are new: nesting, and the two states above, both of which need two machines and neither of which headless Avalonia can reach.
This commit is contained in:
@@ -3,6 +3,7 @@ using Avalonia.Controls;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Session;
|
||||
@@ -208,32 +209,141 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Pressing a group card narrows the grid to that group, and pressing SHOW ALL brings the rest back.
|
||||
/// Driven through the property the card's <c>ListBox</c> binds rather than through a click, because
|
||||
/// what is worth holding is the rule — the filter is a property of the grid, and it also moves the
|
||||
/// selection the group's own EDIT and DELETE act on. A click would test Avalonia's <c>SelectedItem</c>
|
||||
/// binding, which is not this application's code.
|
||||
/// <para>
|
||||
/// The rule one press was split into two gestures for. Selecting a group aims its EDIT and DELETE at it
|
||||
/// and does nothing else; opening one is what narrows the grid, and the trail is the way back out of it.
|
||||
/// While a single press meant both, a group could not be named without every host outside it leaving the
|
||||
/// screen at the same moment.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Driven through the properties the cards bind rather than through a click, because what is worth
|
||||
/// holding here is the rule; the pointer is put on the gesture itself in the test below. A click would
|
||||
/// otherwise be testing Avalonia's <c>SelectedItem</c> binding, which is not this application's code.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ChoosingAGroupNarrowsTheGridAndAimsTheGroupButtonsAtIt()
|
||||
public async Task SelectingAGroupAimsItsButtonsAtItAndOpeningOneNarrowsTheGrid()
|
||||
{
|
||||
var production = vault.Groups.Single();
|
||||
await vault.MoveHostToGroupCommand.ExecuteAsync(
|
||||
new HostGroupMove(Row(vault, "prod-db"), vault.Groups.Single().EntityId));
|
||||
|
||||
vault.MoveHostToGroupCommand.Execute(
|
||||
new HostGroupMove(Row(vault, "prod-db"), production.EntityId));
|
||||
// Re-found after the move, because the reload it ends with replaces every row in the list.
|
||||
var production = vault.VisibleGroups.Single();
|
||||
|
||||
vault.GroupFilter = production;
|
||||
vault.SelectedGroup = production;
|
||||
|
||||
vault.GroupFilter.ShouldBeNull("one press selects a group and does not open it");
|
||||
vault.VisibleHosts.Count.ShouldBe(2, "so the grid still holds the hosts outside it");
|
||||
vault.GroupTarget.ShouldBeSameAs(production, "what EDIT and DELETE act on");
|
||||
vault.ShowsGroupActions.ShouldBeTrue();
|
||||
|
||||
vault.OpenGroupCommand.Execute(production);
|
||||
|
||||
vault.VisibleHosts.Select(row => row.Label)
|
||||
.ShouldBe(["prod-db"], "only what is filed under the chosen group");
|
||||
.ShouldBe(["prod-db"], "only what is filed under the group that is open");
|
||||
|
||||
vault.SelectedGroup.ShouldBeSameAs(production, "what EDIT and DELETE act on");
|
||||
vault.IsFilteredByGroup.ShouldBeTrue();
|
||||
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "production"]);
|
||||
|
||||
vault.ClearGroupFilterCommand.Execute(null);
|
||||
vault.SelectedGroup.ShouldBeNull("the card it was on is not one of the cards on screen any more");
|
||||
vault.GroupTarget.ShouldBeSameAs(
|
||||
production, "so the buttons fall back to the group whose contents are showing");
|
||||
|
||||
vault.VisibleHosts.Count.ShouldBe(2, "SHOW ALL brings back the hosts outside the group");
|
||||
vault.SelectedGroup.ShouldBeNull("nothing is aimed at once the filter is off");
|
||||
// Back out, which is the trail's first crumb and nothing else: SHOW ALL was a second control for the
|
||||
// same job and went with the change.
|
||||
vault.OpenGroupCommand.Execute(vault.GroupTrail[0].Group);
|
||||
|
||||
vault.VisibleHosts.Count.ShouldBe(2, "ALL HOSTS brings back the hosts outside the group");
|
||||
vault.GroupTarget.ShouldBeNull("and nothing is aimed at once no group is open or selected");
|
||||
vault.ShowsGroupActions.ShouldBeFalse("a pair of buttons with no subject is hidden rather than shown");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The gesture, performed. It is wired in the control rather than bound in the markup — which is exactly
|
||||
/// the sort of wiring that compiles whether or not it is connected to anything — and the first half of
|
||||
/// what it asserts is the half that would go unnoticed: one press must still only select, or the split
|
||||
/// bought nothing.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task DoubleClickingAGroupCardOpensIt()
|
||||
{
|
||||
// One press, in a window of its own, because a second pair of clicks in the same one is the gesture
|
||||
// this is separating that press from.
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
{
|
||||
var centre = Centre(GroupCard(screen), window);
|
||||
|
||||
window.MouseDown(centre, MouseButton.Left);
|
||||
window.MouseUp(centre, MouseButton.Left);
|
||||
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.SelectedGroup.ShouldNotBeNull("one press selects the card");
|
||||
vault.GroupFilter.ShouldBeNull("and opens nothing");
|
||||
});
|
||||
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
{
|
||||
var centre = Centre(GroupCard(screen), window);
|
||||
|
||||
window.MouseDown(centre, MouseButton.Left);
|
||||
window.MouseUp(centre, MouseButton.Left);
|
||||
window.MouseDown(centre, MouseButton.Left);
|
||||
window.MouseUp(centre, MouseButton.Left);
|
||||
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.GroupFilter.ShouldNotBeNull().Label.ShouldBe("production");
|
||||
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "production"]);
|
||||
|
||||
// And out again through the trail as it is actually drawn, rather than through the command. A
|
||||
// crumb is an item in a template and the command it presses is the vault's, so the two are
|
||||
// joined by a $parent binding — which is a string that compiles whether or not it resolves, and
|
||||
// would leave a trail of buttons that do nothing.
|
||||
var back = screen.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.First(button => button.DataContext is GroupCrumbViewModel { Group: null });
|
||||
|
||||
back.Command.ShouldNotBeNull("the crumb reached the vault's command").Execute(back.CommandParameter);
|
||||
|
||||
vault.GroupFilter.ShouldBeNull("ALL HOSTS is the way back out");
|
||||
vault.VisibleHosts.Count.ShouldBe(2);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The cards are one level of the tree, and the trail is how that level was reached.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The grid used to draw every group at once, which was the only honest thing to do while a card was a
|
||||
/// filter: a filter nobody can see is a filter nobody can turn off. Once opening a group became
|
||||
/// navigation the cards became its contents — and a level with no name and no way back is a grid that
|
||||
/// has quietly hidden things, which is what the trail is for.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task OpeningANestedGroupShowsWhatIsInsideItAndTheWayBack()
|
||||
{
|
||||
await AddGroupAsync("estate");
|
||||
await FileGroupUnderAsync("production", "estate");
|
||||
|
||||
vault.VisibleGroups.Select(row => row.Label)
|
||||
.ShouldBe(["estate"], "the outermost groups, and production is not one of them any more");
|
||||
|
||||
vault.OpenGroupCommand.Execute(vault.VisibleGroups.Single());
|
||||
|
||||
vault.VisibleGroups.Select(row => row.Label).ShouldBe(["production"], "what is inside estate");
|
||||
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "estate"]);
|
||||
|
||||
vault.OpenGroupCommand.Execute(vault.VisibleGroups.Single());
|
||||
|
||||
vault.HasVisibleGroups.ShouldBeFalse("production has nothing inside it, so the cards fold away");
|
||||
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "estate", "production"]);
|
||||
|
||||
// A middle crumb goes back one level rather than all the way out, which is the whole reason the
|
||||
// trail is a row of buttons instead of a sentence saying where you are.
|
||||
vault.OpenGroupCommand.Execute(vault.GroupTrail[1].Group);
|
||||
|
||||
vault.VisibleGroups.Select(row => row.Label).ShouldBe(["production"]);
|
||||
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "estate"]);
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
@@ -292,6 +402,12 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
},
|
||||
Token);
|
||||
|
||||
private static ListBoxItem GroupCard(HostsScreen screen) =>
|
||||
screen.GroupGrid
|
||||
.GetVisualDescendants()
|
||||
.OfType<ListBoxItem>()
|
||||
.Single(item => item.DataContext is HostGroupRowViewModel);
|
||||
|
||||
private static ListBoxItem CardFor(Visual screen, HostRowViewModel host) =>
|
||||
screen.GetVisualDescendants()
|
||||
.OfType<ListBoxItem>()
|
||||
@@ -304,6 +420,27 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
|
||||
?? throw new InvalidOperationException("the control is not in this window's tree");
|
||||
|
||||
private async Task AddGroupAsync(string label)
|
||||
{
|
||||
vault.GroupEditorLabel = label;
|
||||
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
/// <summary>Files one group under another the way a user can: through the group's own editor.</summary>
|
||||
private async Task FileGroupUnderAsync(string group, string parent)
|
||||
{
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, group, StringComparison.Ordinal));
|
||||
|
||||
vault.EditGroupCommand.Execute(null);
|
||||
|
||||
vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single(
|
||||
choice => string.Equals(choice.Label, parent, StringComparison.Ordinal));
|
||||
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
/// <remarks>Two hosts and a group, so there is a heading in the list and a selection to move off.</remarks>
|
||||
private async Task SeedAsync()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user