Public Access
613 lines
27 KiB
C#
613 lines
27 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// The shell window.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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 <see cref="NativeKeyboardFocus"/> for why one direction is
|
|
/// a plain <c>Focus()</c> call and the other is not.
|
|
/// </remarks>
|
|
internal sealed partial class MainWindow : Window
|
|
{
|
|
/// <summary>
|
|
/// The page's request to give the keyboard back to the application.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// It has to come from the page. Once the native child window holds Win32 focus, Avalonia sees no
|
|
/// key events at all, so a <c>KeyBinding</c> on this window could never fire — the terminal is the
|
|
/// only thing that can hear the shortcut and ask to be let go of.
|
|
/// </remarks>
|
|
private const string ReleaseFocusMessage = "dodossh.release-focus";
|
|
|
|
private MainWindowViewModel? shell;
|
|
|
|
private bool wasUnlocked;
|
|
|
|
/// <summary>Which closed-vault card the keyboard was last sent to, so it is sent once per card.</summary>
|
|
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);
|
|
}
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Colours the system-drawn frame the moment there is a handle to colour it on.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <c>OnOpened</c> and not the constructor: the window has no platform handle until it is shown, and
|
|
/// <see cref="NativeWindowFrame"/> does nothing without one. See that class for what the frame is and
|
|
/// why <c>BorderOnly</c> still has one.
|
|
/// </remarks>
|
|
protected override void OnOpened(EventArgs e)
|
|
{
|
|
base.OnOpened(e);
|
|
|
|
NativeWindowFrame.MatchTo(this);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Asks the Linux backend for the one mode it can actually draw inside this window.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Without this the terminal is blank on Linux</b>, 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 <c>SupportedScenarios = NativeDialog</c> — 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 <c>ExperimentalOffscreen</c>
|
|
/// changes the same adapter's answer to <c>OffscreenRenderer</c>, which is the mode Avalonia's
|
|
/// compositor can draw.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <em>Experimental</em> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static void OnTerminalEnvironmentRequested(object? sender, EventArgs e)
|
|
{
|
|
if (e is GtkWebViewEnvironmentRequestedEventArgs gtk)
|
|
{
|
|
gtk.ExperimentalOffscreen = true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Where the keyboard belongs when the terminal is not holding it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Each screen answers for itself, because <c>Focus()</c> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// The window has to be marked <c>Focusable="True"</c> in the markup for that fallback to mean
|
|
/// anything — a <c>Window</c> is not focusable by default, and <c>Focus()</c> 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 <c>Focus()</c> call was failing silently.
|
|
/// </para>
|
|
/// <para>
|
|
/// The terminal answers first, and it has to, because <see cref="MainWindowViewModel.Screen"/> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
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,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Asks for the terminal to take the keyboard, once layout has run.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Posted, not called.</b> 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. <c>NativeControlHost</c> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <c>DispatcherPriority.Loaded</c> runs after layout. It is the same fix and the same reasoning as
|
|
/// <see cref="QuickConnect"/>'s, which posts its own focus for the same race in the other direction.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void FocusTerminalWhenLaidOut() =>
|
|
Dispatcher.UIThread.Post(
|
|
() =>
|
|
{
|
|
if (shell is { IsTerminalShowing: true })
|
|
{
|
|
Terminal.Focus();
|
|
}
|
|
},
|
|
DispatcherPriority.Loaded);
|
|
|
|
/// <summary>
|
|
/// Where the keyboard belongs once the vault is no longer open.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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 <c>Focus()</c> 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.
|
|
/// </remarks>
|
|
private IInputElement ClosedVaultKeyboardHome => shell?.State switch
|
|
{
|
|
ShellState.Locked => UnlockPane.PassphraseBox,
|
|
ShellState.NeedsServer => ServerUrlBox,
|
|
ShellState.NeedsEnrollment => NewPassphraseBox,
|
|
_ => this,
|
|
};
|
|
|
|
/// <summary>
|
|
/// The shortcuts the window owns.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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 <c>KeyBinding</c> so that toggling stays one code path with the
|
|
/// rest of the chord set.
|
|
/// </para>
|
|
/// <para>
|
|
/// The palette's own keys are forwarded rather than answered: <see cref="QuickConnect"/> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
|
|
/// <summary>What Escape closes when the palette is not up: the thing drawn nearest the user.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Escape on a confirmation drawn in place: the sign-out one, or a deletion's.</summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The text-size chords, when the terminal is not the thing hearing them.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
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;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <c>NativeWebView.OnGotFocus</c> pushes Win32 focus into WebView2 for us, so a <c>Focus()</c> 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
|
|
/// <see cref="FocusTerminalWhenLaidOut"/>.
|
|
/// </remarks>
|
|
private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
|
|
|
|
/// <remarks>
|
|
/// The same call for a session that was already open and has just been typed into from the sidebar —
|
|
/// see <see cref="MainWindowViewModel.TerminalFocusRequested"/>. 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.
|
|
/// </remarks>
|
|
private void OnTerminalFocusRequested(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Hands the keyboard to the box on whichever card a closed vault is showing.</summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
private void FocusClosedVaultCard(MainWindowViewModel viewModel)
|
|
{
|
|
closedState = viewModel.State;
|
|
|
|
var home = ClosedVaultKeyboardHome;
|
|
|
|
Dispatcher.UIThread.Post(() => home.Focus(), DispatcherPriority.Loaded);
|
|
}
|
|
|
|
/// <summary>Gives the host-key card the keyboard when it appears, and gives it back when it goes.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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 <see cref="ReleaseKeyboardTo"/> rather than <c>Focus()</c>, and
|
|
/// posted, because the binding that reveals the card has not run yet.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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
|
|
/// <c>PropertyChanged</c>, before the binding that reveals the control, and <c>Focus()</c> on a control
|
|
/// that is still collapsed is a no-op that is not replayed when it is revealed.
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Moves the keyboard when the window swaps a page for a terminal, or one page for another.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// <para>
|
|
/// <c>ReleaseKeyboardTo</c>, not <c>Focus()</c>, in the page direction — and that is the whole of why
|
|
/// this method is worth reading. <b>Collapsing the WebView does not release the keyboard.</b> 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 <c>docs/platform-flags.md</c>, and
|
|
/// <see cref="NativeKeyboardFocus"/> for why only one direction needs the Win32 call.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void OnShowingSomethingElse(MainWindowViewModel viewModel)
|
|
{
|
|
if (!viewModel.IsUnlocked)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (viewModel.IsTerminalShowing)
|
|
{
|
|
FocusTerminalWhenLaidOut();
|
|
}
|
|
else
|
|
{
|
|
ReleaseKeyboardTo(KeyboardHome);
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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. <c>term.focus()</c> in the page only
|
|
/// ever reaches <c>document.activeElement</c>, 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.
|
|
/// </remarks>
|
|
private void OnSelectedTabChanged(MainWindowViewModel viewModel)
|
|
{
|
|
if (viewModel.SelectedTab is not null && viewModel.IsTerminalShowing)
|
|
{
|
|
FocusTerminalWhenLaidOut();
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Both halves are needed. The Win32 call moves the keyboard off the native child window, and the
|
|
/// <c>Focus()</c> 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.
|
|
/// </remarks>
|
|
private void ReleaseKeyboardTo(IInputElement target)
|
|
{
|
|
NativeKeyboardFocus.ReturnTo(this);
|
|
target.Focus();
|
|
}
|
|
}
|