using System.ComponentModel; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Platform; using Avalonia.Threading; using DodoSSH.Client.Shell.ViewModels; namespace DodoSSH.Client.App.Views; /// /// The shell window. /// /// /// Keyboard focus across the Avalonia/WebView boundary is handled here rather than in a view model, /// because it is a property of the controls and not of the state. What the view models expose is the /// facts the policy needs — a session opened, the vault is no longer unlocked, the palette is open — and /// all of them are things they already know. See for why one direction is /// a plain Focus() call and the other is not. /// internal sealed partial class MainWindow : Window { /// /// The page's request to give the keyboard back to the application. /// /// /// It has to come from the page. Once the native child window holds Win32 focus, Avalonia sees no /// key events at all, so a KeyBinding on this window could never fire — the terminal is the /// only thing that can hear the shortcut and ask to be let go of. /// private const string ReleaseFocusMessage = "dodossh.release-focus"; private MainWindowViewModel? shell; private bool wasUnlocked; /// Which closed-vault card the keyboard was last sent to, so it is sent once per card. private ShellState? closedState; private bool wasAskingAboutHostKey; public MainWindow() { InitializeComponent(); DataContextChanged += (_, _) => Attach(DataContext as MainWindowViewModel); // Before anything navigates: the environment is settled once, when the adapter is built. Terminal.EnvironmentRequested += OnTerminalEnvironmentRequested; Terminal.WebMessageReceived += (_, e) => { // Compared against a constant rather than parsed: the page sends exactly one message and // treating anything else as a command would be a wider door than this needs. A string // posted by the page arrives in Body verbatim. if (string.Equals(e.Body, ReleaseFocusMessage, StringComparison.Ordinal)) { ReleaseKeyboardTo(KeyboardHome); } }; } /// /// Colours the system-drawn frame the moment there is a handle to colour it on. /// /// /// OnOpened and not the constructor: the window has no platform handle until it is shown, and /// does nothing without one. See that class for what the frame is and /// why BorderOnly still has one. /// protected override void OnOpened(EventArgs e) { base.OnOpened(e); NativeWindowFrame.MatchTo(this); } /// /// Asks the Linux backend for the one mode it can actually draw inside this window. /// /// /// /// Without this the terminal is blank on Linux, and blank in the most confusing way available: /// the page loads, scripts run, the renderer connects — everything except pixels. Measured on Fedora 44 /// with Avalonia.Controls.WebView 12.0.1, where the backend is WebKitGTK 2.52.5 (WPE, the backend the /// package's Linux notes describe, is not packaged for Fedora at all). In its default mode that adapter /// reports SupportedScenarios = NativeDialog — it can open a window of its own and nothing else, /// so a control asked to host it in place has nothing to show. Setting ExperimentalOffscreen /// changes the same adapter's answer to OffscreenRenderer, which is the mode Avalonia's /// compositor can draw. /// /// /// Windows and macOS are untouched, and by construction rather than by an OS check: the argument is a /// GTK type there and this method does nothing. WebView2 and WKWebView both host in place already. /// /// /// Experimental is the vendor's word and worth repeating. If a future release makes the GTK /// adapter host in place properly, this becomes unnecessary rather than wrong — and if the flag is /// withdrawn, the terminal goes back to being blank on Linux, which is the thing to check first. /// /// private static void OnTerminalEnvironmentRequested(object? sender, EventArgs e) { if (e is GtkWebViewEnvironmentRequestedEventArgs gtk) { gtk.ExperimentalOffscreen = true; } } /// /// Where the keyboard belongs when the terminal is not holding it. /// /// /// /// Each screen answers for itself, because Focus() on a collapsed control is measurably a no-op /// that is not replayed when the control is revealed — so a fixed target would swallow the keyboard /// whenever its own screen was not the one showing. Only two screens have anything focusable on them; /// the other two are prose, and the window is the fallback there. /// /// /// The window has to be marked Focusable="True" in the markup for that fallback to mean /// anything — a Window is not focusable by default, and Focus() on one that is not /// measurably returns false. Without it, closing the palette on Files, Team or Preferences left the /// keyboard nowhere: focus does not stay where it was, because collapsing the control it was on clears /// it outright, and the fallback's own Focus() call was failing silently. /// /// /// The terminal answers first, and it has to, because still /// names a page while a terminal is showing — that is the point of it. Asking the screen would hand the /// keyboard to a host list nobody can see. /// /// private IInputElement KeyboardHome => shell switch { // v5c: settings mode has no keyboard-focused control of its own yet — its pages are read-only prose // and buttons, the same shape the account and logs screens already fall back to the window for. { IsSettingsMode: true } => this, { IsTerminalShowing: true } => Terminal, { Screen: ShellScreen.Keychain } => VaultPane.KeyboardTarget, { Screen: ShellScreen.Hosts } => HostsPane.KeyboardTarget, { Screen: ShellScreen.KnownHosts } => PinsPane.KeyboardTarget, { Screen: ShellScreen.Snippets } => SnippetsPane.KeyboardTarget, { Screen: ShellScreen.Logs } => LogsPane.KeyboardTarget, _ => this, }; /// /// Asks for the terminal to take the keyboard, once layout has run. /// /// /// /// Posted, not called. Every path that reaches here has revealed the WebView in this same turn — /// a session opened from another screen, a tab clicked while a page was showing, the palette closing /// back onto a terminal. NativeControlHost re-pushes its bounds on the next layout pass, so /// focusing microseconds ahead of that pass races exactly the thing the focus depends on, and the /// symptom is silent: a terminal that looks selected and receives nothing until it is clicked. /// /// /// DispatcherPriority.Loaded runs after layout. It is the same fix and the same reasoning as /// 's, which posts its own focus for the same race in the other direction. /// /// /// Re-checked inside the post rather than trusted from outside it, because a turn is long enough for the /// user to have navigated away — closing the last tab, or clicking the rail — and stealing the keyboard /// into a collapsed WebView would leave the window with nothing focused at all. /// /// private void FocusTerminalWhenLaidOut() => Dispatcher.UIThread.Post( () => { if (shell is { IsTerminalShowing: true }) { Terminal.Focus(); } }, DispatcherPriority.Loaded); /// /// Where the keyboard belongs once the vault is no longer open. /// /// /// Two ways out of an unlocked vault, and they land on different screens: locking shows the passphrase /// box, and signing out empties this machine and goes back to asking for a server. Both collapse the /// controls the keyboard was on, and Focus() on a collapsed control is a no-op that is not /// replayed when it is revealed — so a fixed target would leave whoever signed out with a window that /// swallows every keystroke until they click something. /// private IInputElement ClosedVaultKeyboardHome => shell?.State switch { ShellState.Locked => UnlockPane.PassphraseBox, ShellState.NeedsServer => ServerUrlBox, ShellState.NeedsEnrollment => NewPassphraseBox, _ => this, }; /// /// The shortcuts the window owns. /// /// /// /// Ctrl+K is here rather than on the palette because it has to work when the palette is not showing, and /// it is a plain handler rather than a KeyBinding so that toggling stays one code path with the /// rest of the chord set. /// /// /// The palette's own keys are forwarded rather than answered: intercepts them /// on their way down while the focus is inside it, and this is the net for when it is not — a press that /// arrives with nothing focused, or from a control on the screen behind, still has to close the palette /// rather than fall through to whatever is underneath it. /// /// /// None of this reaches the terminal, and it does not need to. Once the WebView's child window holds /// Win32 focus Avalonia sees no key events at all — which is why the terminal has its own way out /// (Ctrl+Shift+F6, handled in the page) and why the shortcuts here can be as ordinary as they like. /// /// protected override void OnKeyDown(KeyEventArgs e) { if (shell is not { } viewModel) { base.OnKeyDown(e); return; } if (e.Key == Key.K && e.KeyModifiers.HasFlag(KeyModifiers.Control)) { viewModel.ToggleSearchCommand.Execute(null); e.Handled = true; } else if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && TerminalFontCommand(viewModel, e.Key) is { } size) { size.Execute(null); e.Handled = true; } // A host-key decision before the palette, because it is drawn over the palette: Escape is CANCEL or // CLOSE, both of which refuse — never the answer that connects. See HostKeyCard.SafeAnswer. else if (e.Key == Key.Escape && viewModel.IsHostKeyDecisionShowing) { viewModel.Vault?.RejectHostKeyCommand.Execute(null); e.Handled = true; } else if (viewModel.IsSearching) { Palette.HandleKey(e); } else if (e.Key == Key.Escape && CloseTheNearestThing(viewModel)) { e.Handled = true; } base.OnKeyDown(e); } /// What Escape closes when the palette is not up: the thing drawn nearest the user. /// /// /// One rule throughout — closest thing first — so the order of these arms is the order things are drawn /// over one another. A confirmation before anything, because it is a question in front of whatever page /// it is on, including a settings page: Escape on the sign-out question cancels it and stays in settings. /// /// /// v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape already /// follows in the caller. Checked after the palette rather than before it: the two states are mutually /// exclusive in practice — opening the palette does not enter settings mode and entering settings does /// not open the palette — but an Escape while both were somehow true should close the thing drawn on /// top, which is the palette. /// /// /// v5c: with the importer up, Escape closes only that — the same rule, and the same one the titlebar's own /// back button follows by showing "Back to preferences" rather than "Back to application" while /// IsImportOpen is true. The members dialog on Settings → Vaults likewise: it is drawn over the page, so /// it goes first and settings stays. /// /// /// Last, because it is the furthest thing back: the hosts drawer, which Escape puts away exactly as its /// own arrow does — an open editor abandoned, the selection kept. See VaultViewModel.CloseDrawer. /// /// private static bool CloseTheNearestThing(MainWindowViewModel viewModel) { if (AnswerNoToTheQuestionShowing(viewModel)) { return true; } var close = viewModel switch { { IsImportOpen: true } => viewModel.CloseImportCommand, { IsSettingsMode: true, Vaults.IsMembersPanelOpen: true } => viewModel.Vaults.CloseMembersPanelCommand, { IsSettingsMode: true } => viewModel.LeaveSettingsCommand, { IsUnlocked: true, IsTerminalShowing: false, Screen: ShellScreen.Hosts, Vault: { IsDrawerOpen: true } vault } => vault.CloseDrawerCommand, _ => null, }; close?.Execute(null); return close is not null; } /// Escape on a confirmation drawn in place: the sign-out one, or a deletion's. /// /// Both answers it gives are CANCEL, which is the only thing a key with no label is allowed to mean on a /// question about destroying something. A deletion question left armed on a screen somebody navigated away /// from is cancelled by it too, which is harmless — it is the same answer the question would get if it /// were on screen — and cheaper than deciding which of the six places that card is drawn is showing. /// private static bool AnswerNoToTheQuestionShowing(MainWindowViewModel viewModel) { if (viewModel.IsConfirmingSignOut) { viewModel.CancelSignOutCommand.Execute(null); return true; } if (viewModel is { IsUnlocked: true, IsTerminalShowing: false, Vault: { IsConfirmingDeletion: true } vault }) { vault.CancelDeleteCommand.Execute(null); return true; } return false; } /// /// The text-size chords, when the terminal is not the thing hearing them. /// /// /// /// The same three chords the page answers, and the duplication is the point rather than an oversight: /// the page hears them only while a terminal has focus, and the whole reason somebody reaches for them /// is often that they are looking at a terminal they cannot read from a screen that is not it — the /// host list, or preferences. Both routes end in the same commands on the shell. /// /// /// Both keys for plus, because a keyboard has two of them and neither is more correct: OemPlus is the /// one beside Backspace, Add is the one on the numeric pad. Same for minus. /// /// private static System.Windows.Input.ICommand? TerminalFontCommand( MainWindowViewModel viewModel, Key key) => key switch { Key.OemPlus or Key.Add => viewModel.EnlargeTerminalFontCommand, Key.OemMinus or Key.Subtract => viewModel.ShrinkTerminalFontCommand, Key.D0 or Key.NumPad0 => viewModel.ResetTerminalFontCommand, _ => null, }; private void Attach(MainWindowViewModel? viewModel) { if (shell is { } previous) { previous.TerminalSessionOpened -= OnTerminalSessionOpened; previous.TerminalFocusRequested -= OnTerminalFocusRequested; previous.PropertyChanged -= OnShellPropertyChanged; } shell = viewModel; if (viewModel is null) { return; } // Navigation happens once the data context is known, because the URL carries the port the // loopback listener was assigned. Setting Source in XAML would need a constant port, and a // fixed port is one that another process can already be holding. Terminal.Source = viewModel.TerminalPageUrl; wasUnlocked = viewModel.IsUnlocked; closedState = null; wasAskingAboutHostKey = viewModel.IsHostKeyDecisionShowing; // The shell may have reached its card before this window was given it, and then no change would // ever say so. if (!viewModel.IsUnlocked) { FocusClosedVaultCard(viewModel); } viewModel.TerminalSessionOpened += OnTerminalSessionOpened; viewModel.TerminalFocusRequested += OnTerminalFocusRequested; viewModel.PropertyChanged += OnShellPropertyChanged; } /// /// NativeWebView.OnGotFocus pushes Win32 focus into WebView2 for us, so a Focus() call is /// the whole fix in this direction — but it has to happen while the control is visible, and it no longer /// reliably is at this instant. A session can now be opened from any screen, so this event routinely /// arrives in the same turn that revealed the WebView. Hence the post; see /// . /// private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut(); /// /// The same call for a session that was already open and has just been typed into from the sidebar — /// see . Posted like every other path here, /// although nothing was revealed this turn: the post also re-checks that a terminal is still showing, /// which is what keeps this from stealing the keyboard if the insert landed the user on the snippets /// screen instead. /// private void OnTerminalFocusRequested(object? sender, EventArgs e) => FocusTerminalWhenLaidOut(); /// /// A dispatch and nothing else. Every arm below is a separate decision about where the keyboard goes, /// and they were one method until the four of them stopped fitting in a screenful — which is roughly the /// point at which "does this one return early" stops being obvious to a reader. /// private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (shell is not { } viewModel) { return; } switch (e.PropertyName) { case nameof(MainWindowViewModel.IsUnlocked): OnVaultOpenedOrClosed(viewModel); break; case nameof(MainWindowViewModel.IsSearching): OnPaletteToggled(viewModel); break; // One arm for both, deliberately. They mean the same thing to this handler — what the window is // showing may have changed — and answering them separately would make the order of two // PropertyChanged raises decide the outcome. Connecting from the palette moves both. case nameof(MainWindowViewModel.Surface): case nameof(MainWindowViewModel.Screen): OnShowingSomethingElse(viewModel); break; case nameof(MainWindowViewModel.SelectedTab): OnSelectedTabChanged(viewModel); break; case nameof(MainWindowViewModel.IsHostKeyDecisionShowing): OnHostKeyDecision(viewModel); break; default: break; } } private void OnVaultOpenedOrClosed(MainWindowViewModel viewModel) { var unlocked = viewModel.IsUnlocked; // Only the transition out of unlocked matters. IsUnlocked is re-raised for every shell state // change, and reacting to all of them would move focus during setup and sign-in. if (wasUnlocked && !unlocked) { ReleaseKeyboardTo(ClosedVaultKeyboardHome); } else if (!unlocked && viewModel.State != closedState) { FocusClosedVaultCard(viewModel); } closedState = unlocked ? null : viewModel.State; wasUnlocked = unlocked; } /// Hands the keyboard to the box on whichever card a closed vault is showing. /// /// For arriving at a card other than by locking — launch landing on the unlock card, a sign-in finishing /// on the enrolment one — which used to leave the passphrase somebody started typing going nowhere. Once /// per card, which is what keeps it from moving the keyboard while somebody is mid-sign-in: IsUnlocked is /// raised for every state change, and the state does not change while they type. Posted, because the card /// is revealed by a binding the same change has not reached yet. /// private void FocusClosedVaultCard(MainWindowViewModel viewModel) { closedState = viewModel.State; var home = ClosedVaultKeyboardHome; Dispatcher.UIThread.Post(() => home.Focus(), DispatcherPriority.Loaded); } /// Gives the host-key card the keyboard when it appears, and gives it back when it goes. /// /// /// It used to appear with the keyboard wherever it had been — often inside the terminal it covers, whose /// native window goes on holding it after the WebView is collapsed, so the card took no keys at all and /// Tab walked the page under it. Hence rather than Focus(), and /// posted, because the binding that reveals the card has not run yet. /// /// /// Latched, because the flag is re-raised with every change to the terminal's state and the keyboard must /// move once per question, not once per raise. On the way out the keyboard goes where the window would /// put it for whatever is now showing; a connection that went ahead moves it again when its terminal opens. /// /// private void OnHostKeyDecision(MainWindowViewModel viewModel) { var asking = viewModel.IsHostKeyDecisionShowing; if (asking == wasAskingAboutHostKey) { return; } wasAskingAboutHostKey = asking; Dispatcher.UIThread.Post( () => { if (shell != viewModel || viewModel.IsHostKeyDecisionShowing != asking) { return; } if (asking) { ReleaseKeyboardTo(HostKeyPane.SafeAnswer); } else { OnShowingSomethingElse(viewModel); } }, DispatcherPriority.Loaded); } /// /// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected /// to start typing into immediately — but the palette does that for itself when it becomes visible, /// which is a moment this handler is measurably ahead of: it runs from the view model's /// PropertyChanged, before the binding that reveals the control, and Focus() on a control /// that is still collapsed is a no-op that is not replayed when it is revealed. /// private void OnPaletteToggled(MainWindowViewModel viewModel) { if (viewModel.IsSearching) { return; } // Closing the palette over a terminal reveals the WebView in this same turn, so it needs the posted // focus rather than the immediate one. if (viewModel.IsTerminalShowing) { FocusTerminalWhenLaidOut(); } else { ReleaseKeyboardTo(KeyboardHome); } } /// /// Moves the keyboard when the window swaps a page for a terminal, or one page for another. /// /// /// The most common gesture in the window now that the strip spans every screen: a tab and a rail entry /// are both one click away at all times. /// /// ReleaseKeyboardTo, not Focus(), in the page direction — and that is the whole of why /// this method is worth reading. Collapsing the WebView does not release the keyboard. The native /// child window goes on holding Win32 focus, Avalonia then sees no key events at all, and the screen /// that just appeared silently swallows every keystroke. It was a latent defect while leaving a terminal /// was rare; it is the hot path now. See docs/platform-flags.md, and /// for why only one direction needs the Win32 call. /// /// private void OnShowingSomethingElse(MainWindowViewModel viewModel) { if (!viewModel.IsUnlocked) { return; } if (viewModel.IsTerminalShowing) { FocusTerminalWhenLaidOut(); } else { ReleaseKeyboardTo(KeyboardHome); } } /// /// Clicking a tab moves both Win32 and Avalonia focus onto the button that was clicked — the click is /// what took the WebView's Win32 focus away in the first place. term.focus() in the page only /// ever reaches document.activeElement, which does nothing for a page that no longer holds the /// native focus, so without this the pane looks selected and every keystroke goes to the button instead /// of the shell until the user clicks inside the terminal by hand. /// private void OnSelectedTabChanged(MainWindowViewModel viewModel) { if (viewModel.SelectedTab is not null && viewModel.IsTerminalShowing) { FocusTerminalWhenLaidOut(); } } /// /// Both halves are needed. The Win32 call moves the keyboard off the native child window, and the /// Focus() gives it somewhere to go — collapsing the terminal leaves Avalonia with no /// focused element, so the keystrokes would otherwise reach the window and stop there. /// private void ReleaseKeyboardTo(IInputElement target) { NativeKeyboardFocus.ReturnTo(this); target.Focus(); } }