using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.VisualTree; using DodoSSH.Client.Shell.ViewModels; namespace DodoSSH.Client.App.Views; /// /// The flat board of host cards, and everything on it that is a gesture rather than a binding. /// /// /// /// ◆ v5: THE GRID OF GROUP CARDS AND THE BREADCRUMB TRAIL ARE GONE, AND SO IS THE DRAG. The desktop /// used to hold one level of the group tree at a time — a wrap of group cards above the hosts, opened by a /// double-click, with a trail above it saying where you were — the way a directory pane holds one directory. /// That model is retired outright rather than folded into the new board: every group is now a heading, in /// label order, all of them at once, exactly the flattening the phone's SidebarRows has always drawn. /// A host is filed under a group through the group's own picker in its editor, or through the "Change /// group…" entry the chosen-hosts menu already carried — dragging a card onto another card had exactly one /// destination, a group card, and that card is what left. See VaultViewModel.HostSections. /// /// /// ◆ ONE SECTION, ONE ListBox. The two shapes worth weighing were a single mixed list of /// header rows and host rows in one WrapPanel, or an ItemsControl of sections each holding its /// own card list. The first is what the phone's single flat list already is, and reusing it here would have /// meant a header row pretending to be as wide as the row it sits on so the wrap panel breaks a line before /// and after it — a real technique, and a fragile one to get right at every width this window can be resized /// to. The second costs a real thing: there is no longer one ListBox owning one SelectedItem, /// so multi-select and the marquee have to be taught to reach across however many section lists are on /// screen. That is what most of this file now does. It was chosen anyway, because a WrapPanel that /// only ever holds cards of one kind is the ordinary, well-trodden case, and "reach across N lists instead /// of one" is a bounded, mechanical problem — enumerate every list, not the fixed one named in the markup — /// where "make a wrap panel break a line for one specific child" is a harder one to be sure of without a /// custom panel this application does not otherwise need. /// /// /// ◆ SELECTION IS ONE-WAY INTO EVERY SECTION AND WRITTEN BY HAND ON THE WAY OUT. Each section's /// ListBox binds SelectedItem from VaultViewModel.SelectedSidebarRow so the right card /// lights up wherever it lives, but not back — a two-way binding shared by several independently-rebuilt /// lists would have each list's own Reset (one per filter keystroke, one per background sync) racing /// to null the one shared property, which is the same hazard VaultViewModel.RebuildVisibleHosts /// already documents for a single list and multiplies it by the section count. So a card being pressed is /// what actually moves the selection, here in code, exactly as a Ctrl or Shift modifier already had to be — /// see . /// /// /// ◆ THERE ARE STILL TWO SELECTIONS ON THIS SCREEN, AND THEY ARE STILL NOT THE SAME THING. /// SelectedHost is the card the drawer, CONNECT and the ordinary menu are about; the chosen set is /// what Ctrl, Shift and the band build on top of it, as IsChosen on the row. Every handler below that /// reads a modifier is about keeping the two from being confused for one another, on the same terms this /// screen has always used — see VaultViewModel.ChooseHosts and Android's HostsScreen. /// /// /// Its data context is the VaultViewModel, as 's is. The window hands it /// over; see . /// /// internal sealed partial class HostsScreen : UserControl { /// How far the pointer has to travel before a press on the empty space becomes a band. /// /// A threshold, because a press on the space around the cards is nearly always a click that drops the /// chosen set — see . Starting a band on the press itself would turn every one /// of those into a rectangle nobody asked for. /// private const double DragThreshold = 5; /// Where a Shift-click measures its run from: the last card pressed without one. /// /// Kept here rather than in the vault because it is a fact about the gesture rather than about the /// keychain — it is what the pointer last touched, and it means nothing to the phone or to any command. /// Null until something has been pressed, which is what a Shift-click into an untouched board falls back /// to the selected card for. /// private HostRowViewModel? anchor; /// Whether a band is being dragged out over the board right now. private bool banding; /// The corner it was started from, in the scroller's own coordinates. private Point bandFrom; /// Whether that band adds to the set rather than being the whole of it. /// /// Read once, when the band starts, rather than per move. A modifier picked up halfway through a drag /// would change what the rectangle already crossed means, which is a selection nobody could predict. /// private bool bandAdds; public HostsScreen() { InitializeComponent(); // Wired here rather than in the markup because it is a gesture rather than a binding, which is how // the transfers screen opens a directory too. Attached to the board rather than to any one section's // list, since double-tapping a card in any of them means the same thing and DoubleTapped bubbles. Board.DoubleTapped += OnHostActivated; // Tunnelled, so the card under the pointer is read before whichever section's ListBox has answered // the press itself. Bubbling would work for a plain click but not for the menu or for Ctrl/Shift: by // the time either reaches this control the list has already decided what it thinks is selected. Board.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel); Board.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel); WireTheBand(); HostFilter.TextChanged += OnFilterTyped; } /// How long the find box waits for the typing to pause before the board is rebuilt. private static readonly TimeSpan FilterPause = TimeSpan.FromMilliseconds(150); /// The wait itself, replaceable so a test can say when a pause is over rather than sleep through one. internal Func Pause { get; set; } = static span => Task.Delay(span); /// How many times the find box has changed, so a wait can tell it is no longer the latest. private int typed; /// Hands the find box's text to the vault once the typing pauses. /// /// /// Every change to the filter rebuilds the board's sections and every card in them, and a name typed at /// speed used to do that once a letter. Now it is done once, with the whole of what was typed. /// /// /// A wait rather than a binding delay, and on the dispatcher's own queue: the box is bound one way, so a /// filter the vault sets itself — cleared, or restored — still lands in the box at once, and the /// change it makes to the box comes back here as the same text, which changes nothing. /// /// private void OnFilterTyped(object? sender, TextChangedEventArgs e) => _ = PassTheFilterOnAsync(HostFilter.Text ?? string.Empty, ++typed); private async Task PassTheFilterOnAsync(string text, int keystroke) { await Pause(FilterPause).ConfigureAwait(true); // A later letter has its own wait, and it is the one that speaks. if (keystroke != typed) { return; } if (Vault is { } vault && !string.Equals(vault.HostFilter, text, StringComparison.Ordinal)) { vault.HostFilter = text; } } /// The band's own wiring, and the two keys that go with a set. /// /// Split out of the constructor rather than sitting in it, and only because the constructor is at the /// length this repository's analyser allows for one. Everything here arrived together: it is the whole /// of choosing more than one card with a pointer. /// private void WireTheBand() { // Esc and Ctrl+A, on the board rather than on the window: both are ordinary editing keys that mean // something else everywhere else, and Ctrl+A in the find box above has to go on selecting the text // in it. The board takes focus on a press, so the keys work from the moment anything has been // touched; see KeyboardTarget for the other half of who has the keyboard on this screen. Board.KeyDown += OnGridKey; // The arrows' half of the selection, which the pointer's half writes by hand in OnPointerPressed. See // OnCardSelectionChanged for why this is not simply a two-way binding. Board.AddHandler(SelectingItemsControl.SelectionChangedEvent, OnCardSelectionChanged); // Tunnelled, because a section's list answers Enter itself and nothing bubbles past it. Board.AddHandler(KeyDownEvent, OnCardEnter, RoutingStrategies.Tunnel); // ◆ The band is the scroller's rather than any one section list's, because the space it is dragged // out over is mostly not a list's: a WrapPanel of cards is exactly as tall as its cards, and the gaps // between sections and below the last one belong to the stack around them. Tunnelled for the reason // the board's own press is, and it runs first, so it has to recognise a press on a card and leave it // alone. Scroll.AddHandler(PointerPressedEvent, OnBandPressed, RoutingStrategies.Tunnel); Scroll.PointerMoved += OnBandMoved; Scroll.PointerReleased += OnBandReleased; Scroll.PointerCaptureLost += OnBandCaptureLost; } /// /// Null before the window has handed one over, and while the previewer is showing this control with no /// data context at all. Every handler below checks rather than assuming. /// private VaultViewModel? Vault => DataContext as VaultViewModel; /// /// Where the keyboard should land when the terminal hands it back. /// /// /// /// Exposed as a property rather than left for the window to find by name, because the name is inside /// this control's template and the window cannot see it. /// /// /// It has to be a control the keyboard can actually go to. Focus() on a collapsed control is /// measurably a no-op and is not replayed when the control is revealed, so handing the keyboard to /// something that is not there would swallow it. Board is focusable and holds every section, so it /// answers as long as one card is on it; an empty board is what a filter matching nothing produces, and /// the find box is the answer then — it is where they were typing. /// /// internal IInputElement KeyboardTarget => Vault is { HasHostBoardEntries: true } ? Board : HostFilter; /// /// Fire-and-forget, as the transfers screen's is: the command reports its own failures onto the status /// line — an unknown host key, a refused password — and awaiting it here would mean an event handler /// returning a task nothing observes. /// private void OnHostActivated(object? sender, TappedEventArgs e) { // Only over a card. A double-tap on the space around them, or on a section heading, must not connect // to whichever host was selected before — which is what an unguarded handler would do, on a machine // the user is not even pointing at. if (Vault is { } vault && RowUnder(e.Source) is HostRowViewModel) { _ = vault.ConnectCommand.ExecuteAsync(null); } } /// /// Points the menu at whatever was right-clicked. /// /// /// /// The menu's commands read the vault's host selection, and a right click does not move it — which would /// mean a menu that quietly acted on whichever host happened to be selected instead of the one under the /// pointer. Deleting the wrong machine is the version of that mistake worth designing against. /// /// /// ◆ A right click outside the ticked set drops the set, and one inside it keeps it. That is the /// same rule as the plain press below, and it is what makes the two halves of the menu safe to draw from /// one markup: the entries about a set and the entries about a selection are never both meaningful, so /// Delete… can never be a question about the card under the pointer asked while six others sit ticked /// behind the menu. /// /// /// Cancelled outright over the space around the cards, or on a section heading. Neither is a host, and a /// menu offering Connect, Edit and Delete over either would be entries that either do nothing or act on /// something else entirely. /// /// private void OnContextRequested(object? sender, ContextRequestedEventArgs e) { if (Vault is not { } vault || RowUnder(e.Source) is not HostRowViewModel row) { e.Handled = true; return; } if (vault.IsChoosingHosts && !row.IsChosen) { vault.ClearHostChoiceCommand.Execute(null); } if (!vault.IsChoosingHosts) { vault.SelectedHost = row; } } /// /// Ticks cards, or moves the selection. /// /// /// /// ◆ Ctrl and Shift are answered here and go no further. Both mark the event handled, which is /// what stops whichever section's ListBox the press landed in from moving its own selection onto /// the card: a Ctrl-click that also selected would light the card it had just unticked, and the drawer /// would open on a machine the user was removing from a set. Handled on the way down is the only place /// that can be said — by the time the press bubbles the control has already answered it. /// /// /// A plain press moves the selection and drops the chosen set, which is every file manager's rule /// and the reason the two selections on this screen can never disagree: after an ordinary click there is /// exactly one card in play. Not marked handled — the section list's own native selection is left to /// follow along, which is harmless now that it is only OneWay bound: whatever it shows locally is /// overwritten the moment 's change reaches every section again. /// /// /// Nothing here refuses while the drawer's editor is open, and the phone's own ChooseHost does. /// The difference is real: there a sheet is over the list and the row under the finger is not what was /// aimed at, where the board sits beside the editor in plain view. Ticking is free anyway — the seven /// things that can then be done to a set each refuse for themselves, and say so. /// /// private void OnPointerPressed(object? sender, PointerPressedEventArgs e) { if (Vault is not { } vault || !e.GetCurrentPoint(Board).Properties.IsLeftButtonPressed || RowUnder(e.Source) is not HostRowViewModel row) { return; } var control = e.KeyModifiers.HasFlag(KeyModifiers.Control); if (e.KeyModifiers.HasFlag(KeyModifiers.Shift)) { vault.ChooseHostRun(anchor ?? vault.SelectedHost, row, replacing: !control); Board.Focus(); e.Handled = true; return; } if (control) { // Extend rather than toggle: the set this starts has the lit card in it, which is the half a // plain toggle left out. See VaultViewModel.ExtendHostChoice. vault.ExtendHostChoiceCommand.Execute(row); anchor = row; Board.Focus(); e.Handled = true; return; } anchor = row; if (vault.IsChoosingHosts) { vault.ClearHostChoiceCommand.Execute(null); } vault.SelectedHost = row; } /// /// Esc drops the set, and Ctrl+A is every card on the board. /// /// /// /// Every card on the board: , which is every section /// with whatever the find box and the two toolbar flyouts have already narrowed it to, and every card a /// fold has not hidden. Ctrl+A over a filtered board that quietly ticked machines it is not showing would /// be the worst possible input to Delete. /// /// /// Handled only when they did something. Esc has other jobs on this window — it closes the palette, and /// it cancels the terminal's own things — and swallowing it here while nothing is ticked would take it /// away from whichever of those the user meant. /// /// private void OnGridKey(object? sender, KeyEventArgs e) { if (Vault is not { } vault) { return; } if (e.Key is Key.Escape && vault.IsChoosingHosts) { vault.ClearHostChoiceCommand.Execute(null); e.Handled = true; return; } // An arrow with the board itself focused — which is where this screen puts the keyboard, see // KeyboardTarget — goes into the cards: onto the selected one, or the first. Otherwise no arrow ever // reaches a section's list, since none of them has the keyboard until a card has been clicked. if (e.Key is Key.Up or Key.Down or Key.Left or Key.Right && ReferenceEquals(e.Source, Board)) { e.Handled = EnterTheCards(vault); return; } if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasHostBoardEntries) { var order = vault.HostBoardOrder.ToList(); vault.ChooseHosts(order, replacing: true); anchor = order[0]; e.Handled = true; } } /// /// Starts a band on the empty space between and below the cards. /// /// /// /// ◆ Only where there is nothing to press. The scroller holds every section heading, every card /// list and whichever panel the set's menu raised, and a rectangle dragged out from inside any of those /// would be a gesture aimed at hosts started on something that is not one. is /// the guard, and it is ancestor-based rather than naming one control, which is what lets it work /// regardless of how many section lists happen to be realized. /// /// /// The pointer is captured, because a band is nearly always dragged past the edge of the control it /// started in — down onto the status bar, or off the window entirely — and without capture the moves /// stop arriving and the release lands somewhere else, leaving a rectangle painted over the board with /// nothing left to take it down. /// /// /// Nothing is ticked or unticked here. A press is not yet a band and may never become one; what a click /// on the empty space means is decided on the release. See . /// /// /// Enter on a card is the double-press, for somebody who got there with the arrows. /// /// Only on a card, for the reason gives: Enter with the board itself focused /// would connect to whatever was selected before, which is not something anyone is looking at. And not /// while cards are ticked, where the selection is not what the set is about. /// private void OnCardEnter(object? sender, KeyEventArgs e) { if (e.Key is not Key.Enter || e.KeyModifiers != KeyModifiers.None || Vault is not { IsChoosingHosts: false } vault || RowUnder(e.Source) is not HostRowViewModel row) { return; } vault.SelectedHost = row; _ = vault.ConnectCommand.ExecuteAsync(null); e.Handled = true; } /// Writes a selection the arrows made in one section's list back to the vault. /// /// /// The cards' SelectedItem is one-way, for the reason the markup gives: several lists rebuilt /// independently would each null the vault's selection on every filter keystroke and every sync pass. So /// the pointer writes it by hand, and until this nothing wrote it for the arrows — a card lit by the /// keyboard was not the one the drawer's CONNECT and DELETE then acted on, which is the same mistake the /// right-click handler exists to prevent. /// /// /// ◆ Only from the list that has the keyboard, and only for a card. A rebuild's reset arrives as a change /// to nothing, and the binding's own refills of the lists nobody is in arrive without the keyboard — both /// are exactly the writes the one-way binding keeps out, and they stay out. A click arrives here too, after /// has already written the same card, and changes nothing. /// /// private void OnCardSelectionChanged(object? sender, SelectionChangedEventArgs e) { if (Vault is not { } vault || e.Source is not ListBox { IsKeyboardFocusWithin: true, SelectedItem: HostRowViewModel row } || ReferenceEquals(row, vault.SelectedHost)) { return; } if (vault.IsChoosingHosts) { vault.ClearHostChoiceCommand.Execute(null); } anchor = row; vault.SelectedHost = row; } /// Hands the keyboard from the board to a card: the selected one if it is drawn, else the first. private bool EnterTheCards(VaultViewModel vault) { var cards = Board.GetVisualDescendants().OfType().Where(card => card.IsEffectivelyVisible).ToList(); var card = cards.Find(card => ReferenceEquals(card.DataContext, vault.SelectedHost)) ?? cards.FirstOrDefault(); if (card?.DataContext is not HostRowViewModel row) { return false; } vault.SelectedHost = row; anchor = row; return card.Focus(NavigationMethod.Directional); } private void OnBandPressed(object? sender, PointerPressedEventArgs e) { EndBand(); if (Vault is null || !e.GetCurrentPoint(Scroll).Properties.IsLeftButtonPressed || TakesThePress(e.Source)) { return; } banding = true; bandFrom = e.GetPosition(Scroll); bandAdds = e.KeyModifiers.HasFlag(KeyModifiers.Control); e.Pointer.Capture(Scroll); Board.Focus(); } /// /// The rectangle is drawn and the set is rewritten on every move, so what is ticked is what the band is /// over at that moment — including the cards it has just been pulled back off. A band that only ever /// added would make overshooting it unrecoverable without starting again. Ctrl is the exception and is /// the reason it is a mode read once: with it held the band adds to what was already ticked, which is how /// a second run is picked up without losing the first. /// private void OnBandMoved(object? sender, PointerEventArgs e) { if (!banding || Vault is not { } vault) { return; } if (!e.GetCurrentPoint(Scroll).Properties.IsLeftButtonPressed) { EndBand(); return; } var at = e.GetPosition(Scroll); var box = new Rect( Math.Min(bandFrom.X, at.X), Math.Min(bandFrom.Y, at.Y), Math.Abs(at.X - bandFrom.X), Math.Abs(at.Y - bandFrom.Y)); if (box.Width < DragThreshold && box.Height < DragThreshold) { return; } Band.Margin = new Thickness(box.X, box.Y, 0, 0); Band.Width = box.Width; Band.Height = box.Height; Band.IsVisible = true; vault.ChooseHosts(CardsIn(box), replacing: !bandAdds); } /// /// A press and a release with no band between them is a click on the space around the cards, and that /// drops the set — the same thing a plain click on a card does, and the way out of selection mode for /// anybody who never finds Esc. Where a band was drawn the moves have already said what is /// ticked, and re-applying it here would only repeat the last one. /// private void OnBandReleased(object? sender, PointerReleasedEventArgs e) { if (!banding) { return; } var drawn = Band.IsVisible; EndBand(); e.Pointer.Capture(null); if (!drawn && Vault is { IsChoosingHosts: true } vault) { vault.ClearHostChoiceCommand.Execute(null); } } /// /// The band is taken down and nothing is decided. Capture is lost when a menu opens over the drag, when /// the window is deactivated, and when the platform simply takes it — none of which is the user saying /// what they wanted; leaving the rectangle up would be the only visible consequence. /// private void OnBandCaptureLost(object? sender, PointerCaptureLostEventArgs e) => EndBand(); private void EndBand() { banding = false; Band.IsVisible = false; } /// The hosts whose cards a rectangle over the scroller touches. /// /// Touches rather than contains, which is what makes a band usable at all: a rectangle that had to /// swallow a card whole would tick nothing for a band drawn down the middle of a column. Gathered from /// every section's own list — see — rather than one named control, since /// a band drawn across two sections has to touch cards in both. /// private List CardsIn(Rect box) { var hit = new List(); foreach (var container in AllCardContainers()) { if (container.DataContext is HostRowViewModel row && container.TranslatePoint(default, Scroll) is { } corner && box.Intersects(new Rect(corner, container.Bounds.Size))) { hit.Add(row); } } return hit; } /// Every realized card, across every section's own list. /// /// A section is a ListBox of its own — see the type's remarks — so there is no single control /// whose GetRealizedContainers() would answer for the whole board. Found by class rather than by /// name, because how many sections exist is a fact about the keychain rather than the markup: a flat /// keychain draws one, a filed one draws several, and neither shape is known until the vault says so. /// private List AllCardContainers() => [.. this.GetVisualDescendants() .OfType() .Where(list => list.Classes.Contains("sectioncards")) .SelectMany(list => list.GetRealizedContainers()) .OfType()]; /// Whether what was pressed is something that answers a press itself. /// /// Everything a band must not start from, asked as "is this inside one" rather than by hit-testing a /// rectangle: the cards, the scrollbar, and the controls on whichever panel is up or in a section's own /// heading. Missing the scrollbar is the one that would be felt every day — dragging the thumb would /// paint a band down the board and tick everything it passed. /// private static bool TakesThePress(object? source) => source is Visual visual && visual.GetSelfAndVisualAncestors().Any(element => element is ListBoxItem or ScrollBar or Button or ComboBox or TextBox); /// The view model of the grid item an event happened on, if it happened on a host card. private static HostRowViewModel? RowUnder(object? source) => Container(source)?.DataContext as HostRowViewModel; /// /// Walks up from whatever was actually hit — a text block, a border, the card's own grid — because that /// is what an event's source is. Anything not inside a card, which includes a section's own heading and /// the space around them, yields null. /// private static ListBoxItem? Container(object? source) => source is Visual visual ? visual.FindAncestorOfType(includeSelf: true) : null; }