Public Access
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:
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user