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;