Android: a customisable key bar, a quick-access keyboard, a long-press cursor gesture, and a side panel on tablets
ci / build and test (push) Successful in 4m52s
ci / desktop nightly (push) Failing after 58s
ci / android head (push) Successful in 5m59s
ci / api image (push) Successful in 8s

The key bar above the software keyboard is now the user's own: which keys, in what order, up to sixteen
in two rows that never scroll. Ctrl and Alt latch across the bar and the phone's keyboard, and Ctrl or Alt
with a cursor key sends the modified CSI form. Seven predefined groups (essentials, cursor, control,
symbols, tmux, vim, function) and keys of one's own, written in \e / ^C notation, are arranged on a
customise sheet that saves as it goes.

A grid button swaps the software keyboard for a quick-access keyboard of the same height, with KEYS,
SNIPPETS, HISTORY and APPEARANCE tabs; it reopens to the tab last used. Snippets go through the shell's
existing InsertSnippet. History keeps a typed command only once the remote has echoed it, so nothing
typed at a password prompt is ever recorded; it is memory-only and LOCK clears it. Each session pump now
keeps its last kilobyte of output and the alternate-screen state for that test (OutputTail).

Long-press and drag on the terminal sends arrow keys, as a joystick with three speeds by distance;
scrolling is unchanged and there is no mode switch. It takes the touch before Chromium's own long press,
so a held finger no longer pastes; paste is on the bar by default instead. A switch in APPEARANCE turns
the gesture off. The text-size buttons moved from the bar to APPEARANCE.

On wide screens the same tabs open as a side panel beside the terminal, toggled from the top bar, with
the bar's own keys repeated at the top of KEYS while the bar stands down.

The model, gesture arithmetic, echo reading and history are in DodoSSH.Client.Shell and covered by
TerminalKeyboardTests and OutputTailTests. Builds clean; not yet exercised on a device, because the
emulator's install is locked behind a passphrase that was not recorded.
This commit is contained in:
jaap-jan committed 2026-10-03 14:29:22 +02:00
1 parent c21de4ade4
commit 367cebae9b
22 files changed
+3949 -344

No files matched your search

