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; 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, _ => 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; } else if (viewModel.IsSearching) { Palette.HandleKey(e); } // v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape // already follows one branch up. 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 "closest thing first" 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. else if (e.Key == Key.Escape && viewModel.IsImportOpen) { viewModel.CloseImportCommand.Execute(null); e.Handled = true; } else if (e.Key == Key.Escape && viewModel.IsSettingsMode) { viewModel.LeaveSettingsCommand.Execute(null); e.Handled = true; } base.OnKeyDown(e); } /// /// 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; 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; 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); } wasUnlocked = unlocked; } /// /// 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(); } }