diff --git a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml
index 42b8dd3..5a6545e 100644
--- a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml
+++ b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml
@@ -62,11 +62,40 @@
+
-
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs b/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
index 08aa512..b79b599 100644
--- a/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
@@ -204,6 +204,11 @@ internal sealed partial class MainWindow : Window
viewModel.ToggleSearchCommand.Execute(null);
e.Handled = true;
}
+ else if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && TerminalFontCommand(viewModel, e.Key) is { } size)
+ {
+ size.Execute(null);
+ e.Handled = true;
+ }
else if (viewModel.IsSearching)
{
Palette.HandleKey(e);
@@ -212,6 +217,31 @@ internal sealed partial class MainWindow : Window
base.OnKeyDown(e);
}
+ ///
+ /// The text-size chords, when the terminal is not the thing hearing them.
+ ///
+ ///
+ ///
+ /// The same three chords the page answers, and the duplication is the point rather than an oversight:
+ /// the page hears them only while a terminal has focus, and the whole reason somebody reaches for them
+ /// is often that they are looking at a terminal they cannot read from a screen that is not it — the
+ /// host list, or preferences. Both routes end in the same commands on the shell.
+ ///
+ ///
+ /// Both keys for plus, because a keyboard has two of them and neither is more correct: OemPlus is the
+ /// one beside Backspace, Add is the one on the numeric pad. Same for minus.
+ ///
+ ///
+ private static System.Windows.Input.ICommand? TerminalFontCommand(
+ MainWindowViewModel viewModel,
+ Key key) => key switch
+ {
+ Key.OemPlus or Key.Add => viewModel.EnlargeTerminalFontCommand,
+ Key.OemMinus or Key.Subtract => viewModel.ShrinkTerminalFontCommand,
+ Key.D0 or Key.NumPad0 => viewModel.ResetTerminalFontCommand,
+ _ => null,
+ };
+
private void Attach(MainWindowViewModel? viewModel)
{
if (shell is { } previous)
diff --git a/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml b/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml
index e85718a..67b1aa3 100644
--- a/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml
+++ b/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml
@@ -8,11 +8,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Session/ClientPaths.cs b/src/DodoSSH.Client.Session/ClientPaths.cs
index cae4723..c991d4b 100644
--- a/src/DodoSSH.Client.Session/ClientPaths.cs
+++ b/src/DodoSSH.Client.Session/ClientPaths.cs
@@ -29,6 +29,16 @@ public sealed record ClientPaths(string DataDirectory)
/// The encrypted local cache.
public string CacheFile => Path.Combine(DataDirectory, "cache.db");
+ ///
+ /// This machine's preferences, in plaintext.
+ ///
+ ///
+ /// Beside the cache rather than inside it, because everything in it is needed before a vault
+ /// is unlocked — a terminal draws at a chosen size on a launch that never reaches a passphrase. That
+ /// is also the reason nothing secret may go in here; see .
+ ///
+ public string SettingsFile => Path.Combine(DataDirectory, "settings.json");
+
///
/// This machine's device key, encrypted to a key it cannot export.
///
diff --git a/src/DodoSSH.Client.Session/ClientSettings.cs b/src/DodoSSH.Client.Session/ClientSettings.cs
new file mode 100644
index 0000000..b90f7be
--- /dev/null
+++ b/src/DodoSSH.Client.Session/ClientSettings.cs
@@ -0,0 +1,138 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace DodoSSH.Client.Session;
+
+///
+/// Preferences that belong to this machine rather than to the vault.
+///
+///
+///
+/// Deliberately not in the cache, and deliberately not synced. The cache is encrypted and cannot be
+/// read until a vault is unlocked, and a font size is needed by the first terminal a locked launch draws.
+/// Syncing it would be worse than useless: the size that suits a phone is not the size that suits a
+/// 27-inch monitor, and one following the other around is a preference nobody asked for.
+///
+///
+/// Nothing secret goes in here, and that is a rule rather than a description of what happens to be here
+/// today. The file is plaintext in the profile directory; anything that would matter if it were read
+/// belongs in the cache, behind the passphrase.
+///
+///
+public sealed record ClientSettings
+{
+ /// The size a terminal draws at with nothing stored.
+ ///
+ /// Matches the renderer's own default, and has to: the page creates panes at its own constant until
+ /// the host tells it otherwise, so a different value here would show as a resize in front of the user
+ /// on every launch.
+ ///
+ public const int DefaultTerminalFontSize = 13;
+
+ /// Smallest terminal font size the shell will set.
+ ///
+ /// Not zero, and not one. Below about eight pixels a monospace grid stops being legible and starts
+ /// being a texture — and every column of it is still a column the remote is being told exists.
+ ///
+ public const int MinimumTerminalFontSize = 8;
+
+ /// Largest terminal font size the shell will set.
+ ///
+ /// A phone in portrait at 32px is about twenty columns, which is already narrower than most command
+ /// output survives. The cap is what stops "bigger" arriving at a terminal that cannot show a prompt.
+ ///
+ public const int MaximumTerminalFontSize = 32;
+
+ /// The terminal font size, in CSS pixels.
+ public int TerminalFontSize { get; init; } = DefaultTerminalFontSize;
+
+ /// Brings a value inside the range this type will store.
+ public static int ClampTerminalFontSize(int pixels) =>
+ Math.Clamp(pixels, MinimumTerminalFontSize, MaximumTerminalFontSize);
+}
+
+///
+/// Reads and writes as a small JSON file.
+///
+///
+///
+/// Every failure resolves to the defaults, and none of them throw. A missing file is the first launch, a
+/// truncated one is a machine that lost power mid-write, and an unreadable one is a profile directory
+/// somebody has been editing by hand. None of those is a reason to refuse to start an SSH client, and a
+/// preference that cannot be read is a preference that was never set.
+///
+///
+/// Written through a temporary file and moved into place, so an interrupted write leaves the previous
+/// settings rather than half of the new ones.
+///
+///
+public sealed class ClientSettingsStore(ClientPaths paths)
+{
+ /// Reads the stored settings, or the defaults.
+ public ClientSettings Read()
+ {
+ try
+ {
+ if (!File.Exists(paths.SettingsFile))
+ {
+ return new ClientSettings();
+ }
+
+ var json = File.ReadAllText(paths.SettingsFile);
+
+ var stored = JsonSerializer.Deserialize(json, ClientSettingsJsonContext.Default.ClientSettings);
+
+ return stored is null
+ ? new ClientSettings()
+ : stored with
+ {
+ // Clamped on the way in as well as on the way out. The file is editable by hand, and a
+ // 400-pixel terminal is a pane with no columns in it rather than an amusing mistake.
+ TerminalFontSize = ClientSettings.ClampTerminalFontSize(stored.TerminalFontSize),
+ };
+ }
+ catch (Exception exception) when (exception is IOException
+ or UnauthorizedAccessException
+ or JsonException)
+ {
+ return new ClientSettings();
+ }
+ }
+
+ /// Stores the settings, best effort.
+ ///
+ /// Whether they reached the disk. False is worth having rather than silence: it is the difference
+ /// between a preference that will be there next launch and one that will not, and the caller is the
+ /// only thing that could say so.
+ ///
+ public bool Write(ClientSettings settings)
+ {
+ ArgumentNullException.ThrowIfNull(settings);
+
+ try
+ {
+ paths.EnsureCreated();
+
+ var temporary = paths.SettingsFile + ".tmp";
+
+ File.WriteAllText(
+ temporary,
+ JsonSerializer.Serialize(settings, ClientSettingsJsonContext.Default.ClientSettings));
+
+ File.Move(temporary, paths.SettingsFile, overwrite: true);
+
+ return true;
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
+ {
+ return false;
+ }
+ }
+}
+
+[JsonSourceGenerationOptions(
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ WriteIndented = true,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
+[JsonSerializable(typeof(ClientSettings))]
+internal sealed partial class ClientSettingsJsonContext : JsonSerializerContext;
diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
index d51c132..e80848d 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
@@ -175,6 +175,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private readonly TimeProvider clock;
private readonly Argon2Profile? passphraseProfile;
+ ///
+ /// Built here from the paths rather than taken as a dependency, because it holds preferences and not
+ /// state: there is nothing for a head to substitute, and a constructor parameter every head would pass
+ /// the same value to is a parameter that only ever makes the heads longer.
+ ///
+ private readonly ClientSettingsStore settings;
+
///
/// 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
@@ -297,8 +304,54 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// Subscribed for the life of the process, because the workspace lives that long and so does the tab
// list. Detached in DisposeAsync, which is the only point either of them ends.
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
+ this.workspace.FontSizeStepRequested += OnFontSizeStepRequested;
+
+ settings = new ClientSettingsStore(paths);
+
+ // Read straight away rather than at first use, so the value is right before anything can read it —
+ // a phone draws its terminal buttons from this, and a size that arrived a moment later would show
+ // as the interface correcting itself.
+ TerminalFontSize = ClientSettings.ClampTerminalFontSize(settings.Read().TerminalFontSize);
+
+ _ = TellRendererTheFontSizeAsync();
}
+ ///
+ ///
+ /// The page starts at its own default and has no way to know what was stored, so somebody has to tell
+ /// it — and it cannot be told before its socket exists. Waiting on the renderer is the only ordering
+ /// available; nothing else knows when the page is there.
+ ///
+ ///
+ /// Failure is silence on purpose. A launch where no terminal is ever opened still runs this, and a
+ /// renderer that never attached is not a fault in that case — it is the ordinary shape of a session
+ /// spent in the keychain. The size is sent again by every change, so nothing is permanently lost.
+ ///
+ ///
+ private async Task TellRendererTheFontSizeAsync()
+ {
+ try
+ {
+ await workspace.WaitForRendererAsync(CancellationToken.None).ConfigureAwait(false);
+
+ await workspace
+ .SetFontSizeAsync(TerminalFontSize, CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ catch (Exception exception) when (exception is TimeoutException or OperationCanceledException)
+ {
+ // No renderer this run. Nothing to tell.
+ }
+ }
+
+ ///
+ /// Marshalled, because this arrives on the data plane's receive loop — see
+ /// — and everything it touches is a view model
+ /// property somebody's interface is bound to.
+ ///
+ private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) =>
+ Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step));
+
[ObservableProperty]
private ShellState state = ShellState.Starting;
@@ -456,6 +509,80 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
internal ValueTask SendTerminalInputAsync(uint sessionId, ReadOnlyMemory data) =>
workspace.SendInputAsync(sessionId, data, CancellationToken.None);
+ ///
+ /// How large the terminal draws, in CSS pixels.
+ ///
+ ///
+ ///
+ /// A font size rather than a zoom, and the difference is the whole design. Zoom scales what is already
+ /// drawn, so the remote goes on wrapping to a width that is no longer on screen; changing the font size
+ /// refits the grid and tells the far end how many columns it now has. That is why this is one number
+ /// owned here and pushed to the renderer, rather than a gesture the page handles alone.
+ ///
+ ///
+ /// Owned by the shell rather than by the page for two reasons that pull the same way: it has to survive
+ /// a relaunch, and it has to be reachable from a button on a phone that has no keyboard to press
+ /// Ctrl+plus with. The page's chords arrive here as steps — see
+ /// — so both routes end in this property.
+ ///
+ ///
+ [ObservableProperty]
+ private int terminalFontSize = ClientSettings.DefaultTerminalFontSize;
+
+ /// Whether the terminal could be drawn larger than it is.
+ internal bool CanEnlargeTerminalFont => TerminalFontSize < ClientSettings.MaximumTerminalFontSize;
+
+ /// Whether the terminal could be drawn smaller than it is.
+ internal bool CanShrinkTerminalFont => TerminalFontSize > ClientSettings.MinimumTerminalFontSize;
+
+ /// Draws the terminal one point larger.
+ [RelayCommand]
+ private void EnlargeTerminalFont() => StepTerminalFontSize(1);
+
+ /// Draws the terminal one point smaller.
+ [RelayCommand]
+ private void ShrinkTerminalFont() => StepTerminalFontSize(-1);
+
+ /// Puts the terminal back to the size it ships at.
+ ///
+ /// Worth a command of its own rather than leaving people to count clicks back. A terminal that has been
+ /// made unreadable is hard to make readable again by eye, which is the state this exists for.
+ ///
+ [RelayCommand]
+ private void ResetTerminalFont() => ApplyTerminalFontSize(ClientSettings.DefaultTerminalFontSize);
+
+ /// Points to move by, or zero to return to the default.
+ private void StepTerminalFontSize(int step) => ApplyTerminalFontSize(
+ step == 0 ? ClientSettings.DefaultTerminalFontSize : TerminalFontSize + step);
+
+ ///
+ /// One path for every route in — the phone's buttons, the page's chords, and the stored value read at
+ /// startup — so clamping, persisting and telling the renderer happen once each rather than three times
+ /// with one of them eventually forgotten.
+ ///
+ private void ApplyTerminalFontSize(int pixels)
+ {
+ var clamped = ClientSettings.ClampTerminalFontSize(pixels);
+
+ // Told anyway when nothing moved. A step at the cap is a no-op here, but the page may have been
+ // reloaded since the last frame — and a renderer at the wrong size is worse than a redundant frame.
+ TerminalFontSize = clamped;
+
+ // Fire and forget: a socket that is not there yet is the ordinary case at startup, and a font size
+ // is not worth blocking a button handler on.
+ _ = workspace.SetFontSizeAsync(clamped, CancellationToken.None).AsTask();
+
+ // Read-modify-write against the file rather than against a field, so a setting this build does not
+ // know about — written by a newer one, or by hand — survives this one storing its own.
+ settings.Write(settings.Read() with { TerminalFontSize = clamped });
+ }
+
+ partial void OnTerminalFontSizeChanged(int value)
+ {
+ OnPropertyChanged(nameof(CanEnlargeTerminalFont));
+ OnPropertyChanged(nameof(CanShrinkTerminalFont));
+ }
+
///
/// Raised when a terminal session opens, so the view can hand the terminal the keyboard.
///
@@ -1770,6 +1897,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
disposed = true;
workspace.SessionEnded -= OnWorkspaceSessionEnded;
+ workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
knownHosts.Close();
diff --git a/src/DodoSSH.Client.Shell/WebAssets/terminal.js b/src/DodoSSH.Client.Shell/WebAssets/terminal.js
index 0a6b989..b44fed7 100644
--- a/src/DodoSSH.Client.Shell/WebAssets/terminal.js
+++ b/src/DodoSSH.Client.Shell/WebAssets/terminal.js
@@ -23,14 +23,24 @@ const SERVER_SESSION_CLOSED = 3;
const SERVER_SESSION_ACTIVATED = 4;
const SERVER_SESSION_REMOVED = 5;
const SERVER_PASTE = 6;
+const SERVER_FONT_SIZE = 7;
const CLIENT_INPUT = 1;
const CLIENT_ACKNOWLEDGE = 2;
const CLIENT_RESIZE = 3;
+const CLIENT_FONT_SIZE_STEP = 4;
const HEADER_LENGTH = 5;
const SCROLLBACK_LINES = 5000;
+/*
+ The size panes are created at, until the host says otherwise — which it does as soon as it has read
+ the stored preference, usually before the first session exists. Kept here as well so a pane opened
+ before that frame arrives is not created at some other size and then jumped.
+*/
+const DEFAULT_FONT_SIZE = 13;
+let fontSize = DEFAULT_FONT_SIZE;
+
/*
The way out of the terminal, for someone using only a keyboard.
@@ -107,17 +117,78 @@ function releaseFocusToHost() {
}
/**
- * Swallows the release-focus shortcut so it never reaches the remote.
+ * Asks the host to move the font size, or to put it back (step 0).
*
- * Returning false stops xterm processing the event, which is what keeps the chord from being encoded
+ * A request rather than a change made here: the host owns the size, because the host is what remembers
+ * it between launches and what draws the buttons the phone uses. The answer arrives as a
+ * SERVER_FONT_SIZE frame, so this route and that one end in the same place.
+ */
+function requestFontSizeStep(step) {
+ const payload = new Uint8Array(1);
+ new DataView(payload.buffer).setInt8(0, step);
+
+ // Session zero: the size is not a property of any one terminal.
+ send(CLIENT_FONT_SIZE_STEP, 0, payload);
+}
+
+/**
+ * Applies a size to every pane, and to panes opened after this.
+ *
+ * Refitting is not optional. The cell size has changed, so the column and row counts have too, and a
+ * pane left unfitted draws a grid the remote is not wrapping to. fit() sends the resize frame that
+ * tells the far end.
+ */
+function applyFontSize(size) {
+ fontSize = size;
+
+ for (const [sessionId, session] of sessions) {
+ session.term.options.fontSize = size;
+ resize(session, sessionId);
+ }
+}
+
+/**
+ * Swallows the shortcuts that belong to the terminal application rather than to the remote.
+ *
+ * Returning false stops xterm processing the event, which is what keeps a chord from being encoded
* and written to the pty.
*/
function handleKey(event) {
- if (event.type === 'keydown' && event.ctrlKey && event.shiftKey && event.key === 'F6') {
+ if (event.type !== 'keydown') {
+ return true;
+ }
+
+ if (event.ctrlKey && event.shiftKey && event.key === 'F6') {
releaseFocusToHost();
return false;
}
+ /*
+ Ctrl with plus, minus and zero — what every terminal emulator and every browser uses for text size,
+ and it has to be caught here for the reason the release-focus chord does: while a terminal has focus
+ the host's window receives no key events at all, so nothing on that side could hear it.
+
+ Both spellings of plus, because the key that is drawn as + on the keycap reports as '+' when Shift
+ is read and as '=' when it is not, and which one arrives is not something the person pressing it
+ should have to know. Same for minus and underscore.
+ */
+ if (event.ctrlKey && !event.altKey) {
+ if (event.key === '+' || event.key === '=') {
+ requestFontSizeStep(1);
+ return false;
+ }
+
+ if (event.key === '-' || event.key === '_') {
+ requestFontSizeStep(-1);
+ return false;
+ }
+
+ if (event.key === '0') {
+ requestFontSizeStep(0);
+ return false;
+ }
+ }
+
return true;
}
@@ -131,7 +202,7 @@ function createSession(sessionId) {
allowProposedApi: true,
convertEol: false,
cursorBlink: true,
- fontSize: 13,
+ fontSize,
scrollback: SCROLLBACK_LINES,
// Matches terminal.css, so the canvas and the page agree on the background.
theme: { background: '#10131a', foreground: '#d5d8de' },
@@ -309,6 +380,18 @@ function handleFrame(buffer) {
break;
}
+ case SERVER_FONT_SIZE: {
+ if (payload.length < 1) {
+ break;
+ }
+
+ // Applied even with no sessions open, which is the common case at startup: the host sends the
+ // stored size as soon as this page attaches, and the first pane is then created at it rather
+ // than being created small and resized in front of the user.
+ applyFontSize(payload[0]);
+ break;
+ }
+
case SERVER_SESSION_CLOSED: {
const session = sessions.get(sessionId);
const reason = new TextDecoder().decode(payload);
diff --git a/src/DodoSSH.Client.Terminal/TerminalDataPlane.cs b/src/DodoSSH.Client.Terminal/TerminalDataPlane.cs
index 241c194..50bdd91 100644
--- a/src/DodoSSH.Client.Terminal/TerminalDataPlane.cs
+++ b/src/DodoSSH.Client.Terminal/TerminalDataPlane.cs
@@ -98,6 +98,14 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
/// Completes once the renderer has attached its socket.
public Task RendererAttached => rendererAttached.Task;
+ /// Raised when the page asks for a different font size.
+ ///
+ /// Raised on the socket's receive loop rather than any UI thread, so a handler that touches view models
+ /// has to marshal. forwards it as it arrives and leaves that to the
+ /// shell, which is where the thread affinity is known.
+ ///
+ public event EventHandler? FontSizeStepRequested;
+
/// Registers a session so inbound frames can be routed to it.
public void Register(uint sessionId, TerminalSessionPump pump)
{
@@ -385,6 +393,19 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
return;
}
+ // Answered before the session lookup, because it is the one frame that is not about a session. The
+ // page sends whichever id it had to hand, and a chord pressed in a terminal whose shell has just
+ // ended is still a request to make the text bigger.
+ if ((TerminalClientOpcode)opcode is TerminalClientOpcode.FontSizeStep)
+ {
+ if (TerminalFrame.TryReadFontSizeStep(payload, out var step))
+ {
+ FontSizeStepRequested?.Invoke(this, new TerminalFontSizeStepEventArgs(step));
+ }
+
+ return;
+ }
+
TerminalSessionPump? pump;
lock (pumpGate)
{
diff --git a/src/DodoSSH.Client.Terminal/TerminalFrame.cs b/src/DodoSSH.Client.Terminal/TerminalFrame.cs
index 11c4105..5eda2cf 100644
--- a/src/DodoSSH.Client.Terminal/TerminalFrame.cs
+++ b/src/DodoSSH.Client.Terminal/TerminalFrame.cs
@@ -76,6 +76,25 @@ public enum TerminalServerOpcode : byte
///
///
Paste = 6,
+
+ ///
+ /// What size every pane should draw at. Payload is one byte, the size in CSS pixels.
+ ///
+ ///
+ ///
+ /// Not per session, although the frame carries a session id as every frame does: a font size is a
+ /// preference about reading rather than a property of one remote, and somebody who makes the text
+ /// bigger means all of it. The id is sent as zero and ignored.
+ ///
+ ///
+ /// Changing it reflows the remote, and that is the point. The page refits after applying, which
+ /// changes the column and row count and sends back — so a
+ /// larger font is a terminal with fewer columns, and the far end is told about it. That is why this is
+ /// a font size rather than a zoom: zoom scales pixels and leaves the remote wrapping to a width that
+ /// is no longer on screen, which is what made the phone's pinch-zoom worse than useless.
+ ///
+ ///
+ FontSize = 7,
}
/// Frames the renderer sends to the host.
@@ -94,6 +113,24 @@ public enum TerminalClientOpcode : byte
/// The terminal was resized. Payload is four big-endian values.
Resize = 3,
+
+ ///
+ /// The page asking for a different font size. Payload is one signed byte: a step, or zero to reset.
+ ///
+ ///
+ ///
+ /// A request rather than a statement, and the direction matters. The page owns the keyboard whenever a
+ /// terminal has focus — the host's window sees no key events at all then — so Ctrl+plus and Ctrl+minus
+ /// can only be heard there. But the size has to be remembered across launches and shown in a
+ /// control the page knows nothing about, so it is decided here. The page asks, the host
+ /// clamps, stores and answers with .
+ ///
+ ///
+ /// Which also means one path, not two: the phone's buttons and the desktop's chords arrive at the same
+ /// place, and a size set by either is the size both remember.
+ ///
+ ///
+ FontSizeStep = 4,
}
///
@@ -230,6 +267,31 @@ public static class TerminalFrame
return payload;
}
+ /// Reads a payload.
+ ///
+ /// Signed, because the one byte carries a direction as well as a magnitude — and zero means reset,
+ /// which is why this cannot be a flag pair.
+ ///
+ public static bool TryReadFontSizeStep(ReadOnlySpan payload, out int step)
+ {
+ step = 0;
+
+ if (payload.Length != 1)
+ {
+ return false;
+ }
+
+ step = (sbyte)payload[0];
+
+ return true;
+ }
+
+ /// Writes a payload.
+ public static byte[] CreateFontSizeStepPayload(int step) => [(byte)(sbyte)Math.Clamp(step, -128, 127)];
+
+ /// Writes a payload.
+ public static byte[] CreateFontSizePayload(int pixels) => [(byte)Math.Clamp(pixels, 1, 255)];
+
/// Writes an payload.
public static byte[] CreateAcknowledgementPayload(uint rendered)
{
diff --git a/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs b/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs
index 7e26335..c8190cf 100644
--- a/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs
+++ b/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs
@@ -37,6 +37,18 @@ public sealed class TerminalSessionEndedEventArgs(uint sessionId) : EventArgs
public uint SessionId { get; } = sessionId;
}
+/// The renderer asking for a different font size.
+///
+/// How far to move, in points of font size, or zero to go back to the default. It is a step rather than a
+/// size because the page does not hold the current one — the host does, and clamping a step is what stops
+/// two chords in flight from disagreeing about where they started.
+///
+public sealed class TerminalFontSizeStepEventArgs(int step) : EventArgs
+{
+ /// The requested move, or zero for "back to the default".
+ public int Step { get; } = step;
+}
+
///
/// Owns the loopback data plane and every live terminal session.
///
@@ -96,6 +108,11 @@ public sealed class TerminalWorkspace : IAsyncDisposable
this.options = options ?? new TerminalWorkspaceOptions();
dataPlane = new TerminalDataPlane(assets);
+
+ // Forwarded rather than re-raised with the workspace as the sender, so a handler can tell where it
+ // came from. Nothing here decides anything about the size: the shell owns it, because the shell is
+ // what remembers it between launches.
+ dataPlane.FontSizeStepRequested += (_, e) => FontSizeStepRequested?.Invoke(this, e);
}
///
@@ -186,9 +203,42 @@ public sealed class TerminalWorkspace : IAsyncDisposable
///
public event EventHandler? SessionEnded;
+ /// Raised when the renderer's own keyboard asks for a different font size.
+ ///
+ /// The chords can only be heard by the page — once a terminal has focus the host's window sees no key
+ /// events at all — so this is how Ctrl+plus reaches the thing that owns the setting. Raised on the
+ /// socket's receive loop; marshal before touching a view model.
+ ///
+ public event EventHandler? FontSizeStepRequested;
+
/// Starts the loopback listener.
public void Start() => server = dataPlane.RunAsync(lifetime.Token);
+ ///
+ /// Tells every pane what size to draw at.
+ ///
+ ///
+ ///
+ /// Sent to the page rather than applied per session, and unconditionally rather than only when a
+ /// session is live: the page keeps the size for panes opened later, so this is also how the first
+ /// terminal of a launch comes up at the size the user last chose.
+ ///
+ ///
+ /// Every live pane refits as a result and reports its new geometry, so the remotes are told they have
+ /// fewer columns. That round trip is the feature rather than a side effect — see
+ /// .
+ ///
+ ///
+ /// The size in CSS pixels. Clamped by the caller; sent as one byte.
+ /// Cancellation.
+ public ValueTask SetFontSizeAsync(int pixels, CancellationToken cancellationToken) =>
+ dataPlane.SendAsync(
+ TerminalFrame.Create(
+ (byte)TerminalServerOpcode.FontSize,
+ sessionId: 0,
+ TerminalFrame.CreateFontSizePayload(pixels)),
+ cancellationToken);
+
///
/// Waits until the renderer page has attached its socket.
///
diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
index 2b27a94..d93fbea 100644
--- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
@@ -143,6 +143,88 @@ public sealed class ShellFlowTests : IAsyncLifetime
}
}
+ [Fact]
+ public void TheTerminalFontSize_StartsAtTheSizeTheRendererDrawsAt()
+ {
+ // The page creates panes at its own constant until it is told otherwise. A different number here
+ // would show as the terminal resizing itself on every launch, in front of the user.
+ shell.TerminalFontSize.ShouldBe(ClientSettings.DefaultTerminalFontSize);
+ }
+
+ ///
+ /// The cap is what stops "larger" arriving at a terminal too narrow to hold a prompt — this resizes the
+ /// grid rather than magnifying it, so every step up is columns taken away from the remote. Asserted
+ /// through the command rather than the clamp so the disabled state is covered with it: a button that
+ /// keeps accepting presses and does nothing reads as the application having stopped responding.
+ ///
+ [Fact]
+ public void EnlargingPastTheCap_StopsAndSaysSo()
+ {
+ for (var i = 0; i < 100; i++)
+ {
+ shell.EnlargeTerminalFontCommand.Execute(null);
+ }
+
+ shell.TerminalFontSize.ShouldBe(ClientSettings.MaximumTerminalFontSize);
+ shell.CanEnlargeTerminalFont.ShouldBeFalse();
+ shell.CanShrinkTerminalFont.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void ShrinkingPastTheFloor_StopsAndSaysSo()
+ {
+ for (var i = 0; i < 100; i++)
+ {
+ shell.ShrinkTerminalFontCommand.Execute(null);
+ }
+
+ shell.TerminalFontSize.ShouldBe(ClientSettings.MinimumTerminalFontSize);
+ shell.CanShrinkTerminalFont.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ResettingTheTerminalFont_GoesBackToTheDefault()
+ {
+ shell.EnlargeTerminalFontCommand.Execute(null);
+ shell.EnlargeTerminalFontCommand.Execute(null);
+
+ shell.ResetTerminalFontCommand.Execute(null);
+
+ shell.TerminalFontSize.ShouldBe(ClientSettings.DefaultTerminalFontSize);
+ }
+
+ ///
+ /// The reason the setting is a file beside the cache rather than a row inside it: this has to be
+ /// readable on a launch that never unlocks anything, which is every launch up to the passphrase. A
+ /// second shell over the same profile directory is exactly that launch.
+ ///
+ [Fact]
+ public async Task ASizeChosenOnce_IsThereOnTheNextLaunch()
+ {
+ shell.EnlargeTerminalFontCommand.Execute(null);
+ shell.EnlargeTerminalFontCommand.Execute(null);
+
+ var chosen = shell.TerminalFontSize;
+ chosen.ShouldBe(ClientSettings.DefaultTerminalFontSize + 2);
+
+ var relaunched = new MainWindowViewModel(
+ paths,
+ caches,
+ workspace,
+ knownHosts,
+ deviceKeys,
+ SignInAsync,
+ TimeProvider.System,
+ ssh,
+ CheapProfile,
+ ResumeAsync);
+
+ await using (relaunched.ConfigureAwait(false))
+ {
+ relaunched.TerminalFontSize.ShouldBe(chosen);
+ }
+ }
+
[Fact]
public async Task AFreshMachine_AsksForAServer()
{
diff --git a/tests/DodoSSH.Client.Terminal.Tests/TerminalDataPlaneTests.cs b/tests/DodoSSH.Client.Terminal.Tests/TerminalDataPlaneTests.cs
index 8a0dc92..1c0df1f 100644
--- a/tests/DodoSSH.Client.Terminal.Tests/TerminalDataPlaneTests.cs
+++ b/tests/DodoSSH.Client.Terminal.Tests/TerminalDataPlaneTests.cs
@@ -247,6 +247,34 @@ public sealed class TerminalDataPlaneTests : IAsyncDisposable
await WaitUntilAsync(() => pump.Credits.Outstanding == 0);
}
+ ///
+ /// The point of this one is the absence of a registered session. Every other client frame is
+ /// about a terminal and is dropped once its session has gone; a text-size chord is about the person
+ /// reading, and the page sends whichever id it had to hand — often one whose shell has just ended,
+ /// which is exactly when somebody may be squinting at the message it left behind. Dispatching this
+ /// opcode before the session lookup is what makes that work.
+ ///
+ [Fact]
+ public async Task AFontSizeStep_IsHeardWithNoSessionRegistered()
+ {
+ Start();
+
+ using var socket = await ConnectAsync();
+
+ var steps = new List();
+ plane.FontSizeStepRequested += (_, e) => steps.Add(e.Step);
+
+ await SendAsync(
+ socket,
+ (byte)TerminalClientOpcode.FontSizeStep,
+ TerminalFrame.CreateFontSizeStepPayload(-1),
+ sessionId: 0);
+
+ await WaitUntilAsync(() => steps.Count > 0);
+
+ steps[0].ShouldBe(-1);
+ }
+
[Fact]
public async Task AFrameForAnUnregisteredSession_IsIgnored()
{
diff --git a/tests/DodoSSH.Client.Terminal.Tests/TerminalFrameTests.cs b/tests/DodoSSH.Client.Terminal.Tests/TerminalFrameTests.cs
index a68e90f..c4afbbe 100644
--- a/tests/DodoSSH.Client.Terminal.Tests/TerminalFrameTests.cs
+++ b/tests/DodoSSH.Client.Terminal.Tests/TerminalFrameTests.cs
@@ -114,4 +114,38 @@ public sealed class TerminalFrameTests
payload[2].ShouldBe((byte)0);
payload[3].ShouldBe((byte)24);
}
+
+ ///
+ /// The step byte is signed, and this is what says so. Read as unsigned, a step down arrives as 255 —
+ /// which the shell clamps to the largest font it will set, so getting this wrong makes "smaller" do
+ /// the most dramatic available version of "larger".
+ ///
+ [Theory]
+ [InlineData(1)]
+ [InlineData(-1)]
+ [InlineData(0)]
+ [InlineData(-8)]
+ public void AFontSizeStep_RoundTripsWithItsSign(int step)
+ {
+ var payload = TerminalFrame.CreateFontSizeStepPayload(step);
+
+ TerminalFrame.TryReadFontSizeStep(payload, out var decoded).ShouldBeTrue();
+ decoded.ShouldBe(step);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(2)]
+ public void AFontSizeStepOfTheWrongLength_IsRejected(int length)
+ {
+ TerminalFrame.TryReadFontSizeStep(new byte[length], out _).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void AFontSize_IsOneUnsignedByte()
+ {
+ // Unsigned, unlike the step: a size is a size, and the byte is what the page reads straight into
+ // xterm's fontSize option.
+ TerminalFrame.CreateFontSizePayload(20).ShouldBe([(byte)20]);
+ }
}