diff --git a/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml b/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml
index 93b1e01..751141d 100644
--- a/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml
+++ b/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml
@@ -59,7 +59,7 @@
-
+
diff --git a/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml.cs b/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml.cs
index 298eed1..88efb73 100644
--- a/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml.cs
@@ -1,4 +1,5 @@
using Avalonia.Controls;
+using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
@@ -12,5 +13,10 @@ namespace DodoSSH.Client.App.Views;
///
internal sealed partial class ConfirmDeleteCard : UserControl
{
- public ConfirmDeleteCard() => InitializeComponent();
+ public ConfirmDeleteCard()
+ {
+ InitializeComponent();
+
+ SafeAnswerFocus.Follow(this, nameof(VaultViewModel.PendingDeletion), () => CancelButton);
+ }
}
diff --git a/src/DodoSSH.Client.App/Views/HostKeyCard.axaml b/src/DodoSSH.Client.App/Views/HostKeyCard.axaml
index e878d3c..f743fcb 100644
--- a/src/DodoSSH.Client.App/Views/HostKeyCard.axaml
+++ b/src/DodoSSH.Client.App/Views/HostKeyCard.axaml
@@ -45,7 +45,11 @@
80% of Canvas, written out because a scrim is a brush with an alpha and the palette holds no alpha
variant of a surface; the pre-multiplied ones there are accent washes.
-->
-
+
+
@@ -83,7 +87,7 @@
-
+
@@ -119,7 +123,7 @@
is not an answer, because this half of the control has none to give. Compare the accent button
above, which connects.
-->
-
diff --git a/src/DodoSSH.Client.App/Views/HostKeyCard.axaml.cs b/src/DodoSSH.Client.App/Views/HostKeyCard.axaml.cs
index 61a4347..c2b75ac 100644
--- a/src/DodoSSH.Client.App/Views/HostKeyCard.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/HostKeyCard.axaml.cs
@@ -17,4 +17,14 @@ namespace DodoSSH.Client.App.Views;
internal sealed partial class HostKeyCard : UserControl
{
public HostKeyCard() => InitializeComponent();
+
+ /// The answer the keyboard is given when the card appears: CANCEL, or CLOSE for a changed key.
+ ///
+ /// Never TRUST AND CONNECT, for the reason gives about every confirmation
+ /// and with more force here: an Enter meant for whatever was focused before the card appeared must not
+ /// be what pins a key nobody has compared. The window moves the keyboard rather than the card, because
+ /// this one is raised over a terminal that may be holding it natively — see
+ /// MainWindow.OnHostKeyDecision.
+ ///
+ internal Control SafeAnswer => CancelButton.IsEffectivelyVisible ? CancelButton : CloseButton;
}
diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs b/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
index be0a8e4..31d8f5f 100644
--- a/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
@@ -36,6 +36,8 @@ internal sealed partial class MainWindow : Window
/// 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();
@@ -230,40 +232,96 @@ internal sealed partial class MainWindow : Window
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);
}
- // 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)
+ else if (e.Key == Key.Escape && CloseTheNearestThing(viewModel))
{
- viewModel.CloseImportCommand.Execute(null);
- e.Handled = true;
- }
- // The members dialog on Settings → Vaults, by the same rule: it is drawn over the page, so it goes
- // first and settings stays.
- else if (e.Key == Key.Escape && viewModel.IsSettingsMode && viewModel.Vaults.IsMembersPanelOpen)
- {
- viewModel.Vaults.CloseMembersPanelCommand.Execute(null);
- e.Handled = true;
- }
- else if (e.Key == Key.Escape && viewModel.IsSettingsMode)
- {
- viewModel.LeaveSettingsCommand.Execute(null);
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.
///
@@ -312,6 +370,7 @@ internal sealed partial class MainWindow : Window
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.
@@ -377,6 +436,10 @@ internal sealed partial class MainWindow : Window
OnSelectedTabChanged(viewModel);
break;
+ case nameof(MainWindowViewModel.IsHostKeyDecisionShowing):
+ OnHostKeyDecision(viewModel);
+ break;
+
default:
break;
}
@@ -418,6 +481,51 @@ internal sealed partial class MainWindow : Window
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,
diff --git a/src/DodoSSH.Client.App/Views/SafeAnswerFocus.cs b/src/DodoSSH.Client.App/Views/SafeAnswerFocus.cs
new file mode 100644
index 0000000..dae7c53
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SafeAnswerFocus.cs
@@ -0,0 +1,81 @@
+using System.ComponentModel;
+using Avalonia.Controls;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+
+namespace DodoSSH.Client.App.Views;
+
+///
+/// Hands the keyboard to a confirmation's harmless answer when the question appears.
+///
+///
+///
+/// For the questions drawn in place on a page — the deletion and the sign-out confirmations — which used to
+/// appear with the keyboard still on the button that asked them, or nowhere. Tab then walked the page behind
+/// the question, Enter did nothing useful, and somebody working from the keyboard had to reach for the mouse
+/// to say no.
+///
+///
+/// ◆ The harmless answer, never the one the question is about. Enter on a focused button presses it,
+/// so the button that takes the keyboard is the one an accidental Enter is allowed to press: CANCEL. Putting it
+/// on DELETE or SIGN OUT AND DELETE would make two keystrokes — the one that asked and the one after — a way to
+/// destroy something without reading the sentence in between.
+///
+///
+/// Each card follows its own question rather than the window following all of them, because the same card is
+/// drawn in several places and only the one somebody can see should take the keyboard — which the posted
+/// check on IsEffectivelyVisible decides, after the binding that reveals it has run. Subscribed only
+/// while the card is in the tree: settings pages are rebuilt as they are visited, and a card that subscribed
+/// for good would leave one handler on the long-lived view model per visit.
+///
+///
+internal static class SafeAnswerFocus
+{
+ internal static void Follow(Control card, string question, Func safeAnswer)
+ {
+ INotifyPropertyChanged? watched = null;
+
+ void OnChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (!string.Equals(e.PropertyName, question, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ Dispatcher.UIThread.Post(
+ () =>
+ {
+ if (safeAnswer() is { IsEffectivelyVisible: true } answer)
+ {
+ answer.Focus();
+ }
+ },
+ DispatcherPriority.Loaded);
+ }
+
+ void Watch(INotifyPropertyChanged? next)
+ {
+ if (watched is not null)
+ {
+ watched.PropertyChanged -= OnChanged;
+ }
+
+ watched = next;
+
+ if (watched is not null)
+ {
+ watched.PropertyChanged += OnChanged;
+ }
+ }
+
+ card.AttachedToVisualTree += (_, _) => Watch(card.DataContext as INotifyPropertyChanged);
+ card.DetachedFromVisualTree += (_, _) => Watch(null);
+ card.DataContextChanged += (_, _) =>
+ {
+ if (card.IsAttachedToVisualTree())
+ {
+ Watch(card.DataContext as INotifyPropertyChanged);
+ }
+ };
+ }
+}
diff --git a/src/DodoSSH.Client.App/Views/SignOutCard.axaml b/src/DodoSSH.Client.App/Views/SignOutCard.axaml
index d9c4343..d2bfcfd 100644
--- a/src/DodoSSH.Client.App/Views/SignOutCard.axaml
+++ b/src/DodoSSH.Client.App/Views/SignOutCard.axaml
@@ -55,7 +55,7 @@
-
+
The sign-out confirmation, shown on the preferences screen and on the unlock screen.
internal sealed partial class SignOutCard : UserControl
{
- public SignOutCard() => InitializeComponent();
+ public SignOutCard()
+ {
+ InitializeComponent();
+
+ SafeAnswerFocus.Follow(this, nameof(MainWindowViewModel.IsConfirmingSignOut), () => CancelButton);
+ }
}
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/ClosedVaultFocusTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/ClosedVaultFocusTests.cs
index b774ec4..4dde15b 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/ClosedVaultFocusTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/ClosedVaultFocusTests.cs
@@ -1,16 +1,10 @@
using Avalonia.Controls;
using Avalonia.Input;
-using Avalonia.Threading;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
-using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
-using DodoSSH.Client.Terminal;
-using DodoSSH.Client.Transfer;
-using DodoSSH.Crypto;
-using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
@@ -24,18 +18,11 @@ namespace DodoSSH.Client.App.Layout.Tests;
/// and Enter in the server address did nothing at all.
///
///
-/// The whole window, which says cannot be shown
-/// here: the terminal's WebView refuses this thread's apartment when it is attached. It is taken out of the
-/// tree before the window is shown, which is everything that test is about and nothing these are.
+/// The whole window, shown the way describes.
///
///
public sealed class ClosedVaultFocusTests
{
- private const string Passphrase = "a sufficiently long passphrase";
-
- private static readonly Argon2Profile CheapProfile =
- Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
-
private static CancellationToken Token => TestContext.Current.CancellationToken;
[Fact]
@@ -44,14 +31,14 @@ public sealed class ClosedVaultFocusTests
using var caches = ClientCacheFactory.ForMemory($"focus-{Guid.CreateVersion7():N}");
await caches.MigrateAsync(Token);
- await using var workspace = Workspace();
- await using var shell = Shell(caches, workspace);
+ await using var workspace = WholeWindow.Workspace();
+ await using var shell = WholeWindow.Shell(caches, workspace);
await shell.StartAsync(Token);
shell.State.ShouldBe(ShellState.NeedsServer);
await LayoutHarness.OnTheUiThreadAsync(
- () => WithWindow(shell, window =>
+ () => WholeWindow.Show(shell, window =>
{
var box = window.FindControl("ServerUrlBox").ShouldNotBeNull();
@@ -69,59 +56,19 @@ public sealed class ClosedVaultFocusTests
using var caches = ClientCacheFactory.ForMemory($"focus-{Guid.CreateVersion7():N}");
await caches.MigrateAsync(Token);
- await new AccountProvisioner(new FakeAccountServer(), new StubKeyBinding(), caches, TimeProvider.System, CheapProfile)
- .EnrollAsync("https://dodossh.example", Passphrase, "laptop", "Personal", Token);
+ await new AccountProvisioner(new FakeAccountServer(), new StubKeyBinding(), caches, TimeProvider.System, WholeWindow.CheapProfile)
+ .EnrollAsync("https://dodossh.example", WholeWindow.Passphrase, "laptop", "Personal", Token);
- await using var workspace = Workspace();
- await using var shell = Shell(caches, workspace);
+ await using var workspace = WholeWindow.Workspace();
+ await using var shell = WholeWindow.Shell(caches, workspace);
await shell.StartAsync(Token);
shell.State.ShouldBe(ShellState.Locked);
await LayoutHarness.OnTheUiThreadAsync(
- () => WithWindow(shell, window =>
+ () => WholeWindow.Show(shell, window =>
window.FocusManager!.GetFocusedElement()
.ShouldBeSameAs(window.FindControl("UnlockPane").ShouldNotBeNull().PassphraseBox)),
Token);
}
-
- private static TerminalWorkspace Workspace() =>
- new(
- new InMemoryTerminalAssetProvider(new Dictionary(StringComparer.Ordinal)),
- Substitute.For(),
- TimeProvider.System);
-
- private static MainWindowViewModel Shell(ClientCacheFactory caches, TerminalWorkspace workspace) =>
- new(
- new ClientPaths(Path.Combine(Path.GetTempPath(), $"dodossh-focus-{Guid.CreateVersion7():N}")),
- caches,
- workspace,
- new VaultKnownHostStore(),
- new UnavailableDeviceKeyStore(),
- static (_, _) => throw new InvalidOperationException("A layout test has no network."),
- TimeProvider.System,
- Substitute.For(),
- CheapProfile);
-
- private static void WithWindow(MainWindowViewModel shell, Action assert)
- {
- var window = new MainWindow { DataContext = shell };
-
- if (window.FindControl("Terminal") is { Parent: Panel panel } web)
- {
- panel.Children.Remove(web);
- }
-
- try
- {
- LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
- Dispatcher.UIThread.RunJobs();
-
- assert(window);
- }
- finally
- {
- window.Close();
- }
- }
}
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/ConfirmationKeyboardTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/ConfirmationKeyboardTests.cs
new file mode 100644
index 0000000..5f658f2
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Layout.Tests/ConfirmationKeyboardTests.cs
@@ -0,0 +1,175 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Input;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using DodoSSH.Client.App.Views;
+using DodoSSH.Client.Session;
+using DodoSSH.Client.Session.Tests;
+using DodoSSH.Client.Shell.ViewModels;
+using DodoSSH.Client.Ssh;
+using DodoSSH.Client.Storage;
+using DodoSSH.Client.Terminal;
+
+namespace DodoSSH.Client.App.Layout.Tests;
+
+///
+/// The keyboard on the questions the window asks over itself: where it is when one appears, and what Escape
+/// answers.
+///
+///
+/// They used to appear with the keyboard wherever it had been, Escape did nothing, and Tab walked the page
+/// behind them. The answer they are given here is always the harmless one — see
+/// — and that is what each test pins: not merely that focus moved, but which button it moved to.
+///
+public sealed class ConfirmationKeyboardTests
+{
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ [Fact]
+ public async Task TheSignOutQuestion_TakesTheKeyboardOnCancel_AndEscapeSaysNo()
+ {
+ using var caches = await EnrolledAsync();
+
+ await using var workspace = WholeWindow.Workspace();
+ await using var shell = WholeWindow.Shell(caches, workspace);
+
+ await shell.StartAsync(Token);
+
+ await LayoutHarness.OnTheUiThreadAsync(
+ () => WholeWindow.Show(shell, window =>
+ {
+ shell.SignOutCommand.Execute(null);
+ Dispatcher.UIThread.RunJobs();
+
+ var card = window.GetVisualDescendants().OfType().Single(card => card.IsEffectivelyVisible);
+
+ window.FocusManager!.GetFocusedElement().ShouldBeSameAs(card.FindControl