Files
DodoSSH/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
T
jaap-jan cec73010d3
ci / android head (pull_request) Failing after 12s
ci / build and test (pull_request) Failing after 12s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
Colour the window's frame, inset Hosts like its neighbours, drop Pins
Three things one pass over the shell's chrome turned up, none of them related
to the others beyond having been looked at together.

◆ A PALE STRIP ACROSS THE TOP OF THE WINDOW ON WINDOWS, and it is not this
application's titlebar. Avalonia's Win32 backend gives a BorderOnly window
WS_BORDER | WS_THICKFRAME and then calls DwmExtendFrameIntoClientArea with
one-pixel margins on all four sides — read out of WindowImpl.UpdateWindowProperties
in 12.1.1 rather than guessed at. So DWM owns a hairline of every edge and fills
it with the system's caption and border colours, which follow the user's
personalisation settings: with "show accent colour on title bars and window
borders" on, that is blue against a near-black shell. Nothing in the visual tree
painted those pixels, which is why nothing in the visual tree could cover them.

NativeWindowFrame sets DWMWA_BORDER_COLOR and DWMWA_CAPTION_COLOR to the
window's own Background, so the hairline still exists — the resize grip is on
it, the drop shadow hangs off it — and cannot be seen. Deliberately not
DWMWA_COLOR_NONE, which removes the border outright and leaves a near-black
window with no edge at all on a dark desktop. Windows 10 gets the dark-mode
attribute and nothing else, because the two colour attributes are Windows 11
and DwmSetWindowAttribute simply answers E_INVALIDARG there.

Called from OnOpened, not the constructor: there is no platform handle until
the window is shown, and calling early is a silent no-op — which looks exactly
like a fix that does not work.

Verified on screen on Windows 11.

◆ THE HOSTS HEADER SAT A STEP LEFT OF AND ABOVE EVERY OTHER SCREEN'S. Keychain,
Snips, Logs and Pins all frame their content with Margin="26"; Hosts was on 16
a side and 20 on top. It is 26 all round now, stated per row rather than once on
the root, because the board's ScrollViewer is deliberately full-bleed so that
its scrollbar rides the pane's edge, and because a root margin would also inset
the drawer, which draws its own.

That cost the cards ten pixels, and the layout suite is what said so:
TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen failed, because
Border.tile's 224 was derived from the board's old 16-pixel margins and the grid
quietly collapses to one column at exactly the size this application guarantees.
224 becomes 214, with the arithmetic in App.axaml rewritten — it had also gone
stale in a way that hid itself, still citing the 1016 minimum and 190 rail from
before v5b, whose two changes happened to cancel.

◆ PINS LEAVES THE RAIL, and only the rail. KnownHostsScreen is still built and
still one click away, from "Host keys" on the Keys screen's own header, which
was always the second way in. The row was kept through v5b on the grounds that
the mock has no screen for approved host keys — a reason for the screen to
exist, and never a reason for a rail entry once the keychain had a door to the
same place. Two rows landing on one screen is a rail that has to be read twice.
MainWindowViewModel.IsKnownHostsShowing stays: it names a real shell state and
ShellFlowTests still asserts on it.

design-import-gaps.md recorded that row as a deliberate deviation and
manual-checks.md Phase 1.1 walked the rail entry by entry; both are corrected,
and the manual check now reaches the screen the way a user would.

The layout suite's rail row count moves from six to five with it.

153 layout tests and 446 shell tests pass. The frame is confirmed by eye; the
Hosts inset and the rail are covered by the layout suite but were not seen
running, because the instance launched to check them came up locked.
2026-08-12 10:43:37 +02:00

464 lines
20 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;
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,
_ => 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;
}
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);
}
/// <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;
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;
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;
}
/// <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();
}
}