Let the terminal's text be made bigger, and remember how big
ci / build and test (push) Successful in 1m12s
ci / android head (push) Failing after 4s
ci / api image (push) Successful in 24s

Taking pinch-zoom off the phone left nothing in its place, and there was nothing on the desktop
either. This is the replacement, and it is deliberately not the thing that was removed: zoom scales
what has already been drawn, so the remote goes on wrapping to a width that is no longer on screen.
Changing the font size refits the grid and reports the new column count, so the far end is told it
has fewer columns. That round trip is the feature.

The size is one number, owned by the shell. It has to be, for two reasons that pull the same way: it
must survive a relaunch, and it must be reachable from a phone that has no Ctrl key to press. So the
page asks and the host decides — a signed step over a new client opcode, answered with a size over a
new server opcode. 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.

Stored in settings.json beside the cache rather than in it, and that is not laziness about a
migration. The cache is encrypted and unreadable until a vault is unlocked, and the first terminal of
a locked launch needs the size already. Nothing secret may go in that file; ClientSettings says so
out loud, because the next person to add a preference is the one who needs to read it.

Where it is reachable from differs per head, and only here. The phone gets A− and A+ on the
connection line — not in the accessory row, which scrolls, and a control that fixes unreadable text
must never be the thing that is off-screen. The desktop gets the three chords every terminal
emulator has, answered by the page while a terminal has focus and by the window when it does not,
plus a row in preferences that shows the current value and names the chords rather than replacing
them. Someone whose terminal is too small to read is not in a position to go looking.

Clamped 8 to 32. Below eight a monospace grid stops being legible and becomes a texture, and every
column of it is still a column the remote is being told exists; above thirty-two a phone in portrait
has too few columns to hold a prompt. The buttons disable at the ends rather than accepting presses
that do nothing, which on a terminal reads as the application having stopped responding.

The preferences screen's header comment claimed none of the design's terminal settings could be
saved, and listed the three things that were missing to make one work. All three now exist, so it
says which one is real and why the other five still are not.

