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();
}
}