Desktop: give the host-key, delete and sign-out questions the keyboard on their harmless answer, and let Escape say no

This commit is contained in:
jaap-jan committed 2026-10-02 13:45:40 +02:00
1 parent 372a7cbdc7
commit 6c09bdecb6
11 files changed
+498 -92

No files matched your search

@@ -59,7 +59,7 @@
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
</StackPanel>
</StackPanel>
@@ -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;
/// </remarks>
internal sealed partial class ConfirmDeleteCard : UserControl
{
public ConfirmDeleteCard() => InitializeComponent();
public ConfirmDeleteCard()
{
InitializeComponent();
SafeAnswerFocus.Follow(this, nameof(VaultViewModel.PendingDeletion), () => CancelButton);
}
}
@@ -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.
-->
<Border Background="#CC05050A">
<!--
Tab cycles inside the card rather than walking the page under the wash, which it used to: a question
that cannot be answered by clicking away should not be answerable by tabbing away either.
-->
<Border Background="#CC05050A" KeyboardNavigation.TabNavigation="Cycle">
<Panel>
<!-- ============ UNKNOWN HOST KEY ============ -->
@@ -83,7 +87,7 @@
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="TRUST AND CONNECT"
Command="{Binding TrustHostKeyCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
</StackPanel>
</StackPanel>
@@ -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.
-->
<Button Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
<Button x:Name="CloseButton" Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
Command="{Binding RejectHostKeyCommand}" />
</StackPanel>
@@ -17,4 +17,14 @@ namespace DodoSSH.Client.App.Views;
internal sealed partial class HostKeyCard : UserControl
{
public HostKeyCard() => InitializeComponent();
/// <summary>The answer the keyboard is given when the card appears: CANCEL, or CLOSE for a changed key.</summary>
/// <remarks>
/// Never TRUST AND CONNECT, for the reason <see cref="SafeAnswerFocus"/> 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
/// <c>MainWindow.OnHostKeyDecision</c>.
/// </remarks>
internal Control SafeAnswer => CancelButton.IsEffectivelyVisible ? CancelButton : CloseButton;
}
+131 -23
View File
@@ -36,6 +36,8 @@ internal sealed partial class MainWindow : Window
/// <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();
@@ -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);
}
/// <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>
@@ -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);
}
/// <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,
@@ -0,0 +1,81 @@
using System.ComponentModel;
using Avalonia.Controls;
using Avalonia.Threading;
using Avalonia.VisualTree;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// Hands the keyboard to a confirmation's harmless answer when the question appears.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// ◆ <b>The harmless answer, never the one the question is about.</b> 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.
/// </para>
/// <para>
/// 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 <c>IsEffectivelyVisible</c> 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.
/// </para>
/// </remarks>
internal static class SafeAnswerFocus
{
internal static void Follow(Control card, string question, Func<Control> 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);
}
};
}
}
@@ -55,7 +55,7 @@
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="danger" Content="SIGN OUT AND DELETE"
Command="{Binding ConfirmSignOutCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelSignOutCommand}" />
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding CancelSignOutCommand}" />
</StackPanel>
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
@@ -1,9 +1,15 @@
using Avalonia.Controls;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
/// <summary>The sign-out confirmation, shown on the preferences screen and on the unlock screen.</summary>
internal sealed partial class SignOutCard : UserControl
{
public SignOutCard() => InitializeComponent();
public SignOutCard()
{
InitializeComponent();
SafeAnswerFocus.Follow(this, nameof(MainWindowViewModel.IsConfirmingSignOut), () => CancelButton);
}
}