Public Access
Let the terminal's text be made bigger, and remember how big
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:
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user