@@ -332,6 +332,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private readonly ClientSettingsStore settings;
/// <summary>
/// The phone's keys for a terminal: the bar, the quick-access keyboard, and how both are arranged.
/// </summary>
internal TerminalKeyboardViewModel Keyboard { get; }
/// <remarks>
/// Held here only to hand to each vault as it is opened. The shell has nothing to copy of its own; the
/// keychain screen does. Null on a machine with no clipboard, which is a state that reports itself
@@ -641,6 +646,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
this.workspace.RendererReattached += OnRendererReattached;
settings = new ClientSettingsStore(paths);
Keyboard = CreateKeyboard();
updateScreen = CreateUpdateScreen(updates);
@@ -651,6 +657,34 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
StartSessionShellTracking();
}
/// <summary>The phone's keys, wired to this shell's sessions.</summary>
/// <remarks>
/// Built on every head because it is only state and a settings read, and the desktop simply never draws
/// it; a head-specific view model would be one more thing this constructor had to be told about per
/// platform. The customise sheet takes the screen the way the connect sheet does, so it moves the same
/// flag — see <see cref="IsTerminalShowing"/>.
/// </remarks>
private TerminalKeyboardViewModel CreateKeyboard()
{
var keyboard = new TerminalKeyboardViewModel(
settings,
CurrentTypingTarget,
SendTerminalInputAsync,
workspace.GetRecentOutput,
workspace.PasteAsync,
PasteIntoTerminalAsync);
keyboard.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(TerminalKeyboardViewModel.IsCustomizing))
{
RaiseTerminalState();
}
};
return keyboard;
}
/// <summary>
/// Takes this machine's own preferences off disk, before anything can read them.
/// </summary>
@@ -1500,6 +1534,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
IsTerminalSurface
&& !IsConnectSheetOpen
&& !IsForwardsSheetOpen
&& !Keyboard.IsCustomizing
&& !IsHostKeyDecisionShowing
&& SelectedTab is { HasSession: true };
@@ -3815,6 +3850,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
? null
: new SnippetsViewModel(newValue, CurrentInsertTarget, workspace.PasteAsync);
// The commands typed on this phone go with the keychain, for the reason the recent connections below
// do: they are a record of where somebody works and what they ran there, and LOCK is the promise
// that nothing like that is left on screen. See TypedCommandHistory.
if (newValue is null)
{
Keyboard.ClearHistory();
}
LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections);
// Emptied with the vault it was read out of. These rows are decrypted log entries — a host's name
@@ -4230,6 +4273,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
open.HostLabel, open.Address, open.StartedAt, deviceName)),
];
/// <summary>The session the phone's keys type into: the selected tab, once it has a shell.</summary>
private TypingTarget? CurrentTypingTarget() =>
SelectedTab is { HasSession: true } tab ? new TypingTarget(tab.SessionId, tab.Label) : null;
private InsertTarget CurrentInsertTarget() =>
SelectedTab is { } tab ? new InsertTarget(tab.SessionId, tab.Label) : InsertTarget.None;
@@ -0,0 +1,825 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.Keyboard;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>The quick-access keyboard's tabs.</summary>
internal enum QuickAccessTab
{
Keys,
Snippets,
History,
Appearance,
}
/// <summary>Where typed bytes are going: a session that is open, and what its tab is called.</summary>
internal sealed record TypingTarget(uint SessionId, string Host);
/// <summary>
/// The phone's keys for a terminal: the bar above the software keyboard, the quick-access keyboard that can
/// take its place, the customise screen, and the cursor gesture's switch.
/// </summary>
/// <remarks>
/// <para>
/// <b>Here rather than in the Android head, because almost none of it is about Android.</b> Which keys exist,
/// how they are arranged, what a latched Ctrl does to the next one and what the history keeps are questions
/// with right answers that can be tested on any machine, and the head is a project no test can reference.
/// What stays in the head is what only a phone has: a software keyboard to hide and show, a touch stream to
/// read a long press from, and the screen width that decides between a bottom panel and a side panel.
/// </para>
/// <para>
/// <b>Every key press, from the bar, the quick-access keyboard or the software keyboard itself, comes through
/// <see cref="Send"/>.</b> That is what makes the latches mean one thing — Ctrl pressed on the bar applies to
/// a letter typed on Gboard — and what lets the history see the whole of a line rather than the part typed on
/// one of the three.
/// </para>
/// <para>
/// <b>Customising saves as it goes.</b> There is no draft and no SAVE: every change on the customise screen is
/// a change to the bar that is already drawn under the sheet's scrim, and to the settings file, the moment it
/// is made. A keyboard is not a document, and "did that save" is not a question it should raise.
/// </para>
/// </remarks>
internal sealed partial class TerminalKeyboardViewModel : ObservableObject
{
private readonly ClientSettingsStore settings;
private readonly Func<TypingTarget?> target;
private readonly Func<uint, ReadOnlyMemory<byte>, ValueTask> send;
private readonly Func<uint, RecentOutput?> recent;
private readonly Func<uint, string, bool, CancellationToken, Task<bool>> insert;
private readonly Func<uint, Task> paste;
private readonly List<string> bar = [];
private readonly List<string> groupOrder = [];
private readonly HashSet<string> hiddenGroups = new(StringComparer.Ordinal);
private readonly List<CustomTerminalKey> customKeys = [];
/// <summary>One button model per key id, shared wherever the key is drawn, so a latch lights all of them.</summary>
private readonly Dictionary<string, KeyButtonViewModel> buttons = new(StringComparer.Ordinal);
private readonly TypedCommandHistory history = new();
/// <param name="settings">Where the arrangement is kept.</param>
/// <param name="target">The session keys go to right now, or null when no tab has one.</param>
/// <param name="send">Writes bytes to a session.</param>
/// <param name="recent">A session's recent output, for the history's echo test.</param>
/// <param name="insert">Types text into a session as the snippets screen does — the workspace's paste.</param>
/// <param name="paste">Pastes the clipboard into a session.</param>
internal TerminalKeyboardViewModel(
ClientSettingsStore settings,
Func<TypingTarget?> target,
Func<uint, ReadOnlyMemory<byte>, ValueTask> send,
Func<uint, RecentOutput?> recent,
Func<uint, string, bool, CancellationToken, Task<bool>> insert,
Func<uint, Task> paste)
{
this.settings = settings;
this.target = target;
this.send = send;
this.recent = recent;
this.insert = insert;
this.paste = paste;
var stored = settings.Read().TerminalKeyboard ?? new TerminalKeyboardSettings();
Load(stored);
selectedTab = Enum.TryParse<QuickAccessTab>(stored.QuickAccessTab, out var tab) ? tab : QuickAccessTab.Keys;
isSidePanelOpen = stored.SidePanelOpen;
cursorGestures = stored.CursorGestures;
Rebuild();
}
/// <summary>The bar, as rows of at most <see cref="TerminalKeyCatalog.BarRowWidth"/>.</summary>
internal ObservableCollection<KeyRowViewModel> BarRows { get; } = [];
/// <summary>The KEYS tab: every group that is switched on, in order.</summary>
internal ObservableCollection<KeyGroupViewModel> Groups { get; } = [];
/// <summary>The HISTORY tab.</summary>
internal ObservableCollection<HistoryEntry> History => history.Entries;
/// <summary>The customise screen's bar list.</summary>
internal ObservableCollection<BarEditorRowViewModel> BarEditor { get; } = [];
/// <summary>The customise screen's group list.</summary>
internal ObservableCollection<GroupEditorRowViewModel> GroupEditor { get; } = [];
/// <summary>The customise screen's list of keys somebody added.</summary>
internal ObservableCollection<CustomKeyRowViewModel> CustomKeyEditor { get; } = [];
/// <summary>Whether the bar has any keys on it; an empty bar is drawn as nothing at all.</summary>
internal bool HasBar => bar.Count > 0;
/// <summary>Whether the HISTORY tab has anything to show.</summary>
internal bool HasHistory => History.Count > 0;
/// <summary>Whether the bar can take another key.</summary>
internal bool CanAddToBar => bar.Count < TerminalKeyCatalog.MaximumBarKeys;
/// <summary>The customise screen's count line.</summary>
internal string BarCountText => $"{bar.Count} of {TerminalKeyCatalog.MaximumBarKeys}";
[ObservableProperty]
private bool isControlLatched;
[ObservableProperty]
private bool isAltLatched;
/// <summary>Whether the quick-access keyboard is up in place of the software keyboard.</summary>
[ObservableProperty]
private bool isQuickAccessOpen;
/// <summary>Whether a wide screen draws the quick-access keyboard beside the terminal.</summary>
[ObservableProperty]
private bool isSidePanelOpen;
/// <summary>Whether the customise screen is up.</summary>
[ObservableProperty]
private bool isCustomizing;
/// <summary>Whether long-press-and-drag on the terminal moves the cursor.</summary>
[ObservableProperty]
private bool cursorGestures;
[ObservableProperty]
private QuickAccessTab selectedTab;
[ObservableProperty]
private string newKeyLabel = string.Empty;
[ObservableProperty]
private string newKeySequence = string.Empty;
[ObservableProperty]
private string customizeStatus = string.Empty;
internal bool IsKeysTab => SelectedTab is QuickAccessTab.Keys;
internal bool IsSnippetsTab => SelectedTab is QuickAccessTab.Snippets;
internal bool IsHistoryTab => SelectedTab is QuickAccessTab.History;
internal bool IsAppearanceTab => SelectedTab is QuickAccessTab.Appearance;
// ── typing ───────────────────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Sends one key's worth of bytes to the selected session, through the latches and past the history.
/// </summary>
/// <remarks>
/// <para>
/// Both latches release after any key, including one they did not change. The person pressed Ctrl and
/// then a key; that was the key Ctrl was for, whatever it turned out to be.
/// </para>
/// <para>
/// Discarded rather than awaited: this is a keystroke, the workspace ignores a session that has gone,
/// and awaiting would serialise every key behind a slow link.
/// </para>
/// </remarks>
internal void Send(byte[] bytes)
{
ArgumentNullException.ThrowIfNull(bytes);
if (bytes.Length == 0 || target() is not { } to)
{
return;
}
var payload = TerminalModifiers.Apply(bytes, IsControlLatched, IsAltLatched);
IsControlLatched = false;
IsAltLatched = false;
history.Observe(to.SessionId, to.Host, payload, () => recent(to.SessionId));
OnPropertyChanged(nameof(HasHistory));
_ = send(to.SessionId, payload).AsTask();
}
/// <summary>Sends the cursor gesture's arrows, which no latch applies to.</summary>
/// <remarks>
/// A gesture is not "the next key" in the sense a latch means — nobody pressing Ctrl on the bar and then
/// dragging the terminal means Ctrl+arrow by it, and spending the latch here would surprise the letter
/// typed afterwards. The history is still told, because the line has changed under it.
/// </remarks>
internal void SendArrow(ArrowDirection direction, int count)
{
if (count <= 0 || target() is not { } to)
{
return;
}
string? id = direction switch
{
ArrowDirection.Left => "left",
ArrowDirection.Down => "down",
ArrowDirection.Up => "up",
ArrowDirection.Right => "right",
_ => null,
};
if (id is null)
{
return;
}
var one = TerminalKeyCatalog.ById[id].Bytes;
var payload = new byte[one.Length * count];
for (var i = 0; i < count; i++)
{
one.CopyTo(payload, i * one.Length);
}
history.Observe(to.SessionId, to.Host, one, () => recent(to.SessionId));
_ = send(to.SessionId, payload).AsTask();
}
/// <summary>What a key does when pressed, wherever it is drawn.</summary>
internal void Press(TerminalKey key)
{
ArgumentNullException.ThrowIfNull(key);
switch (key.Kind)
{
case TerminalKeyKind.Control:
IsControlLatched = !IsControlLatched;
break;
case TerminalKeyKind.Alt:
IsAltLatched = !IsAltLatched;
break;
case TerminalKeyKind.Paste:
if (target() is { } to)
{
history.NoteInserted(to.SessionId, () => recent(to.SessionId));
_ = paste(to.SessionId);
}
break;
default:
Send(key.Bytes);
break;
}
}
/// <summary>Notes that a session's tab is gone, so a half-typed line is not kept for it.</summary>
internal void ForgetSession(uint sessionId) => history.Forget(sessionId);
/// <summary>Empties the history, which LOCK does as well as the tab's own CLEAR.</summary>
[RelayCommand]
internal void ClearHistory()
{
history.Clear();
OnPropertyChanged(nameof(HasHistory));
}
/// <summary>Types a history entry at the prompt, without pressing Enter.</summary>
/// <remarks>
/// The snippets screen's own route — typed rather than pasted for one line, and never with an Enter —
/// for the reason that screen gives: the remote may not be at a prompt any more, and running a command
/// out of a list is a thing to ask for rather than have happen.
/// </remarks>
[RelayCommand]
private async Task InsertHistoryAsync(HistoryEntry? entry)
{
if (entry is null || target() is not { } to)
{
return;
}
history.NoteInserted(to.SessionId, () => recent(to.SessionId));
await insert(to.SessionId, entry.Command, false, CancellationToken.None).ConfigureAwait(true);
}
// ── the panels ───────────────────────────────────────────────────────────────────────────────────────
/// <summary>Raises or lowers the quick-access keyboard.</summary>
[RelayCommand]
private void ToggleQuickAccess() => IsQuickAccessOpen = !IsQuickAccessOpen;
/// <summary>Lowers the quick-access keyboard, for the software keyboard coming back.</summary>
internal void CloseQuickAccess() => IsQuickAccessOpen = false;
/// <summary>Shows or hides the wide screen's side panel, and remembers which.</summary>
[RelayCommand]
private void ToggleSidePanel()
{
IsSidePanelOpen = !IsSidePanelOpen;
Save();
}
[RelayCommand]
private void SelectTab(QuickAccessTab tab)
{
SelectedTab = tab;
Save();
}
[RelayCommand]
private void ToggleCursorGestures()
{
CursorGestures = !CursorGestures;
Save();
}
partial void OnSelectedTabChanged(QuickAccessTab value)
{
OnPropertyChanged(nameof(IsKeysTab));
OnPropertyChanged(nameof(IsSnippetsTab));
OnPropertyChanged(nameof(IsHistoryTab));
OnPropertyChanged(nameof(IsAppearanceTab));
}
partial void OnIsControlLatchedChanged(bool value) => RefreshLatches();
partial void OnIsAltLatchedChanged(bool value) => RefreshLatches();
private void RefreshLatches()
{
foreach (var button in buttons.Values)
{
button.IsLatched = button.Key.Kind switch
{
TerminalKeyKind.Control => IsControlLatched,
TerminalKeyKind.Alt => IsAltLatched,
_ => false,
};
}
}
// ── customising ──────────────────────────────────────────────────────────────────────────────────────
[RelayCommand]
private void OpenCustomize()
{
CustomizeStatus = string.Empty;
IsQuickAccessOpen = false;
IsCustomizing = true;
}
[RelayCommand]
private void CloseCustomize()
{
NewKeyLabel = string.Empty;
NewKeySequence = string.Empty;
CustomizeStatus = string.Empty;
IsCustomizing = false;
}
/// <summary>Puts the bar and the groups back as shipped. Keys somebody added are kept, off the bar.</summary>
/// <remarks>
/// A custom key is a thing somebody wrote, and a reset that deleted it would be destroying work to tidy a
/// layout. It stays on the customise screen and in its own group, one tap from the bar again.
/// </remarks>
[RelayCommand]
private void ResetLayout()
{
bar.Clear();
bar.AddRange(TerminalKeyCatalog.DefaultBar);
groupOrder.Clear();
hiddenGroups.Clear();
NormaliseGroupOrder();
CustomizeStatus = "The bar and the groups are back as they shipped.";
Commit();
}
/// <summary>Adds the key the two boxes describe, to its own group and to the bar if there is room.</summary>
[RelayCommand]
private void AddCustomKey()
{
var label = NewKeyLabel.Trim();
if (label.Length is 0 or > 10)
{
CustomizeStatus = "A key needs a label of one to ten characters.";
return;
}
if (!KeySequence.TryParse(NewKeySequence, out var bytes) || bytes.Length == 0)
{
CustomizeStatus = NewKeySequence.Length == 0
? "Say what the key sends."
: "That sequence ends in a lone backslash or a \\x without two hex digits.";
return;
}
var id = TerminalKeyCatalog.CustomPrefix + Guid.CreateVersion7().ToString("N");
customKeys.Add(new CustomTerminalKey(id, label, NewKeySequence));
NormaliseGroupOrder();
var onBar = CanAddToBar;
if (onBar)
{
bar.Add(id);
}
CustomizeStatus = onBar
? $"Added '{label}' to the bar."
: $"Added '{label}' to YOUR KEYS. The bar is full; take a key off it to make room.";
NewKeyLabel = string.Empty;
NewKeySequence = string.Empty;
Commit();
}
private void ToggleOnBar(string id)
{
if (bar.Remove(id))
{
Commit();
return;
}
if (!CanAddToBar)
{
CustomizeStatus = $"The bar holds {TerminalKeyCatalog.MaximumBarKeys} keys. Take one off first.";
return;
}
bar.Add(id);
CustomizeStatus = string.Empty;
Commit();
}
private void MoveOnBar(string id, int by)
{
var from = bar.IndexOf(id);
var to = from + by;
if (from < 0 || to < 0 || to >= bar.Count)
{
return;
}
(bar[from], bar[to]) = (bar[to], bar[from]);
Commit();
}
private void MoveGroup(string id, int by)
{
var from = groupOrder.IndexOf(id);
var to = from + by;
if (from < 0 || to < 0 || to >= groupOrder.Count)
{
return;
}
(groupOrder[from], groupOrder[to]) = (groupOrder[to], groupOrder[from]);
Commit();
}
/// <remarks>
/// Saves and redraws the KEYS tab only. The switch that called this is a row of the customise screen's
/// own group list, and rebuilding that list would be pulling the control out from under the binding that
/// is still busy telling us about it.
/// </remarks>
private void SetGroupShown(string id, bool shown)
{
var changed = shown ? hiddenGroups.Remove(id) : hiddenGroups.Add(id);
if (changed)
{
Save();
RebuildGroups();
}
}
private void DeleteCustomKey(string id)
{
customKeys.RemoveAll(k => string.Equals(k.Id, id, StringComparison.Ordinal));
bar.Remove(id);
buttons.Remove(id);
NormaliseGroupOrder();
Commit();
}
// ── state ────────────────────────────────────────────────────────────────────────────────────────────
private void Load(TerminalKeyboardSettings stored)
{
foreach (var custom in stored.CustomKeys ?? [])
{
if (custom is { Id: { } id, Label: { } label, Sequence: { } sequence }
&& id.StartsWith(TerminalKeyCatalog.CustomPrefix, StringComparison.Ordinal)
&& TerminalKeyCatalog.FromCustom(id, label, sequence) is not null
&& customKeys.TrueForAll(k => !string.Equals(k.Id, id, StringComparison.Ordinal)))
{
customKeys.Add(custom);
}
}
foreach (var id in stored.Bar ?? [.. TerminalKeyCatalog.DefaultBar])
{
if (bar.Count < TerminalKeyCatalog.MaximumBarKeys && KeyFor(id) is not null && !bar.Contains(id))
{
bar.Add(id);
}
}
groupOrder.AddRange(stored.GroupOrder ?? []);
hiddenGroups.UnionWith(stored.HiddenGroups ?? []);
NormaliseGroupOrder();
}
/// <summary>
/// Every group this build has, exactly once: stored order first, anything new at the end.
/// </summary>
/// <remarks>
/// A group shipped after somebody arranged theirs lands at the bottom rather than not at all — they did
/// not hide it, so it is not hidden — and one this build does not know is dropped.
/// </remarks>
private void NormaliseGroupOrder()
{
var known = TerminalKeyCatalog.Groups.Select(g => g.Id).ToList();
if (customKeys.Count > 0)
{
known.Add(TerminalKeyCatalog.CustomGroupId);
}
var ordered = groupOrder
.Where(id => known.Contains(id, StringComparer.Ordinal))
.Distinct(StringComparer.Ordinal)
.ToList();
ordered.AddRange(known.Where(id => !ordered.Contains(id, StringComparer.Ordinal)));
groupOrder.Clear();
groupOrder.AddRange(ordered);
}
private TerminalKey? KeyFor(string id) =>
TerminalKeyCatalog.ById.TryGetValue(id, out var shipped) ? shipped
: customKeys.Find(k => string.Equals(k.Id, id, StringComparison.Ordinal)) is { } custom
? TerminalKeyCatalog.FromCustom(custom.Id, custom.Label, custom.Sequence)
: null;
private TerminalKeyGroup? GroupFor(string id)
{
if (string.Equals(id, TerminalKeyCatalog.CustomGroupId, StringComparison.Ordinal))
{
return new TerminalKeyGroup(
id,
"YOUR KEYS",
[.. customKeys.Select(k => KeyFor(k.Id)).OfType<TerminalKey>()]);
}
return TerminalKeyCatalog.Groups.FirstOrDefault(g => string.Equals(g.Id, id, StringComparison.Ordinal));
}
private KeyButtonViewModel ButtonFor(TerminalKey key)
{
if (!buttons.TryGetValue(key.Id, out var button))
{
button = new KeyButtonViewModel(key, Press);
buttons[key.Id] = button;
}
return button;
}
private void Commit()
{
Save();
Rebuild();
}
private void Save() =>
settings.Write(settings.Read() with
{
TerminalKeyboard = new TerminalKeyboardSettings(
Bar: [.. bar],
GroupOrder: [.. groupOrder],
HiddenGroups: [.. hiddenGroups.Order(StringComparer.Ordinal)],
CustomKeys: [.. customKeys],
QuickAccessTab: SelectedTab.ToString(),
SidePanelOpen: IsSidePanelOpen,
CursorGestures: CursorGestures),
});
/// <summary>Redraws the KEYS tab.</summary>
private void RebuildGroups()
{
Groups.Clear();
foreach (var id in groupOrder)
{
if (!hiddenGroups.Contains(id) && GroupFor(id) is { Keys.Count: > 0 } group)
{
Groups.Add(new KeyGroupViewModel(group.Title, [.. group.Keys.Select(ButtonFor)]));
}
}
}
/// <summary>Redraws everything that depends on the arrangement.</summary>
/// <remarks>
/// Wholesale rather than by diff. The lists are a few dozen rows, a change is a tap, and an incremental
/// update is a second account of the arrangement that can disagree with the first.
/// </remarks>
private void Rebuild()
{
var barKeys = bar.Select(KeyFor).OfType<TerminalKey>().ToList();
BarRows.Clear();
foreach (var row in barKeys.Chunk(TerminalKeyCatalog.BarRowWidth))
{
BarRows.Add(new KeyRowViewModel([.. row.Select(ButtonFor)]));
}
RebuildGroups();
GroupEditor.Clear();
for (var i = 0; i < groupOrder.Count; i++)
{
if (GroupFor(groupOrder[i]) is not { } group)
{
continue;
}
var id = group.Id;
GroupEditor.Add(new GroupEditorRowViewModel(
group.Title,
!hiddenGroups.Contains(id),
shown => SetGroupShown(id, shown),
i > 0 ? () => MoveGroup(id, -1) : null,
i < groupOrder.Count - 1 ? () => MoveGroup(id, 1) : null,
[.. group.Keys.Select(k => new KeyChoiceViewModel(k.Label, bar.Contains(k.Id), () => ToggleOnBar(k.Id)))]));
}
BarEditor.Clear();
for (var i = 0; i < barKeys.Count; i++)
{
var id = barKeys[i].Id;
BarEditor.Add(new BarEditorRowViewModel(
barKeys[i],
i < TerminalKeyCatalog.BarRowWidth ? "TOP ROW" : "SECOND ROW",
i > 0 ? () => MoveOnBar(id, -1) : null,
i < barKeys.Count - 1 ? () => MoveOnBar(id, 1) : null,
() => ToggleOnBar(id)));
}
CustomKeyEditor.Clear();
foreach (var custom in customKeys)
{
var id = custom.Id;
CustomKeyEditor.Add(new CustomKeyRowViewModel(custom.Label, custom.Sequence, () => DeleteCustomKey(id)));
}
RefreshLatches();
OnPropertyChanged(nameof(HasBar));
OnPropertyChanged(nameof(CanAddToBar));
OnPropertyChanged(nameof(BarCountText));
}
}
/// <summary>One key as drawn: on the bar, on the quick-access keyboard, or both at once.</summary>
internal sealed partial class KeyButtonViewModel : ObservableObject
{
internal KeyButtonViewModel(TerminalKey key, Action<TerminalKey> press)
{
Key = key;
PressCommand = new RelayCommand(() => press(key));
}
internal TerminalKey Key { get; }
internal string Label => Key.Label;
internal string Hint => Key.Hint;
internal bool IsWide => Key.IsWide;
internal IRelayCommand PressCommand { get; }
/// <summary>Whether this is a modifier that is on, which has to look on.</summary>
[ObservableProperty]
private bool isLatched;
}
/// <summary>One row of the bar; the markup divides its width evenly between the keys.</summary>
internal sealed record KeyRowViewModel(IReadOnlyList<KeyButtonViewModel> Keys);
/// <summary>One group on the KEYS tab.</summary>
internal sealed record KeyGroupViewModel(string Title, IReadOnlyList<KeyButtonViewModel> Keys);
/// <summary>One key on the customise screen's bar list.</summary>
internal sealed class BarEditorRowViewModel
{
internal BarEditorRowViewModel(TerminalKey key, string row, Action? moveUp, Action? moveDown, Action remove)
{
Label = key.Label;
Hint = key.Hint;
Row = row;
MoveUpCommand = new RelayCommand(() => moveUp?.Invoke(), () => moveUp is not null);
MoveDownCommand = new RelayCommand(() => moveDown?.Invoke(), () => moveDown is not null);
RemoveCommand = new RelayCommand(remove);
}
internal string Label { get; }
internal string Hint { get; }
/// <summary>Which row of the bar the key lands on, since the first eight are the top row.</summary>
internal string Row { get; }
internal IRelayCommand MoveUpCommand { get; }
internal IRelayCommand MoveDownCommand { get; }
internal IRelayCommand RemoveCommand { get; }
}
/// <summary>One group on the customise screen: its switch, its place, and which of its keys are on the bar.</summary>
internal sealed partial class GroupEditorRowViewModel : ObservableObject
{
private readonly Action<bool> setShown;
internal GroupEditorRowViewModel(
string title,
bool isShown,
Action<bool> setShown,
Action? moveUp,
Action? moveDown,
IReadOnlyList<KeyChoiceViewModel> keys)
{
Title = title;
this.isShown = isShown;
this.setShown = setShown;
Keys = keys;
MoveUpCommand = new RelayCommand(() => moveUp?.Invoke(), () => moveUp is not null);
MoveDownCommand = new RelayCommand(() => moveDown?.Invoke(), () => moveDown is not null);
}
internal string Title { get; }
internal IReadOnlyList<KeyChoiceViewModel> Keys { get; }
internal IRelayCommand MoveUpCommand { get; }
internal IRelayCommand MoveDownCommand { get; }
/// <summary>Whether the group is drawn on the KEYS tab.</summary>
[ObservableProperty]
private bool isShown;
partial void OnIsShownChanged(bool value) => setShown(value);
}
/// <summary>A key on the customise screen, lit when it is on the bar; tapping it puts it on or takes it off.</summary>
internal sealed class KeyChoiceViewModel
{
internal KeyChoiceViewModel(string label, bool isOnBar, Action toggle)
{
Label = label;
IsOnBar = isOnBar;
ToggleCommand = new RelayCommand(toggle);
}
internal string Label { get; }
internal bool IsOnBar { get; }
internal IRelayCommand ToggleCommand { get; }
}
/// <summary>A key somebody added, on the customise screen, with the way to delete it.</summary>
internal sealed class CustomKeyRowViewModel
{
internal CustomKeyRowViewModel(string label, string sequence, Action delete)
{
Label = label;
Sequence = sequence;
DeleteCommand = new RelayCommand(delete);
}
internal string Label { get; }
internal string Sequence { get; }
internal IRelayCommand DeleteCommand { get; }
}