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