using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Markup.Xaml; using Avalonia.Threading; using Avalonia.VisualTree; using DodoSSH.Client.Shell.ViewModels; namespace DodoSSH.Client.Android.Views; /// Design 02 — the host list, and the two gestures that are the whole of this file. /// /// /// ◆ A tap connects, a long press chooses, and once anything is chosen a tap ticks instead. The /// branch in the middle of that is why the tap is handled here rather than bound in the markup: which of /// the two a tap means depends on whether the screen is in selection mode, and Avalonia's bindings cannot /// ask. /// /// /// The handlers are on the ListBox rather than on the row, and the row stays a plain /// Border. A button as the item template swallows the press before the list sees it — /// FilesScreen writes that out at length — so neither handler would ever run. /// /// internal sealed partial class HostsScreen : UserControl { /// /// Whether the press that is about to end was long enough to have meant something else. /// /// /// Avalonia raises Tapped on release whatever the press lasted, so without this a long press /// would tick the row and then the tap it becomes would untick it again — one touch doing a thing and /// undoing it, which is the one outcome that would make both gestures untrustworthy. Set when the hold /// starts and cleared by the tap it suppresses, so it never survives the gesture that set it. /// private bool held; /// /// /// Both gestures are attached here rather than in the markup so that they sit beside the property that /// makes one of them fire at all. is set rather than /// assumed: it is the whole of the long press, and a default that changed would take it away silently — /// every tap would go on working and nothing would ever choose a host again. /// /// /// ◆ FindControl rather than the field the name generator declares for x:Name, /// which is the same rule PhoneShell and TerminalScreen already carry, and which this /// file learned the hard way. That field is /// assigned by the generated InitializeComponent, and no view on this head calls it — they load /// their XAML directly, as above. So the field compiles, is null at run time, and the first thing /// touching it throws. Here that was a in this constructor, which /// is a crash before the first frame: the phone shell builds this control on the way up, so the /// application died on launch rather than on the hosts screen. /// /// public HostsScreen() { AvaloniaXamlLoader.Load(this); var rows = this.FindControl("Rows")!; InputElement.SetIsHoldingEnabled(rows, true); rows.Holding += OnRowHeld; var sheet = this.FindControl("PasswordSheet")!; var box = this.FindControl("PasswordBox")!; sheet.PropertyChanged += (_, e) => { if (e.Property == IsVisibleProperty && sheet.IsVisible) { FocusThePasswordBox(box); } }; } /// /// Puts the focus, and so the keyboard, in the connect sheet's box as the sheet opens. /// /// /// The sheet exists for that one box, and it used to open with nothing focused — a second tap before /// typing could start (audit M12). Posted at Loaded priority because the box is not yet laid out /// on the turn the sheet's visibility changes, and focusing a control with no bounds raises no keyboard. /// private static void FocusThePasswordBox(TextBox box) => Dispatcher.UIThread.Post(() => box.Focus(NavigationMethod.Tab), DispatcherPriority.Loaded); /// /// Connects to the row that was tapped, or ticks it where hosts are already ticked. /// /// /// /// ◆ The branch is the selection, not the row. Once anything is chosen the screen is in selection /// mode — the bar across the top says so — and in that mode a tap adds and removes rather than /// connecting. That is what every Android list does, and the alternative is worse than a rule to /// remember: a tap that connected while five machines sat ticked would open a terminal on top of a /// selection somebody was halfway through building. /// /// /// Fire-and-forget where it connects, as the desktop grid's activation is: the command reports its own /// failures onto the status line — an unknown host key, a refused password — and awaiting it here would /// be an event handler returning a task nothing observes. /// /// private void OnRowTapped(object? sender, TappedEventArgs e) { if (held) { held = false; return; } if (DataContext is not VaultViewModel vault || RowUnder(e) is not { } row) { return; } if (vault.IsChoosingHosts) { vault.ToggleHostChoiceCommand.Execute(row); return; } _ = vault.ConnectToRowCommand.ExecuteAsync(row); } /// Puts a tick against the row that was held, entering selection mode with it. /// /// On rather than on completion, so the bar is up while the finger is /// still down. A long press that showed nothing until release would be a gesture with no way to tell it /// had been recognised, and the only feedback available on this list is the thing it does. /// private void OnRowHeld(object? sender, HoldingRoutedEventArgs e) { if (e.HoldingState != HoldingState.Started) { return; } held = true; if (DataContext is VaultViewModel vault && RowUnder(e) is { } row) { vault.ChooseHostCommand.Execute(row); } } /// /// The host the gesture landed on, or null where it landed on something that is not one. /// /// /// /// ◆ Read off the element under the finger rather than off the list's selection, which is what both /// handlers used to do. The selection was defensible while a tap only ever connected: a tap on a /// group heading moves the selection and the view model bounces it straight back — see /// VaultViewModel.SelectedSidebarRow — so reading it answered "a host, or nothing" for free. /// It stops being defensible the moment a tap can tick one: a heading would then bounce the selection to /// whichever host was last chosen and tick that, which is a machine the user was not pointing /// at going into a set they are about to delete. /// /// /// The ancestor rather than e.Source itself, because the source is whatever leaf the finger /// landed on — a tag chip's TextBlock has a string for a data context, and the row's own /// Ellipse has the row. The is the one element in that chain whose data /// context is always the list's item, whatever kind it is. /// /// private static HostRowViewModel? RowUnder(RoutedEventArgs e) => (e.Source as Visual)?.FindAncestorOfType(includeSelf: true)?.DataContext as HostRowViewModel; }