Verified with the protocol suite — including that the step byte round-trips signed, since read
unsigned a step down arrives as 255 and clamps to the largest font, making "smaller" do the most
dramatic available version of "larger" — a data-plane test that the chord is heard with no session
registered, and five shell tests: the default matches the renderer's, both clamps hold, reset works,
and a size chosen in one shell is there in a second one over the same profile directory. Layout
suite and both heads build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 13:15:11 +02:00
co-authored by Claude Opus 5
parent 5cb9ffaf9d
commit c00e5dbc5c
13 changed files with 742 additions and 11 deletions
@@ -62,11 +62,40 @@
</Border>
<!-- ============ the connection line ============ -->
<!--
The text-size buttons live here rather than in the accessory row below, and the row is the reason:
that one scrolls, so a key can be off-screen, and these two must not be — a terminal that is too
small to read is exactly the state in which hunting for the control that fixes it is worst.
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head has
no keyboard to press it with, which is why the two heads differ here and nowhere else in this screen.
-->
<Border Grid.Row="1" Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1" Padding="14,5"
IsVisible="{Binding SelectedTab, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock Classes="detail" FontSize="9.5" TextTrimming="CharacterEllipsis"
Text="{Binding SelectedTab.Address}" />
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" Classes="detail" FontSize="9.5" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" Text="{Binding SelectedTab.Address}" />
<!--
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
nothing reads as the terminal having stopped responding, which is the one thing this screen must
never look like.
-->
<Button Grid.Column="1" Classes="row" MinHeight="34" MinWidth="38" Padding="0"
HorizontalContentAlignment="Center"
Command="{Binding ShrinkTerminalFontCommand}"
IsEnabled="{Binding CanShrinkTerminalFont}">
<TextBlock Classes="mono" FontSize="13" Text="A" />
</Button>
<Button Grid.Column="2" Classes="row" MinHeight="34" MinWidth="38" Padding="0" Margin="4,0,0,0"
HorizontalContentAlignment="Center"
Command="{Binding EnlargeTerminalFontCommand}"
IsEnabled="{Binding CanEnlargeTerminalFont}">
<TextBlock Classes="mono" FontSize="15" Text="A+" />
</Button>
</Grid>
</Border>
<!-- ============ the renderer ============ -->
@@ -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);
}
/// <summary>
/// The text-size chords, when the terminal is not the thing hearing them.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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)
@@ -8,11 +8,13 @@
<!--
Preferences.
The design's rail has six sections and its TERMINAL panel has six settings. None of the six settings can
be saved: there is no preferences table in the local cache, no preference item type in the vault, and no
channel from the host to the renderer that carries a terminal option — the data plane's frames are
output, session-opened, session-closed and session-activated, and nothing else. A font-size stepper
that reset on every launch would be worse than not having one.
The design's rail has six sections and its TERMINAL panel has six settings. One of those six is now
real: text size. It needed all three of the things this comment used to record as missing — somewhere
to keep a preference (settings.json beside the cache, outside it deliberately, so it can be read on a
launch that never unlocks anything), a frame carrying a terminal option (TerminalServerOpcode.FontSize),
and a way for the page's own chords to reach the host that owns the value
(TerminalClientOpcode.FontSizeStep). The rule that kept it off this screen until then still stands and
is why the storage came first: a stepper that reset on every launch is worse than no stepper.
So this screen ships what is real, which is not nothing: this machine's device key is a genuine
preference with a genuine effect, and it is the one thing on the design's SECURITY panel that exists.
@@ -59,6 +61,40 @@
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="TERMINAL" FontSize="13" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" />
<!--
Here as well as on the chord, and not because the chord is in doubt. Ctrl+plus can only be heard
while a terminal has focus, since that is where the keyboard is being read — so somebody who has
not opened one yet, or who has just made the text too small to find anything in, has nowhere else
to look. This is that place, and it names the chord so the screen teaches it rather than replacing
it.
-->
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Text size" Foreground="{StaticResource Text}" FontSize="12"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="10"
Text="How large a terminal draws, in pixels. Ctrl+plus and Ctrl+minus do the same while a terminal has focus, and Ctrl+0 puts it back. It resizes the grid rather than magnifying it, so the remote is told how many columns it now has — which is also why it stops before the columns run out." />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" VerticalAlignment="Top">
<Button Classes="ghost" Content="A" Command="{Binding ShrinkTerminalFontCommand}"
IsEnabled="{Binding CanShrinkTerminalFont}"
ToolTip.Tip="Smaller · Ctrl+minus" />
<TextBlock Classes="mono" FontSize="12" MinWidth="26" VerticalAlignment="Center"
TextAlignment="Center" Foreground="{StaticResource Text}"
Text="{Binding TerminalFontSize}" />
<Button Classes="ghost" Content="A+" Command="{Binding EnlargeTerminalFontCommand}"
IsEnabled="{Binding CanEnlargeTerminalFont}"
ToolTip.Tip="Larger · Ctrl+plus" />
<Button Classes="ghost" Content="RESET" Command="{Binding ResetTerminalFontCommand}"
ToolTip.Tip="Back to the size it ships at · Ctrl+0" />
</StackPanel>
</Grid>
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="KEYCHAIN" FontSize="13" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" />
+10
View File
@@ -29,6 +29,16 @@ public sealed record ClientPaths(string DataDirectory)
/// <summary>The encrypted local cache.</summary>
public string CacheFile => Path.Combine(DataDirectory, "cache.db");
/// <summary>
/// This machine's preferences, in plaintext.
/// </summary>
/// <remarks>
/// Beside the cache rather than inside it, because everything in it is needed <em>before</em> 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 <see cref="ClientSettings"/>.
/// </remarks>
public string SettingsFile => Path.Combine(DataDirectory, "settings.json");
/// <summary>
/// This machine's device key, encrypted to a key it cannot export.
/// </summary>
@@ -0,0 +1,138 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Session;
/// <summary>
/// Preferences that belong to this machine rather than to the vault.
/// </summary>
/// <remarks>
/// <para>
/// <b>Deliberately not in the cache, and deliberately not synced.</b> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public sealed record ClientSettings
{
/// <summary>The size a terminal draws at with nothing stored.</summary>
/// <remarks>
/// 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.
/// </remarks>
public const int DefaultTerminalFontSize = 13;
/// <summary>Smallest terminal font size the shell will set.</summary>
/// <remarks>
/// 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.
/// </remarks>
public const int MinimumTerminalFontSize = 8;
/// <summary>Largest terminal font size the shell will set.</summary>
/// <remarks>
/// 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.
/// </remarks>
public const int MaximumTerminalFontSize = 32;
/// <summary>The terminal font size, in CSS pixels.</summary>
public int TerminalFontSize { get; init; } = DefaultTerminalFontSize;
/// <summary>Brings a value inside the range this type will store.</summary>
public static int ClampTerminalFontSize(int pixels) =>
Math.Clamp(pixels, MinimumTerminalFontSize, MaximumTerminalFontSize);
}
/// <summary>
/// Reads and writes <see cref="ClientSettings"/> as a small JSON file.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// Written through a temporary file and moved into place, so an interrupted write leaves the previous
/// settings rather than half of the new ones.
/// </para>
/// </remarks>
public sealed class ClientSettingsStore(ClientPaths paths)
{
/// <summary>Reads the stored settings, or the defaults.</summary>
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();
}
}
/// <summary>Stores the settings, best effort.</summary>
/// <returns>
/// 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.
/// </returns>
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;
@@ -175,6 +175,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private readonly TimeProvider clock;
private readonly Argon2Profile? passphraseProfile;
/// <remarks>
/// 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.
/// </remarks>
private readonly ClientSettingsStore settings;
/// <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
@@ -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();
}
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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.
}
}
/// <remarks>
/// Marshalled, because this arrives on the data plane's receive loop — see
/// <see cref="TerminalWorkspace.FontSizeStepRequested"/> — and everything it touches is a view model
/// property somebody's interface is bound to.
/// </remarks>
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<byte> data) =>
workspace.SendInputAsync(sessionId, data, CancellationToken.None);
/// <summary>
/// How large the terminal draws, in CSS pixels.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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
/// <see cref="TerminalFontSizeStepEventArgs"/> — so both routes end in this property.
/// </para>
/// </remarks>
[ObservableProperty]
private int terminalFontSize = ClientSettings.DefaultTerminalFontSize;
/// <summary>Whether the terminal could be drawn larger than it is.</summary>
internal bool CanEnlargeTerminalFont => TerminalFontSize < ClientSettings.MaximumTerminalFontSize;
/// <summary>Whether the terminal could be drawn smaller than it is.</summary>
internal bool CanShrinkTerminalFont => TerminalFontSize > ClientSettings.MinimumTerminalFontSize;
/// <summary>Draws the terminal one point larger.</summary>
[RelayCommand]
private void EnlargeTerminalFont() => StepTerminalFontSize(1);
/// <summary>Draws the terminal one point smaller.</summary>
[RelayCommand]
private void ShrinkTerminalFont() => StepTerminalFontSize(-1);
/// <summary>Puts the terminal back to the size it ships at.</summary>
/// <remarks>
/// 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.
/// </remarks>
[RelayCommand]
private void ResetTerminalFont() => ApplyTerminalFontSize(ClientSettings.DefaultTerminalFontSize);
/// <param name="step">Points to move by, or zero to return to the default.</param>
private void StepTerminalFontSize(int step) => ApplyTerminalFontSize(
step == 0 ? ClientSettings.DefaultTerminalFontSize : TerminalFontSize + step);
/// <remarks>
/// 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.
/// </remarks>
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));
}
/// <summary>
/// Raised when a terminal session opens, so the view can hand the terminal the keyboard.
/// </summary>
@@ -1770,6 +1897,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
disposed = true;
workspace.SessionEnded -= OnWorkspaceSessionEnded;
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
knownHosts.Close();
+87 -4
View File
@@ -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);
@@ -98,6 +98,14 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
/// <summary>Completes once the renderer has attached its socket.</summary>
public Task RendererAttached => rendererAttached.Task;
/// <summary>Raised when the page asks for a different font size.</summary>
/// <remarks>
/// Raised on the socket's receive loop rather than any UI thread, so a handler that touches view models
/// has to marshal. <see cref="TerminalWorkspace"/> forwards it as it arrives and leaves that to the
/// shell, which is where the thread affinity is known.
/// </remarks>
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
/// <summary>Registers a session so inbound frames can be routed to it.</summary>
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)
{
@@ -76,6 +76,25 @@ public enum TerminalServerOpcode : byte
/// </para>
/// </remarks>
Paste = 6,
/// <summary>
/// What size every pane should draw at. Payload is one byte, the size in CSS pixels.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>Changing it reflows the remote, and that is the point.</b> The page refits after applying, which
/// changes the column and row count and sends <see cref="TerminalClientOpcode.Resize"/> 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.
/// </para>
/// </remarks>
FontSize = 7,
}
/// <summary>Frames the renderer sends to the host.</summary>
@@ -94,6 +113,24 @@ public enum TerminalClientOpcode : byte
/// <summary>The terminal was resized. Payload is four big-endian <see cref="ushort"/> values.</summary>
Resize = 3,
/// <summary>
/// The page asking for a different font size. Payload is one signed byte: a step, or zero to reset.
/// </summary>
/// <remarks>
/// <para>
/// 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 <em>heard</em> there. But the size has to be remembered across launches and shown in a
/// control the page knows nothing about, so it is <em>decided</em> here. The page asks, the host
/// clamps, stores and answers with <see cref="TerminalServerOpcode.FontSize"/>.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
FontSizeStep = 4,
}
/// <summary>
@@ -230,6 +267,31 @@ public static class TerminalFrame
return payload;
}
/// <summary>Reads a <see cref="TerminalClientOpcode.FontSizeStep"/> payload.</summary>
/// <remarks>
/// 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.
/// </remarks>
public static bool TryReadFontSizeStep(ReadOnlySpan<byte> payload, out int step)
{
step = 0;
if (payload.Length != 1)
{
return false;
}
step = (sbyte)payload[0];
return true;
}
/// <summary>Writes a <see cref="TerminalClientOpcode.FontSizeStep"/> payload.</summary>
public static byte[] CreateFontSizeStepPayload(int step) => [(byte)(sbyte)Math.Clamp(step, -128, 127)];
/// <summary>Writes a <see cref="TerminalServerOpcode.FontSize"/> payload.</summary>
public static byte[] CreateFontSizePayload(int pixels) => [(byte)Math.Clamp(pixels, 1, 255)];
/// <summary>Writes an <see cref="TerminalClientOpcode.Acknowledge"/> payload.</summary>
public static byte[] CreateAcknowledgementPayload(uint rendered)
{
@@ -37,6 +37,18 @@ public sealed class TerminalSessionEndedEventArgs(uint sessionId) : EventArgs
public uint SessionId { get; } = sessionId;
}
/// <summary>The renderer asking for a different font size.</summary>
/// <param name="step">
/// 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.
/// </param>
public sealed class TerminalFontSizeStepEventArgs(int step) : EventArgs
{
/// <summary>The requested move, or zero for "back to the default".</summary>
public int Step { get; } = step;
}
/// <summary>
/// Owns the loopback data plane and every live terminal session.
/// </summary>
@@ -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);
}
/// <summary>
@@ -186,9 +203,42 @@ public sealed class TerminalWorkspace : IAsyncDisposable
/// </remarks>
public event EventHandler<TerminalSessionEndedEventArgs>? SessionEnded;
/// <summary>Raised when the renderer's own keyboard asks for a different font size.</summary>
/// <remarks>
/// 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.
/// </remarks>
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
/// <summary>Starts the loopback listener.</summary>
public void Start() => server = dataPlane.RunAsync(lifetime.Token);
/// <summary>
/// Tells every pane what size to draw at.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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
/// <see cref="TerminalServerOpcode.FontSize"/>.
/// </para>
/// </remarks>
/// <param name="pixels">The size in CSS pixels. Clamped by the caller; sent as one byte.</param>
/// <param name="cancellationToken">Cancellation.</param>
public ValueTask SetFontSizeAsync(int pixels, CancellationToken cancellationToken) =>
dataPlane.SendAsync(
TerminalFrame.Create(
(byte)TerminalServerOpcode.FontSize,
sessionId: 0,
TerminalFrame.CreateFontSizePayload(pixels)),
cancellationToken);
/// <summary>
/// Waits until the renderer page has attached its socket.
/// </summary>