diff --git a/CHANGELOG.md b/CHANGELOG.md
index 03c94538..77bb268f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,10 +9,12 @@ and a release's notes are its section. The procedure is in [`docs/RELEASING.md`]
### Added
-- The desktop window now supports live terminal zoom: Ctrl++ (or Ctrl+=), Ctrl+-, and Ctrl+0, plus
- Zoom in/out/reset in the output pane's right-click menu. Zoom ranges from 50% to 200% and resets to
- the launch font size. It resizes the real terminal grid, including per-pane NAWS, rather than stretching
- a screenshot. Zoom lasts for this window; `--font-size` still chooses the startup size.
+- Text size and zoom in the desktop window. Ctrl+= and Ctrl+- zoom the whole window in and out, as a browser does
+ (50% to 300%), Ctrl+0 goes back to 100%, and so does Ctrl with the mouse wheel or the output's right-click menu. The
+ zoom is kept between runs. *Text size & zoom* in the settings window (or the command palette) sets the game text's
+ size in points and, each on its own, the composer, the header and sidebar, the panels, and menus and dialogs. The
+ panes are re-measured and each game is told its new window size. In a terminal the text size is the terminal's own;
+ help says how to change it there.
- Three ways to start SharpMUTerm, side by side. The Windows install adds *SharpMUTerm (Emulated)* and *SharpMUTerm
(Native)* to the Start menu beside *SharpMUTerm*, and a SharpMUTerm profile to Windows Terminal. The AppImage offers
diff --git a/CLAUDE.md b/CLAUDE.md
index 70ccad62..53b93128 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2033,6 +2033,33 @@ draws ⌃P natively (`INativePalette`, null for the cell popup), whether it draw
cell short; each view says its rules (`EdgeRules(size)`) and an unhittable `SeamRules` layer draws the half cell to
`TerminalView.VerticalHairlines()`. `DrawDividers` runs a horizontal divider and the field strip's top rule on to a vertical
hairline the same way. Presses there are the cells' (grab area unchanged); no cell or NAWS moves. `DividerSeamTests`.
+- **Zoom and text sizes are client settings the window draws at** (Core `DisplaySettings`/`ZoomLevels`,
+ `AppConfiguration.Display`; Client `BuiltInSettings.TextSize`, page *Text size & zoom*; Tui `SharpMUTermApp.TextSize.cs`,
+ `ClientHost.TextSize` → `IWindowTextSize`, null in the terminal, headless and the parity harness; Avalonia `WindowTextSize`,
+ `NativeScale`/`NativeScales`, `MainWindow.ApplyTextSizes`). Decisions not to relitigate:
+ - **The zoom is the whole window's, as a browser's** (steps 50–300%, `ZoomLevels.Steps`), saved through
+ `PersistConfiguration` (a zoom is pressed in runs; the funnel re-periodises timers). The window's Ctrl+=/−/0, Ctrl+wheel
+ (`TerminalView.ZoomForWheel`, exactly Control) and its right-click menu *ask* (`TerminalView.ZoomRequested` →
+ `WindowTextSize.RequestZoom` → `StepZoom`); the client saves and says the sizes back (`SyncTextSizes`, on change only, from
+ `SaveConfiguration` and `PostBufferPaint`). ⌃P has zoom in/out/reset and *Text size & zoom…* (`CommandContext.TextZoom`,
+ `TextSizePage`).
+ - **The cells keep a real font size** (`TerminalView.SetCellZoom`: output points ÷ launch points × zoom; `--font-size` given
+ for a run wins over the saved points), so glyphs stay crisp and NAWS follows. **Every native view is scaled, not
+ re-sized**: `NativeScale.Cover` lays a view out at its area ÷ scale with a `ScaleTransform` (no layout rounding, or it
+ draws half a pixel past its area) — the header, sidebar, footer, strips, bands, toasts and buttons at *interface* × zoom,
+ panels and pop-out buttons at *panels* × zoom; dialogs (`ModalOverlay`, `PaletteOverlay`, the settings window, floating
+ panel windows) through `NativeScale.Wrap`, a `LayoutTransformControl`, read when each opens (presenters' `Scales`);
+ `NativeMenu.Scale` sizes menus. Chrome insets are `NativeLayout.Scaled` (a multiple of 4; exact at 1), so at 100% nothing
+ moves. Hairlines stay one device pixel (`NativeScale.DeviceScale` multiplies every ancestor's transform). The composer's
+ text is the cells' size × *composer* (`ComposerView.TextScale`, over the panel scale when docked).
+ - **A terminal lists none of it**: its text is its emulator's, and nothing a program writes resizes it. `/help` ▸ Text size
+ says to use the terminal's zoom. `--emulated` keeps the zoom and has no page (no native settings; output size by
+ `--font-size`), and its native views are none, so `SizesAreas` is false.
+ - **A panel laid out at the panel scale is told the cell height ÷ that scale** (`DesktopLayer` → gauges, who-here, quick
+ bar), and the vitals card's rows are the gauges' height at it (`VitalsGauges.CardRows(…, scale)`, `WindowPointer.PanelScale`,
+ read from the sizes last said); `SyncTextSizes` re-checks every card (`ResizeVitalsCard`) after saying new sizes. A
+ larger panel wraps the map's look buttons and drops the docked composer's Preview (F1 keeps it) rather than clip them.
+ - Not done: a dialog or floating window open during a zoom change keeps its scale until reopened.
- **The window bundles its faces** (`BundledFonts`, Avalonia; `Fonts/README.md`). Inter 4.1 (four static weights, 1.67 MB)
and JetBrains Mono are registered as `fonts:SharpMUTerm`, Inter is Avalonia's default family and the bare names map onto
the bundle, so every view resolves them from the file on every OS; installed faces are only the fallback for glyphs the
diff --git a/src/SharpMUTerm.Avalonia/AvaloniaConsoleDriver.cs b/src/SharpMUTerm.Avalonia/AvaloniaConsoleDriver.cs
index 6969b6eb..37a1b0ec 100644
--- a/src/SharpMUTerm.Avalonia/AvaloniaConsoleDriver.cs
+++ b/src/SharpMUTerm.Avalonia/AvaloniaConsoleDriver.cs
@@ -1,3 +1,4 @@
+using SharpMUTerm.Core.Configuration;
using Color = SharpConsoleUI.Color;
using Point = System.Drawing.Point;
using SharpConsoleUI;
@@ -96,6 +97,9 @@ public AvaloniaConsoleDriver(int columns, int rows)
/// F7's background transparency, which the main window draws ().
public WindowTranslucency Translucency { get; } = new();
+ /// The Text size page's sizes and the zoom, which the main window draws at ().
+ public WindowTextSize TextSize { get; } = new();
+
/// The window's alerts while it is behind another ().
public WindowAttention Attention { get; } = new();
@@ -233,6 +237,63 @@ public static byte PlaneAlpha(int percent, global::Avalonia.Controls.WindowTrans
: (byte)Math.Round(255 * (100 - Math.Min(percent, 100)) / 100.0);
}
+///
+/// The window's half of : the client says the sizes on its own thread, and the main window,
+/// which subscribes, redraws at them on Avalonia's; the window's zoom keys and Ctrl+wheel ask the client to step the zoom
+/// through . With no client listening (a test of the view alone) a request steps
+/// here, so the keys still zoom.
+///
+internal sealed class WindowTextSize : IWindowTextSize
+{
+ private DisplaySettings _current = new();
+
+ /// The sizes in force: the configuration's until the client says otherwise.
+ public DisplaySettings Current => Volatile.Read(ref _current);
+
+ /// Whether the window draws native views whose sizes the page's areas set; false in --emulated.
+ public bool SizesAreas { get; set; } = true;
+
+ /// Raised with each new set of sizes, on whichever thread said them.
+ public event Action? Changed;
+
+ ///
+ public event Action? ZoomRequested;
+
+ /// Starts from the configuration's sizes, before the client exists, so the first frame is drawn at them.
+ public void Start(DisplaySettings sizes) => Volatile.Write(ref _current, sizes.Clamped());
+
+ public void Show(DisplaySettings sizes)
+ {
+ Volatile.Write(ref _current, sizes.Clamped());
+ Changed?.Invoke(Current);
+ }
+
+ ///
+ /// The window's own zoom keys or wheel: in for a positive , out for a negative one, 100% for
+ /// zero. The client steps, saves and says the result; with none, the step is taken here.
+ ///
+ public void RequestZoom(int step)
+ {
+ if (ZoomRequested is { } client)
+ {
+ client(step);
+ return;
+ }
+
+ var current = Current;
+ var next = new DisplaySettings
+ {
+ Zoom = ZoomLevels.Stepped(current.Zoom, step),
+ OutputPoints = current.OutputPoints,
+ ComposerText = current.ComposerText,
+ InterfaceText = current.InterfaceText,
+ PanelText = current.PanelText,
+ DialogText = current.DialogText,
+ };
+ Show(next);
+ }
+}
+
///
/// The window's half of : the client hands it Character@World — SharpMUTerm on its own
/// thread, and the main window, which subscribes, puts it on the title bar and the taskbar entry on Avalonia's.
diff --git a/src/SharpMUTerm.Avalonia/ComposerPresenter.cs b/src/SharpMUTerm.Avalonia/ComposerPresenter.cs
index a1f7a80c..bc0e9d1e 100644
--- a/src/SharpMUTerm.Avalonia/ComposerPresenter.cs
+++ b/src/SharpMUTerm.Avalonia/ComposerPresenter.cs
@@ -38,6 +38,9 @@ public void Attach(Panel root, Control below, TerminalView view, Action refocus)
_attached = true;
}
+ /// The window's text sizes, read when F1 opens (MainWindow sets it; designed sizes until then).
+ public Func Scales { get; set; } = () => NativeScales.One;
+
public void Open(ComposerSession session, PanelInk ink) => Dispatcher.UIThread.Post(() => OpenNow(session, ink));
public void Close() => Dispatcher.UIThread.Post(CloseNow);
@@ -56,6 +59,7 @@ public void Attach(Panel root, Control below, TerminalView view, Action refocus)
}
var overlay = new ComposerOverlay(view, session, ink);
+ overlay.Composer.TextScale = Scales().Composer;
_overlay = overlay;
var at = _below is not null ? root.Children.IndexOf(_below) : -1;
root.Children.Insert(at < 0 ? root.Children.Count : at, overlay);
diff --git a/src/SharpMUTerm.Avalonia/ComposerView.cs b/src/SharpMUTerm.Avalonia/ComposerView.cs
index 1a328880..9aa4a83b 100644
--- a/src/SharpMUTerm.Avalonia/ComposerView.cs
+++ b/src/SharpMUTerm.Avalonia/ComposerView.cs
@@ -523,6 +523,25 @@ private void Paint()
_marks.InvalidateVisual();
}
+ ///
+ /// How much larger than the cells' text the game text is drawn: the Text size page's composer size, over whatever
+ /// zoom the view is already drawn at (a docked composer sits in a panel drawn at the panels' scale).
+ ///
+ internal double TextScale
+ {
+ get => _textScale;
+ set
+ {
+ if (Math.Abs(value - _textScale) > 0.0005)
+ {
+ _textScale = value;
+ ApplyFont();
+ }
+ }
+ }
+
+ private double _textScale = 1;
+
/// Refreshes an open composer after zoom without changing its selected face.
private void RefreshFontMetrics()
{
@@ -545,8 +564,11 @@ private void ApplyFont()
_textLabel.FontSize = PanelText.Size - 1;
// Game text in the cells' own face and size (the mockups' 13 px JetBrains Mono beside the output's), not a share
// of the row: a row carries the line height's leading, which made the text a size larger than the output's.
- var text = _view.CellTextSize > 0 ? _view.CellTextSize : cell * 0.8;
- _command.FontSize = Math.Clamp(text, 11, 18);
+ // The upper bounds keep a large --font-size from swamping the panel; a size the player chose on the Text size
+ // page or by zooming lifts them with it.
+ var text = (_view.CellTextSize > 0 ? _view.CellTextSize : cell * 0.8) * _textScale;
+ var most = 18 * Math.Max(1, _textScale * ZoomOf(_view));
+ _command.FontSize = Math.Clamp(text, 11, most);
_command.FontFamily = NativeTokens.CellFace(_view.Renderer.Fonts.FamilyName);
foreach (var button in new[] { _previewButton, _sendButton })
{
@@ -558,7 +580,7 @@ private void ApplyFont()
_previewHeading.FontSize = ui - 1.5;
_previewText.FontSize = Math.Clamp(cell * 0.72, 11, 16);
_previewText.FontFamily = NativeTokens.CellFace(_view.Renderer.Fonts.FamilyName);
- _editor.FontSize = Proportional ? Math.Clamp(text * 1.08, 12, 19) : Math.Clamp(text, 11, 18);
+ _editor.FontSize = Proportional ? Math.Clamp(text * 1.08, 12, most * 19 / 18) : Math.Clamp(text, 11, most);
// Rows as far apart as the output's (the mockups' 20 px line), so a post reads at the pitch it will be read at.
_editor.LineHeight = Proportional ? double.NaN : cell;
@@ -571,6 +593,8 @@ private void ApplyFont()
}
}
+ private static double ZoomOf(TerminalView view) => view.ZoomPercent / 100.0;
+
/// Where line (from 1) of starts; the end when it has fewer.
internal static int LineStart(string text, int line)
{
@@ -675,6 +699,13 @@ private void Label(ComposerModel model)
// A docked composer too short for its footer keeps the editor and drops the footer: the heading, the footer
// and an editor of two lines need about 136 px, and below that the footer would squeeze the well shut.
_footer.IsVisible = Bounds.Height is 0 or >= 136;
+
+ // A panel too narrow for both buttons (a narrow column, or its text drawn larger) keeps Send and drops Preview,
+ // which F1 still offers (Alt+P), rather than squeezing it to a sliver.
+ _sendButton.Measure(Size.Infinity);
+ _previewButton.Measure(Size.Infinity);
+ var both = (2 * PanelText.Pad) + _sendButton.DesiredSize.Width + _previewButton.DesiredSize.Width + NativeTokens.Space8;
+ _previewButton.IsVisible = Bounds.Width is 0 || Bounds.Width >= both;
ShowPlaceholder(!model.Armed);
return;
}
diff --git a/src/SharpMUTerm.Avalonia/DesktopLayer.cs b/src/SharpMUTerm.Avalonia/DesktopLayer.cs
index d9af6f4b..23a9a1c1 100644
--- a/src/SharpMUTerm.Avalonia/DesktopLayer.cs
+++ b/src/SharpMUTerm.Avalonia/DesktopLayer.cs
@@ -104,6 +104,28 @@ public DesktopLayer(TerminalView view, HostChrome chrome)
Sync();
}
+ ///
+ /// How much larger than designed each native area is drawn (the Text size page and the zoom). Setting it moves the
+ /// cells out of the way of a header and sidebar of the new size and places every view again.
+ ///
+ internal NativeScales Scales
+ {
+ get => _scales;
+ set
+ {
+ if (_scales == value)
+ {
+ return;
+ }
+
+ _scales = value;
+ SyncInsets(_pointer.Chrome?.Sidebar.Collapsed ?? false);
+ Sync();
+ }
+ }
+
+ private NativeScales _scales = NativeScales.One;
+
/// Which chrome this layer draws. Internal for tests.
internal HostChrome Chrome => _chrome;
@@ -119,7 +141,7 @@ public DesktopLayer(TerminalView view, HostChrome chrome)
///
private void SyncInsets(bool collapsed)
{
- var insets = NativeLayout.Insets(_chrome, collapsed);
+ var insets = NativeLayout.Insets(_chrome, collapsed, _scales.Interface);
if (_view.Margin != insets)
{
_view.Margin = insets;
@@ -237,7 +259,7 @@ private void SyncCovered()
.Concat(_popOuts.Values.Where(b => b.Reserved));
_view.Covered = views
.Where(v => v.IsVisible && v.Width > 0 && v.Height > 0)
- .Select(v => new Rect(GetLeft(v) - origin.X, GetTop(v) - origin.Y, v.Width, v.Height))
+ .Select(v => new Rect(new Point(GetLeft(v) - origin.X, GetTop(v) - origin.Y), NativeScale.Extent(v)))
.ToList();
}
@@ -260,7 +282,8 @@ private void SyncSeams(Size cell)
var views = _strips.Values.Cast().Concat(_bands.Values).Concat(_gauges.Values).Concat(_maps.Values)
.Concat(_whoHere.Values).Concat(_quickBars.Values).Concat(_collapsedBars.Values).Concat(_composers.Values)
.Where(v => v.IsVisible && v.Width > 0 && v.Height > 0 && v is IEdgeRules)
- .Select(v => (new Rect(GetLeft(v), GetTop(v), v.Width, v.Height), ((IEdgeRules)v).EdgeRules(new Size(v.Width, v.Height))));
+ .Select(v => (new Rect(new Point(GetLeft(v), GetTop(v)), NativeScale.Extent(v)),
+ ((IEdgeRules)v).EdgeRules(new Size(v.Width, v.Height)).Select(r => r with { Rect = NativeScale.Out(r.Rect, NativeScale.Of(v)) })));
_seams.Show(SeamRules.Between(views, hairlines, cell.Width));
}
@@ -327,8 +350,11 @@ private void Sync(Dictionary controls, IEnumerable<(string Key, Pa
SetLeft(control, origin.X + (row.X * cell.Width));
SetTop(control, origin.Y + (row.Y * cell.Height));
- control.Width = (row.Width * cell.Width) + RightOfGrid(row.X, row.Width, _view.Driver.Frame.Width, cell);
- control.Height = row.Height * cell.Height;
+ NativeScale.Cover(
+ control,
+ (row.Width * cell.Width) + RightOfGrid(row.X, row.Width, _view.Driver.Frame.Width, cell),
+ row.Height * cell.Height,
+ _scales.Interface);
}
}
@@ -348,10 +374,10 @@ private void SyncToasts(ScreenFrame frame, Size cell)
var origin = CellOrigin;
SetLeft(_toasts, origin.X);
SetTop(_toasts, origin.Y);
- _toasts.Width = Math.Max(0, frame.Width * cell.Width);
- _toasts.Height = Math.Max(0, frame.Height * cell.Height);
+ var toastScale = _scales.Interface;
+ NativeScale.Cover(_toasts, Math.Max(0, frame.Width * cell.Width), Math.Max(0, frame.Height * cell.Height), toastScale);
var above = _noticeAboveRow > 0 && _noticeAboveRow < frame.Height ? _noticeAboveRow : Math.Max(0, frame.Height - 2);
- _toasts.Bottom = above * cell.Height;
+ _toasts.Bottom = above * cell.Height / toastScale;
// The native maps' legends, in the toasts' coordinates: a toast is stacked clear of one rather than over it.
var avoid = new List();
@@ -359,7 +385,7 @@ private void SyncToasts(ScreenFrame frame, Size cell)
{
if (map.LegendRect(new Size(map.Width, map.Height)) is { } legend)
{
- avoid.Add(legend.Translate(new Vector(GetLeft(map) - origin.X, GetTop(map) - origin.Y)));
+ avoid.Add(NativeScale.Out(legend, NativeScale.Of(map)).Translate(new Vector(GetLeft(map) - origin.X, GetTop(map) - origin.Y)));
}
}
@@ -369,11 +395,12 @@ private void SyncToasts(ScreenFrame frame, Size cell)
{
if (view.IsVisible && view.Width > 0 && view.Height > 0)
{
- avoid.Add(new Rect(GetLeft(view) - origin.X, GetTop(view) - origin.Y, view.Width, view.Height));
+ avoid.Add(new Rect(new Point(GetLeft(view) - origin.X, GetTop(view) - origin.Y), NativeScale.Extent(view)));
}
}
- _toasts.Avoid = avoid;
+ // In the toasts' own units: they are drawn at the interface's scale.
+ _toasts.Avoid = avoid.Select(a => new Rect(a.X / toastScale, a.Y / toastScale, a.Width / toastScale, a.Height / toastScale)).ToList();
// Hidden, not stacked under it, while something modal is in front: a settings window is a window of its own and a
// toast under it showed as a clipped fragment past its edge, and ⌃P and the modals have a scrim the toast would sit
@@ -399,20 +426,22 @@ private void SyncChrome()
SyncInsets(chrome?.Sidebar.Collapsed ?? false);
var width = Bounds.Width;
var height = Bounds.Height;
- var top = _chrome.Header ? NativeLayout.HeaderHeight : 0;
- var bottom = _chrome.Status ? NativeLayout.StatusHeight : 0;
+ var scale = _scales.Interface;
+ var top = _chrome.Header ? NativeLayout.Scaled(NativeLayout.HeaderHeight, scale) : 0;
+ var bottom = _chrome.Status ? NativeLayout.Scaled(NativeLayout.StatusHeight, scale) : 0;
- // The header buttons' slots, plus the resize band, stay clear for the buttons drawn above the header.
+ // The header buttons' slots, plus the resize band, stay clear for the buttons drawn above the header (in the
+ // header's own units, which the interface's scale draws larger).
_header.ReserveRight = _chrome.Header
- ? (_pointer.Actions.Count * (NativeLayout.ButtonSize + NativeLayout.ButtonGap)) + WindowChrome.EdgeBand + 4
+ ? (_pointer.Actions.Count * (NativeLayout.ButtonSize + NativeLayout.ButtonGap)) + ((WindowChrome.EdgeBand + 4) / scale)
: 0;
// Before the client has said anything the bands are drawn empty, on the view's own colours, so a window never
// opens with a hole where its header will be.
var ink = chrome is null ? Colour(_view.Renderer.DefaultForeground) : NativeChromeView.Parse(chrome.Palette.Ink);
- Place(_header, _chrome.Header, new Rect(0, 0, width, NativeLayout.HeaderHeight), chrome,
+ Place(_header, _chrome.Header, new Rect(0, 0, width, top), scale, chrome,
Translucent(chrome is null ? Colour(_view.Renderer.DefaultBackground) : NativeChromeView.Parse(chrome.Palette.HeaderPlane)), ink);
- Place(_sidebar, _chrome.Rail, new Rect(0, top, _view.Margin.Left, Math.Max(0, height - top - bottom)), chrome,
+ Place(_sidebar, _chrome.Rail, new Rect(0, top, _view.Margin.Left, Math.Max(0, height - top - bottom)), scale, chrome,
Translucent(chrome is null ? Colour(_view.Renderer.DefaultBackground) : NativeChromeView.Parse(chrome.Palette.RailPlane)), ink);
// The footer across the whole width under the cells and the sidebar, and the field's strip: the header's plane, as
@@ -428,8 +457,7 @@ private void SyncChrome()
{
SetLeft(_footer, 0);
SetTop(_footer, height - bottom);
- _footer.Width = width;
- _footer.Height = bottom;
+ NativeScale.Cover(_footer, width, bottom, scale);
_footer.Show(_pointer.Status);
_footer.IsVisible = true;
}
@@ -439,7 +467,7 @@ private void SyncChrome()
}
}
- private static void Place(NativeChromeView view, bool drawn, Rect area, ChromeOverlay? chrome, Color plane, Color ink)
+ private static void Place(NativeChromeView view, bool drawn, Rect area, double scale, ChromeOverlay? chrome, Color plane, Color ink)
{
if (!drawn || area.Width <= 0 || area.Height <= 0)
{
@@ -449,8 +477,7 @@ private static void Place(NativeChromeView view, bool drawn, Rect area, ChromeOv
SetLeft(view, area.X);
SetTop(view, area.Y);
- view.Width = area.Width;
- view.Height = area.Height;
+ NativeScale.Cover(view, area.Width, area.Height, scale);
view.Show(chrome, plane, ink);
view.IsVisible = true;
}
@@ -481,11 +508,11 @@ private void SyncButtons(ScreenFrame frame, Size cell)
if (_chrome.Header)
{
// In the window's own header: right to left from its end, clear of the resize band, on its plane.
- var side = NativeLayout.ButtonSize;
- button.Width = side;
- button.Height = side;
- SetLeft(button, Bounds.Width - WindowChrome.EdgeBand - 4 - ((i + 1) * (side + NativeLayout.ButtonGap)) + NativeLayout.ButtonGap);
- SetTop(button, Math.Round((NativeLayout.HeaderHeight - side) / 2));
+ var zoom = _scales.Interface;
+ var side = NativeLayout.ButtonSize * zoom;
+ NativeScale.Cover(button, side, side, zoom);
+ SetLeft(button, Bounds.Width - WindowChrome.EdgeBand - 4 - ((i + 1) * (side + (NativeLayout.ButtonGap * zoom))) + (NativeLayout.ButtonGap * zoom));
+ SetTop(button, Math.Round((NativeLayout.Scaled(NativeLayout.HeaderHeight, zoom) - side) / 2));
var said = _pointer.Chrome;
button.Ink = said is null ? Colour(_view.Renderer.DefaultForeground) : NativeChromeView.Parse(said.Palette.Ink);
button.Plane = said is null ? Colour(_view.Renderer.DefaultBackground) : NativeChromeView.Parse(said.Palette.HeaderPlane);
@@ -531,7 +558,7 @@ private void SyncPanels(ScreenFrame frame, Size cell)
(gauges, panel, plane) =>
{
gauges.Seams = (panel.RuleAbove, panel.RuleBelow);
- gauges.Show(panel.Vitals!, panel.Ink, plane, text, cell.Height);
+ gauges.Show(panel.Vitals!, panel.Ink, plane, text, cell.Height / _scales.Panels);
});
Place(
_maps,
@@ -546,7 +573,7 @@ private void SyncPanels(ScreenFrame frame, Size cell)
() => new WhoHereList(_view),
frame,
cell,
- (list, panel, plane) => list.Show(panel.WhoHere!, panel.Ink, plane, text, cell.Height, panel.Pick));
+ (list, panel, plane) => list.Show(panel.WhoHere!, panel.Ink, plane, text, cell.Height / _scales.Panels, panel.Pick));
Place(
_quickBars,
_pointer.Panels.Where(p => p.PanelId == PanelIds.QuickBar && p.QuickBar is not null),
@@ -556,7 +583,7 @@ private void SyncPanels(ScreenFrame frame, Size cell)
(bar, panel, plane) =>
{
bar.Seams = (panel.RuleAbove, panel.RuleBelow);
- bar.Show(panel.QuickBar!, panel.Ink, plane, text, cell.Height, panel.Pick, panel.Dispatch);
+ bar.Show(panel.QuickBar!, panel.Ink, plane, text, cell.Height / _scales.Panels, panel.Pick, panel.Dispatch);
});
// A collapsed panel's bar, at the foot of its column (HostChrome.Strips).
@@ -575,7 +602,13 @@ private void SyncPanels(ScreenFrame frame, Size cell)
() => new ComposerView(_view, fullScreen: false),
frame,
cell,
- (composer, panel, plane) => composer.Show(panel.Composer!, panel.ComposerSession!, panel.Ink, plane, text));
+ (composer, panel, plane) =>
+ {
+ // The editor's text is the cells' size at the composer's own scale, inside a panel already drawn at the
+ // panels' scale.
+ composer.TextScale = _scales.Composer / _scales.Panels;
+ composer.Show(panel.Composer!, panel.ComposerSession!, panel.Ink, plane, text);
+ });
SyncPopOuts(frame, cell);
// A pane a native view covers draws no overlay scrollbar of its own: its cells are only the view's backing (the
@@ -702,8 +735,7 @@ private void SyncPopOuts(ScreenFrame frame, Size cell)
{
// The strip's reserved end: the whole row's height, on the pane's own plane.
var reserve = PopOutReserve(cell);
- button.Width = reserve;
- button.Height = rect.Height * cell.Height;
+ NativeScale.Cover(button, reserve, rect.Height * cell.Height, _scales.Panels);
SetLeft(button, right - reserve);
SetTop(button, origin.Y + (rect.Y * cell.Height));
var inside = rect.X >= 0 && rect.Y >= 0 && rect.X < frame.Width && rect.Y < frame.Height;
@@ -711,8 +743,7 @@ private void SyncPopOuts(ScreenFrame frame, Size cell)
}
else
{
- button.Width = size;
- button.Height = size;
+ NativeScale.Cover(button, size, size, _scales.Panels);
SetLeft(button, right - size - 3);
SetTop(button, origin.Y + (rect.Y * cell.Height) + 2);
button.Plane = null;
@@ -761,9 +792,12 @@ private void Place(
SetTop(control, origin.Y + (rect.Y * cell.Height));
// A one-row strip that can pop out leaves its right end to the button (ReservesPopOut), so the button never
// lies over its text (`88 / 100`, `F4 edits keys`); the button paints the pane's plane over the cells there.
- control.Width = Math.Max(0, (rect.Width * cell.Width) + RightOfGrid(rect.X, rect.Width, frame.Width, cell)
- - (ReservesPopOut(panel) ? PopOutReserve(cell) : 0));
- control.Height = (rect.Height * cell.Height) + BelowGrid(frame, rect, cell);
+ NativeScale.Cover(
+ control,
+ Math.Max(0, (rect.Width * cell.Width) + RightOfGrid(rect.X, rect.Width, frame.Width, cell)
+ - (ReservesPopOut(panel) ? PopOutReserve(cell) : 0)),
+ (rect.Height * cell.Height) + BelowGrid(frame, rect, cell),
+ _scales.Panels);
// The pane's own plane, read off the frame, so the native view sits on whatever the pane wears — its
// character's tint, the focus lift — without the client having to say it twice.
@@ -963,9 +997,17 @@ internal static class NativeLayout
public const double ButtonGap = NativeTokens.Space8;
/// The cells' margin in the window: the space the native header and sidebar take.
- public static Thickness Insets(HostChrome chrome, bool collapsed) => new(
- chrome.Rail ? (collapsed ? SidebarSlim : SidebarWidth) + SidebarGutter : 0,
- chrome.Header ? HeaderHeight : 0,
+ public static Thickness Insets(HostChrome chrome, bool collapsed, double scale = 1) => new(
+ chrome.Rail ? Scaled((collapsed ? SidebarSlim : SidebarWidth) + SidebarGutter, scale) : 0,
+ chrome.Header ? Scaled(HeaderHeight, scale) : 0,
0,
- chrome.Status ? StatusHeight : 0);
+ chrome.Status ? Scaled(StatusHeight, scale) : 0);
+
+ ///
+ /// A chrome size at the interface's , rounded to a multiple of 4
+ /// as every chrome size is, so the cells below and beside it still start on a whole device pixel at 125% and 150%.
+ /// Exactly the designed size at 1.
+ ///
+ public static double Scaled(double designed, double scale) =>
+ Math.Abs(scale - 1) < 0.0005 ? designed : Math.Max(4, Math.Round(designed * scale / 4) * 4);
}
diff --git a/src/SharpMUTerm.Avalonia/FloatingPanels.cs b/src/SharpMUTerm.Avalonia/FloatingPanels.cs
index edcce23a..29e31501 100644
--- a/src/SharpMUTerm.Avalonia/FloatingPanels.cs
+++ b/src/SharpMUTerm.Avalonia/FloatingPanels.cs
@@ -27,6 +27,9 @@ internal sealed class FloatingPanelPresenter : IFloatingPanels
private TerminalView? _view;
private bool _attached;
+ /// The window's text sizes, read when a panel's window opens (MainWindow sets it; designed sizes until then).
+ public Func Scales { get; set; } = () => NativeScales.One;
+
/// What the client last said is floating; any thread.
public IReadOnlyList Panels => Volatile.Read(ref _panels);
@@ -101,7 +104,7 @@ internal void Sync()
continue;
}
- var window = new FloatingPanelWindow(panel, _view);
+ var window = new FloatingPanelWindow(panel, _view, Scales().Panels);
window.Update(panel, _view.CellExtent.Height);
var bounds = panel.Model.Bounds.IsPlaced ? panel.Model.Bounds : BesideOwner(panel.Model.PanelId);
window.Place(Clamp(bounds), Scaling());
@@ -192,7 +195,8 @@ internal sealed class FloatingPanelWindow : Window
private bool _closingForClient;
private bool _placing;
- public FloatingPanelWindow(FloatingPanel panel, TerminalView view)
+ /// How much larger than designed the panel is drawn: the window's panel text size and zoom.
+ public FloatingPanelWindow(FloatingPanel panel, TerminalView view, double scale = 1)
{
_view = view;
view.AttachZoomKeys(this);
@@ -232,7 +236,7 @@ public FloatingPanelWindow(FloatingPanel panel, TerminalView view)
DockPanel.SetDock(_bar, Dock.Top);
root.Children.Add(_bar);
root.Children.Add(_content);
- Content = root;
+ Content = NativeScale.Wrap(root, scale);
_report = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_report.Tick += (_, _) =>
diff --git a/src/SharpMUTerm.Avalonia/MainWindow.cs b/src/SharpMUTerm.Avalonia/MainWindow.cs
index bfa75baa..b6add253 100644
--- a/src/SharpMUTerm.Avalonia/MainWindow.cs
+++ b/src/SharpMUTerm.Avalonia/MainWindow.cs
@@ -53,6 +53,13 @@ public MainWindow(WindowOptions options, IClassicDesktopStyleApplicationLifetime
_view.AttachZoomKeys(this);
+ // Zoom and text sizes are the client's settings (F7 ▸ Text size & zoom): the window starts from the saved ones,
+ // asks the client for each zoom step, and redraws whenever the client says the sizes changed.
+ _driver.TextSize.Start(_config.Display);
+ _view.SetCellZoom(CellZoom(_driver.TextSize.Current));
+ _view.ZoomRequested += _driver.TextSize.RequestZoom;
+ _view.ZoomShown = () => _driver.TextSize.Current.Zoom;
+
Title = SharpMUTerm.Client.Chrome.WindowTitle.Product;
AppIcon.Apply(this);
@@ -106,7 +113,20 @@ public MainWindow(WindowOptions options, IClassicDesktopStyleApplicationLifetime
// view out of their way, so no cell lies under either), the Layouts button, and native panels over their panes.
var layer = new DesktopLayer(_view, options.Chrome);
_layer = layer;
+ layer.Scales = Scales(_driver.TextSize.Current);
+ _view.InterfaceScale = layer.Scales.Interface;
+ NativeMenu.Scale = layer.Scales.Dialogs;
root.Children.Add(layer);
+ _driver.TextSize.Changed += sizes => Dispatcher.UIThread.Post(() => ApplyTextSizes(sizes));
+
+ // Dialogs, F1, the settings window and floating panels read the sizes when they open.
+ Func scales = () => Scales(_driver.TextSize.Current);
+ _driver.Modals.Scales = scales;
+ _driver.Palette.Scales = scales;
+ _driver.Composer.Scales = scales;
+ _driver.Settings.Scales = scales;
+ _driver.Floating.Scales = scales;
+ _driver.Pointer.PanelScale = () => scales().Panels;
// The edge Avalonia would have drawn round a BorderOnly window on X11, drawn here (DecorationsFor): a hairline,
// only while the window is an ordinary window.
@@ -159,7 +179,7 @@ public MainWindow(WindowOptions options, IClassicDesktopStyleApplicationLifetime
// --size names the grid, so the window is that grid plus the native chrome around it — measured at the scale
// of the screen it opens on, and shrunk by whole cells to that screen's working area (WindowFit): at 150% on a
// 1080p screen the default grid is taller than the screen, and its command line opened below the edge.
- var insets = NativeLayout.Insets(options.Chrome, collapsed: false);
+ var insets = NativeLayout.Insets(options.Chrome, collapsed: false, layer.Scales.Interface);
var screen = Screens is { } screens ? screens.Primary ?? screens.All.FirstOrDefault() : null;
if (screen is not null)
{
@@ -233,6 +253,25 @@ private void SyncTranslucency()
_layer.PlaneAlpha = alpha;
}
+ // The cells' size: the output's points (a --font-size given for this run wins over the saved one), at the zoom.
+ private double CellZoom(DisplaySettings sizes)
+ {
+ var points = _options.FontSizeGiven ? _options.FontPoints : sizes.OutputPoints;
+ return points / _options.FontPoints * sizes.Zoom / 100.0;
+ }
+
+ // The native views' scales; in Emulated mode there are none to scale, so only the cells follow the zoom.
+ private NativeScales Scales(DisplaySettings sizes) =>
+ _driver.TextSize.SizesAreas ? NativeScales.From(sizes) : NativeScales.One;
+
+ private void ApplyTextSizes(DisplaySettings sizes)
+ {
+ _view.SetCellZoom(CellZoom(sizes));
+ _layer.Scales = Scales(sizes);
+ _view.InterfaceScale = _layer.Scales.Interface;
+ NativeMenu.Scale = _layer.Scales.Dialogs;
+ }
+
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
diff --git a/src/SharpMUTerm.Avalonia/MapCanvas.cs b/src/SharpMUTerm.Avalonia/MapCanvas.cs
index d6f79fe6..a3d5607f 100644
--- a/src/SharpMUTerm.Avalonia/MapCanvas.cs
+++ b/src/SharpMUTerm.Avalonia/MapCanvas.cs
@@ -515,7 +515,8 @@ internal sealed record LookButton(string Id, Rect Rect, string Label, bool Enabl
///
/// The look buttons the view offers, left to right under the corner label: ▲ ▼ while the area has more than one level,
- /// Areas ▾ while the map has more than one area, and Back to where I am while the view is elsewhere. Internal for tests.
+ /// Areas ▾ while the map has more than one area, and Back to where I am while the view is elsewhere, wrapping onto a
+ /// second row where the panel is too narrow for them. Internal for tests.
///
internal IReadOnlyList LookButtons()
{
@@ -530,6 +531,14 @@ internal IReadOnlyList LookButtons()
void Add(string id, string label, bool enabled)
{
var width = PanelText.Measure(label, ButtonSize, bold: false) + (2 * NativeTokens.Space10);
+
+ // A button the row has no room for starts the next (a narrow column, or the panels drawn larger).
+ if (Bounds.Width > 0 && x > PanelText.Pad && x + width > Bounds.Width - PanelText.Pad)
+ {
+ x = PanelText.Pad;
+ y += ButtonHeight + NativeTokens.Space6;
+ }
+
buttons.Add(new LookButton(id, new Rect(x, y, width, ButtonHeight), label, enabled));
x += width + NativeTokens.Space6;
}
@@ -563,8 +572,8 @@ private double UnderHeading() =>
/// What is drawn above the rooms: the heading, a walk line, and the look buttons while there are any.
private double TopReserve()
{
- var top = UnderHeading();
- return LookButtons().Count > 0 ? top + ButtonHeight + NativeTokens.Space8 : top;
+ var buttons = LookButtons();
+ return buttons.Count > 0 ? buttons.Max(b => b.Rect.Bottom) + NativeTokens.Space8 : UnderHeading();
}
/// What is drawn below the rooms: the legend and the hint, with a gap above them.
diff --git a/src/SharpMUTerm.Avalonia/ModalOverlay.cs b/src/SharpMUTerm.Avalonia/ModalOverlay.cs
index 9aff559e..082aa454 100644
--- a/src/SharpMUTerm.Avalonia/ModalOverlay.cs
+++ b/src/SharpMUTerm.Avalonia/ModalOverlay.cs
@@ -26,12 +26,16 @@ internal sealed class ModalOverlay : Panel
private readonly Action _remove;
private readonly IModalView _view;
private readonly Border _card;
+ private readonly double _scale;
/// What to draw, and where its gestures go.
/// Takes the overlay out of the window.
/// Hands a key to the client, as the cells would receive it.
- public ModalOverlay(ModalSession session, Action remove, Action forward)
+ /// The window's text sizes: the card is drawn at the dialogs' scale, below the header's height.
+ public ModalOverlay(ModalSession session, Action remove, Action forward, NativeScales? scales = null)
{
+ var sizes = scales ?? NativeScales.One;
+ _scale = sizes.Dialogs;
Session = session;
_remove = remove;
Colours = NativeTokens.For(session.Theme, session.IsDark);
@@ -82,13 +86,13 @@ public ModalOverlay(ModalSession session, Action remove, Action
{
@@ -191,8 +195,10 @@ private void Fit()
return;
}
- _card.Width = Math.Max(260, Math.Min(_view.PreferredWidth, Bounds.Width - 32));
- _card.MaxHeight = Math.Max(180, Bounds.Height - (_view.Centred ? 32 : 88));
+ // The room the card is laid out in is the overlay's at the dialogs' scale.
+ var room = Bounds.Size / _scale;
+ _card.Width = Math.Max(260, Math.Min(_view.PreferredWidth, room.Width - 32));
+ _card.MaxHeight = Math.Max(180, room.Height - (_view.Centred ? 32 : 88));
}
}
diff --git a/src/SharpMUTerm.Avalonia/ModalPresenter.cs b/src/SharpMUTerm.Avalonia/ModalPresenter.cs
index 2fa3ee86..89a0b668 100644
--- a/src/SharpMUTerm.Avalonia/ModalPresenter.cs
+++ b/src/SharpMUTerm.Avalonia/ModalPresenter.cs
@@ -59,6 +59,9 @@ public void Attach(Window? owner, Panel root, Action refocus, ActionThe window's text sizes, read when a surface opens (MainWindow sets it; designed sizes until then).
+ public Func Scales { get; set; } = () => NativeScales.One;
+
public void Show(ModalSession session) => Dispatcher.UIThread.Post(() => ShowNow(session));
public void Close(ModalSession session) => Dispatcher.UIThread.Post(() =>
@@ -83,7 +86,7 @@ public void Close(ModalSession session) => Dispatcher.UIThread.Post(() =>
}
var previousFocus = TopLevel.GetTopLevel(root)?.FocusManager?.GetFocusedElement();
- var overlay = new ModalOverlay(session, Remove, Forward);
+ var overlay = new ModalOverlay(session, Remove, Forward, Scales());
if (session.AlwaysInFront)
{
if (_quit is { } old)
diff --git a/src/SharpMUTerm.Avalonia/NativeMenu.cs b/src/SharpMUTerm.Avalonia/NativeMenu.cs
index 58ea76e8..6843bd58 100644
--- a/src/SharpMUTerm.Avalonia/NativeMenu.cs
+++ b/src/SharpMUTerm.Avalonia/NativeMenu.cs
@@ -38,6 +38,12 @@ public static ContextMenu Build(MenuModel model, Action dispatch, Placem
placement,
target);
+ ///
+ /// How much larger than designed menus are drawn: the window's dialog text size and zoom (MainWindow sets it). A
+ /// menu is a popup window of its own, so it is sized rather than transformed.
+ ///
+ internal static double Scale { get; set; } = 1;
+
private static ContextMenu Build(
IEnumerable<(string Label, string? Hint, bool Enabled, bool Checked, Action Run)?> items, PlacementMode placement, Control target)
{
@@ -53,7 +59,7 @@ private static ContextMenu Build(
BorderThickness = new Thickness(NativeTokens.Hairline),
CornerRadius = new CornerRadius(NativeTokens.RadiusM),
Padding = new Thickness(NativeTokens.Space6),
- FontSize = NativeTokens.Body,
+ FontSize = NativeTokens.Body * Scale,
};
tokens.ApplySimple(menu.Resources);
@@ -86,7 +92,7 @@ private static ContextMenu Build(
{
Header = Header(entry.Label, entry.Hint, tokens),
IsEnabled = entry.Enabled,
- MinHeight = NativeTokens.ControlHeightSmall,
+ MinHeight = NativeTokens.ControlHeightSmall * Scale,
};
if (entry.Checked)
{
@@ -146,7 +152,7 @@ private static Control Header(string label, string? hint, NativeTokens tokens)
{
Text = NativeKeys.Words(key),
FontFamily = NativeTokens.KeyFace,
- FontSize = NativeTokens.Small,
+ FontSize = NativeTokens.Small * Scale,
Foreground = tokens.Brush(tokens.Dim),
Margin = new Thickness(NativeTokens.Space32, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
diff --git a/src/SharpMUTerm.Avalonia/NativeScale.cs b/src/SharpMUTerm.Avalonia/NativeScale.cs
new file mode 100644
index 00000000..4f6a5a60
--- /dev/null
+++ b/src/SharpMUTerm.Avalonia/NativeScale.cs
@@ -0,0 +1,115 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.VisualTree;
+using SharpMUTerm.Core.Configuration;
+
+namespace SharpMUTerm.Avalonia;
+
+///
+/// How much larger than designed each native area of the window is drawn: the Text size page's sizes with the zoom
+/// multiplied in (). is against the cells' own text size, which
+/// already carries the zoom; the rest are against the design's own sizes.
+///
+internal readonly record struct NativeScales(double Interface, double Panels, double Dialogs, double Composer)
+{
+ /// Everything as designed.
+ public static NativeScales One { get; } = new(1, 1, 1, 1);
+
+ /// The scales the page's ask for.
+ public static NativeScales From(DisplaySettings sizes)
+ {
+ var held = sizes.Clamped();
+ var zoom = held.Zoom / 100.0;
+ return new(zoom * held.InterfaceText / 100.0, zoom * held.PanelText / 100.0, zoom * held.DialogText / 100.0, held.ComposerText / 100.0);
+ }
+}
+
+///
+/// Draws a native view larger or smaller than designed, as a browser's zoom does: the view is laid out in a box
+/// 1 / scale the size of the area it covers and drawn scale times larger, so its text, padding, rules and
+/// hit areas grow together and it still covers exactly its area. Every view keeps its own layout rules; nothing in them
+/// knows the scale. At 1 nothing is set, so a window at 100% is laid out exactly as it was.
+///
+internal static class NativeScale
+{
+ /// Sizes to cover × at .
+ public static void Cover(Control control, double width, double height, double scale)
+ {
+ if (Math.Abs(scale - 1) < 0.0005)
+ {
+ if (control.RenderTransform is ScaleTransform)
+ {
+ control.RenderTransform = null;
+ }
+
+ control.UseLayoutRounding = true;
+ control.Width = width;
+ control.Height = height;
+ return;
+ }
+
+ // Not rounded: a box 1/scale the area's size is seldom whole pixels, and rounding it would draw the view up to half
+ // a pixel past its area (64 px at 1.5 is 42.67, which rounds to 43 and draws at 64.5).
+ control.UseLayoutRounding = false;
+ control.Width = width / scale;
+ control.Height = height / scale;
+ control.RenderTransformOrigin = RelativePoint.TopLeft;
+ if (control.RenderTransform is not ScaleTransform current || Math.Abs(current.ScaleX - scale) > 0.0005)
+ {
+ control.RenderTransform = new ScaleTransform(scale, scale);
+ }
+ }
+
+ /// The scale is drawn at (1 when it is drawn as laid out).
+ public static double Of(Visual control) => control.RenderTransform is ScaleTransform scale ? scale.ScaleX : 1;
+
+ /// The area covers on screen, in its parent's units.
+ public static Size Extent(Control control)
+ {
+ var scale = Of(control);
+ return new Size(control.Width * scale, control.Height * scale);
+ }
+
+ /// , in a scaled view's own units, in the units of the area it covers.
+ public static Rect Out(Rect rect, double scale) =>
+ Math.Abs(scale - 1) < 0.0005 ? rect : new Rect(rect.X * scale, rect.Y * scale, rect.Width * scale, rect.Height * scale);
+
+ ///
+ /// How many device pixels one of 's own units is: the screen's scaling times every zoom applied
+ /// to it and its ancestors, so a hairline under a zoomed view is still one device pixel.
+ ///
+ public static double DeviceScale(Visual visual)
+ {
+ var top = TopLevel.GetTopLevel(visual);
+ var scale = top?.RenderScaling ?? 1;
+ if (scale <= 0)
+ {
+ scale = 1;
+ }
+
+ for (Visual? at = visual; at is not null && at != top; at = at.GetVisualParent())
+ {
+ // A ScaleTransform of ours (Cover) or the matrix a LayoutTransformControl lays on its child (Wrap).
+ if (at.RenderTransform is { } transform)
+ {
+ scale *= Math.Abs(transform.Value.M11);
+ }
+ }
+
+ return scale;
+ }
+
+ ///
+ /// laid out and drawn at inside whatever holds the result: for a
+ /// dialog over the whole window, which centres itself in the room it is given rather than covering a fixed area.
+ ///
+ public static LayoutTransformControl Wrap(Control child, double scale) => new()
+ {
+ LayoutTransform = Math.Abs(scale - 1) < 0.0005 ? null : new ScaleTransform(scale, scale),
+ Child = child,
+ };
+
+ /// The scale a lays its child out at.
+ public static double WrapScale(LayoutTransformControl wrap) => wrap.LayoutTransform is { } transform ? Math.Abs(transform.Value.M11) : 1;
+}
diff --git a/src/SharpMUTerm.Avalonia/NativeTokens.Spacing.cs b/src/SharpMUTerm.Avalonia/NativeTokens.Spacing.cs
index 5f19c26e..9e9427b5 100644
--- a/src/SharpMUTerm.Avalonia/NativeTokens.Spacing.cs
+++ b/src/SharpMUTerm.Avalonia/NativeTokens.Spacing.cs
@@ -166,9 +166,7 @@ public static double LineCentre(Visual visual, double centre, double width)
///
public static void ApplyTextRendering(Visual window) => TextOptions.SetTextHintingMode(window, TextHintingMode.Light);
- private static double Scaling(Visual visual)
- {
- var scale = TopLevel.GetTopLevel(visual)?.RenderScaling ?? 1;
- return scale > 0 ? scale : 1;
- }
+ // The screen's scaling times any zoom the view is drawn at (NativeScale), so a hairline in a zoomed panel is still one
+ // device pixel rather than the zoom's share of one.
+ private static double Scaling(Visual visual) => NativeScale.DeviceScale(visual);
}
diff --git a/src/SharpMUTerm.Avalonia/PaletteOverlay.cs b/src/SharpMUTerm.Avalonia/PaletteOverlay.cs
index fc62a2af..0e7792fa 100644
--- a/src/SharpMUTerm.Avalonia/PaletteOverlay.cs
+++ b/src/SharpMUTerm.Avalonia/PaletteOverlay.cs
@@ -46,6 +46,8 @@ internal sealed class PaletteOverlay : Panel
private readonly List _keyTexts = [];
private PaletteView _view;
private int _selected = -1;
+ private readonly double _scale;
+ private readonly double _top;
/// What to list, and where a choice goes.
/// Takes the overlay out of the window and gives the keyboard back to the cells.
@@ -55,8 +57,12 @@ internal sealed class PaletteOverlay : Panel
///
internal const string Placeholder = NativeHeader.SearchText + " — type a command, a character, a window or a setting";
- public PaletteOverlay(PaletteSession session, Action remove)
+ /// The window's text sizes: the card is drawn at the dialogs' scale, below the header's height.
+ public PaletteOverlay(PaletteSession session, Action remove, NativeScales? scales = null)
{
+ var sizes = scales ?? NativeScales.One;
+ _scale = sizes.Dialogs;
+ _top = (NativeLayout.Scaled(NativeLayout.HeaderHeight, sizes.Interface) + CardTop - NativeLayout.HeaderHeight) / _scale;
_session = session;
_remove = remove;
_colours = NativeTokens.For(session.Theme, session.IsDark);
@@ -150,13 +156,13 @@ public PaletteOverlay(PaletteSession session, Action remove)
RequestedThemeVariant = session.IsDark ? ThemeVariant.Dark : ThemeVariant.Light,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Top,
- Margin = new Thickness(NativeTokens.Space16, CardTop, NativeTokens.Space16, NativeTokens.Space16),
+ Margin = new Thickness(NativeTokens.Space16, _top, NativeTokens.Space16, NativeTokens.Space16),
Child = _card,
};
scope.Styles.Add(new FluentTheme());
_colours.Apply(scope.Resources);
_colours.ApplyFocusRing(scope);
- Children.Add(scope);
+ Children.Add(NativeScale.Wrap(scope, _scale));
AddHandler(KeyDownEvent, OnKey, RoutingStrategies.Tunnel);
PointerPressed += OnScrimPressed;
@@ -241,9 +247,10 @@ public void Move(int delta)
private void Fit()
{
- var width = Math.Max(240, Math.Min(CardWidth, Bounds.Width - 32));
+ var room = Bounds.Size / _scale;
+ var width = Math.Max(240, Math.Min(CardWidth, room.Width - 32));
_card.Width = width;
- _card.MaxHeight = Math.Max(160, Bounds.Height - CardTop - 32);
+ _card.MaxHeight = Math.Max(160, room.Height - _top - 32);
}
private void Refresh(bool resetSelection)
diff --git a/src/SharpMUTerm.Avalonia/PalettePresenter.cs b/src/SharpMUTerm.Avalonia/PalettePresenter.cs
index a3aa6ae0..6cde8cb2 100644
--- a/src/SharpMUTerm.Avalonia/PalettePresenter.cs
+++ b/src/SharpMUTerm.Avalonia/PalettePresenter.cs
@@ -30,6 +30,9 @@ public void Attach(Panel host, Action refocus)
_refocus = refocus;
}
+ /// The window's text sizes, read when a surface opens (MainWindow sets it; designed sizes until then).
+ public Func Scales { get; set; } = () => NativeScales.One;
+
public void Show(PaletteSession session) => Dispatcher.UIThread.Post(() => ShowNow(session));
public void Close() => Dispatcher.UIThread.Post(() =>
@@ -56,7 +59,7 @@ public void Close() => Dispatcher.UIThread.Post(() =>
return null;
}
- var overlay = new PaletteOverlay(session, Remove);
+ var overlay = new PaletteOverlay(session, Remove, Scales());
_overlay = overlay;
host.Children.Add(overlay);
overlay.SearchBox.Focus();
diff --git a/src/SharpMUTerm.Avalonia/SettingsPresenter.cs b/src/SharpMUTerm.Avalonia/SettingsPresenter.cs
index 1c0fb66a..a5d29fb9 100644
--- a/src/SharpMUTerm.Avalonia/SettingsPresenter.cs
+++ b/src/SharpMUTerm.Avalonia/SettingsPresenter.cs
@@ -30,6 +30,9 @@ internal sealed class SettingsPresenter : INativeSettings
internal Size? RememberedSize { get; private set; }
/// Called on the window's thread once the settings window has opened or closed, so the main window can re-hide or show what sits under it.
+ /// The window's text sizes, read when the settings window opens (MainWindow sets it; designed sizes until then).
+ public Func Scales { get; set; } = () => NativeScales.One;
+
public Action? OpenedOrClosed { get; set; }
/// The window the settings window belongs to, set once the main window exists.
@@ -56,7 +59,7 @@ internal SettingsWindow ShowNow(SettingsForm form)
return open;
}
- var window = new SettingsWindow(form);
+ var window = new SettingsWindow(form, Scales().Dialogs);
if (RememberedSize is { } remembered)
{
window.Width = remembered.Width;
@@ -135,7 +138,7 @@ private void Place(SettingsWindow window, SettingsForm form)
{
var wasDocked = _docked;
_docked = false;
- var size = RememberedSize ?? new Size(SettingsWindow.DefaultWidth, SettingsWindow.DefaultHeight);
+ var size = RememberedSize ?? new Size(SettingsWindow.DefaultWidth * window.Scale, SettingsWindow.DefaultHeight * window.Scale);
// Inside the screen the main window is on (WindowFit): the default 1120 × 760 is 1,140 device rows at 150%.
if ((owner.Screens.ScreenFromWindow(owner) ?? owner.Screens.Primary ?? owner.Screens.All.FirstOrDefault()) is { } screen)
diff --git a/src/SharpMUTerm.Avalonia/SettingsWindow.cs b/src/SharpMUTerm.Avalonia/SettingsWindow.cs
index 161ac805..cb694241 100644
--- a/src/SharpMUTerm.Avalonia/SettingsWindow.cs
+++ b/src/SharpMUTerm.Avalonia/SettingsWindow.cs
@@ -95,17 +95,23 @@ internal sealed partial class SettingsWindow : Window
/// The size the window first opens at, before the player resizes it.
internal const double DefaultWidth = 1120, DefaultHeight = 760;
- public SettingsWindow(SettingsForm form)
+ /// The scale the page is drawn at (the dialogs' text size and the zoom).
+ internal double Scale { get; }
+
+ /// The settings drawn and edited.
+ /// How much larger than designed the page is drawn: the window's dialog text size and zoom.
+ public SettingsWindow(SettingsForm form, double scale = 1)
{
_form = form;
+ Scale = scale;
NativeTokens.ApplyTextRendering(this);
AppIcon.Apply(this);
_tokens = TokensFor(form);
Title = "Settings";
- Width = DefaultWidth;
- Height = DefaultHeight;
- MinWidth = 640;
- MinHeight = 440;
+ Width = DefaultWidth * scale;
+ Height = DefaultHeight * scale;
+ MinWidth = 640 * Math.Min(scale, 1.5);
+ MinHeight = 440 * Math.Min(scale, 1.5);
WindowStartupLocation = WindowStartupLocation.CenterOwner;
// The palette's frame, not the platform's: the window draws its own title, close button and edge, so it reads
@@ -274,7 +280,7 @@ public SettingsWindow(SettingsForm form)
// The frame is ours, so the resize border is too: a band along each edge, as the borderless main window has.
CanResize = true;
- _layers = WindowChrome.WithResizeBands(this, _frame);
+ _layers = WindowChrome.WithResizeBands(this, NativeScale.Wrap(_frame, scale));
Content = _layers;
_clock = new DispatcherTimer(TimeSpan.FromSeconds(1), DispatcherPriority.Background, (_, _) => Tick());
diff --git a/src/SharpMUTerm.Avalonia/TerminalView.cs b/src/SharpMUTerm.Avalonia/TerminalView.cs
index 8cc9cb86..9420aadd 100644
--- a/src/SharpMUTerm.Avalonia/TerminalView.cs
+++ b/src/SharpMUTerm.Avalonia/TerminalView.cs
@@ -35,31 +35,90 @@ internal sealed class TerminalView : Control
private readonly float _lineHeight;
private CellRenderer _renderer;
private double _scale = 1;
- private int _zoomPercent = 100;
- // Render operations can still hold the old fonts. Reuse each zoom level instead of leaking a new
- // font set on every repeated +/- or reset, and never dispose fonts underneath the render thread.
+ // How much larger than the launch size the cells are drawn: the Text size page's output size against the launch's,
+ // times the zoom (MainWindow.ApplyTextSizes). In thousandths, so a renderer can be found again for a size.
+ private int _cellZoom = 1000;
+ // Render operations can still hold the old fonts. Reuse each size instead of leaking a new font set on every
+ // repeated +/- or reset, and never dispose fonts underneath the render thread.
private readonly Dictionary<(double Scale, int Zoom), CellRenderer> _zoomRenderers = [];
- internal int ZoomPercent => _zoomPercent;
+ ///
+ /// How much larger than designed the window's own words over the cells are drawn (the command line's hint, tooltips):
+ /// the Text size page's interface size and the zoom.
+ ///
+ internal double InterfaceScale
+ {
+ get => _interfaceScale;
+ set
+ {
+ if (Math.Abs(value - _interfaceScale) > 0.0005)
+ {
+ _interfaceScale = value;
+ _tipText.FontSize = NativeTokens.Body * value;
+ InvalidateVisual();
+ }
+ }
+ }
+
+ private double _interfaceScale = 1;
+
+ /// The cells' size against the launch size, as a percentage.
+ internal int ZoomPercent => (int)Math.Round(_cellZoom / 10.0);
+
+ /// Draws the cells at of the launch size.
+ internal void SetZoom(int percent) => SetCellZoom(percent / 100.0);
- internal void SetZoom(int percent)
+ ///
+ /// Draws the cells times the launch size, re-measuring the grid (so every pane's NAWS is
+ /// told its new size) without resizing the window.
+ ///
+ internal void SetCellZoom(double factor)
{
- percent = Math.Clamp(percent, 50, 200);
- if (percent == _zoomPercent)
+ var thousandths = (int)Math.Round(Math.Clamp(factor, 0.25, 8) * 1000);
+ if (thousandths == _cellZoom)
{
return;
}
- _zoomPercent = percent;
+ _cellZoom = thousandths;
Rescale(_scale);
}
- internal IReadOnlyList ZoomItems =>
- [
- new("Zoom in", "Ctrl++", () => SetZoom(_zoomPercent + 10), _zoomPercent < 200),
- new("Zoom out", "Ctrl+-", () => SetZoom(_zoomPercent - 10), _zoomPercent > 50),
- new($"Reset zoom ({_zoomPercent}%)", "Ctrl+0", () => SetZoom(100), _zoomPercent != 100),
- ];
+ ///
+ /// Raised when the player zooms with the keys, Ctrl+wheel or the right-click menu: in for a positive step, out for a
+ /// negative one, back to 100% for zero. The window passes it to the client, which saves it and says the new sizes;
+ /// with nothing listening (a test of the view alone) the cells step here.
+ ///
+ internal event Action? ZoomRequested;
+
+ /// The window's zoom as the right-click menu names it; the cells' own size until the window says.
+ internal Func? ZoomShown { get; set; }
+
+ /// Asks for a zoom step (see ).
+ internal void RequestZoom(int step)
+ {
+ if (ZoomRequested is { } listener)
+ {
+ listener(step);
+ return;
+ }
+
+ SetZoom(SharpMUTerm.Core.Configuration.ZoomLevels.Stepped(ZoomPercent, step));
+ }
+
+ internal IReadOnlyList ZoomItems
+ {
+ get
+ {
+ var zoom = ZoomShown?.Invoke() ?? ZoomPercent;
+ return
+ [
+ new($"Zoom in ({SharpMUTerm.Core.Configuration.ZoomLevels.Label(SharpMUTerm.Core.Configuration.ZoomLevels.Next(zoom))})", "Ctrl+=", () => RequestZoom(1), zoom < SharpMUTerm.Core.Configuration.ZoomLevels.Maximum),
+ new($"Zoom out ({SharpMUTerm.Core.Configuration.ZoomLevels.Label(SharpMUTerm.Core.Configuration.ZoomLevels.Previous(zoom))})", "Ctrl+-", () => RequestZoom(-1), zoom > SharpMUTerm.Core.Configuration.ZoomLevels.Minimum),
+ new($"Reset zoom ({SharpMUTerm.Core.Configuration.ZoomLevels.Label(zoom)})", "Ctrl+0", () => RequestZoom(0), zoom != 100),
+ ];
+ }
+ }
private int _invalidatePending;
private char? _swallowText;
private double _wheelRemainder;
@@ -83,7 +142,7 @@ public TerminalView(AvaloniaConsoleDriver driver, SKColor foreground, SKColor ba
_family = family;
_lineHeight = lineHeight;
_renderer = new CellRenderer(new CellFonts(fontPoints, family, lineHeight), foreground, background);
- _zoomRenderers[(1, 100)] = _renderer;
+ _zoomRenderers[(1, 1000)] = _renderer;
_driver.Pointer.CellHeightMeasured = CellSize.Height;
_driver.CellMetrics.Current = new SharpMUTerm.Core.Telnet.TerminalCellSize(_renderer.Fonts.CellWidth, _renderer.Fonts.CellHeight);
_driver.Pointer.ImageCellSizeMeasured = new ImageCellSize(CellSize.Width, CellSize.Height);
@@ -351,13 +410,13 @@ internal void PrepareScale(double scale)
private void Rescale(double scale)
{
_scale = scale;
- if (!_zoomRenderers.TryGetValue((scale, _zoomPercent), out var renderer))
+ if (!_zoomRenderers.TryGetValue((scale, _cellZoom), out var renderer))
{
renderer = new CellRenderer(
- new CellFonts((float)(_fontPoints * scale * _zoomPercent / 100), _family, _lineHeight),
+ new CellFonts((float)(_fontPoints * scale * _cellZoom / 1000), _family, _lineHeight),
_renderer.DefaultForeground,
_renderer.DefaultBackground);
- _zoomRenderers[(scale, _zoomPercent)] = renderer;
+ _zoomRenderers[(scale, _cellZoom)] = renderer;
}
_renderer = renderer;
@@ -389,9 +448,26 @@ private void OnFrameReady()
}
}
- /// Keep zoom available while the native command editor owns keyboard focus.
+ ///
+ /// Keeps the zoom keys and Ctrl+wheel working wherever the pointer or the keyboard is in : over
+ /// a native panel, the header or the sidebar, and while the native composer owns the keyboard.
+ ///
internal void AttachZoomKeys(InputElement root)
{
+ double remainder = 0;
+ root.AddHandler(PointerWheelChangedEvent, (_, e) =>
+ {
+ if (!e.Handled && ZoomForWheel(ref remainder, e.KeyModifiers, e.Delta.Y) is { } steps)
+ {
+ for (var i = 0; i < Math.Abs(steps); i++)
+ {
+ RequestZoom(Math.Sign(steps));
+ }
+
+ e.Handled = true;
+ }
+ }, global::Avalonia.Interactivity.RoutingStrategies.Tunnel);
+
char? swallowed = null;
root.AddHandler(KeyDownEvent, (_, e) =>
{
@@ -415,15 +491,31 @@ internal void AttachZoomKeys(InputElement root)
root.AddHandler(LostFocusEvent, (_, _) => swallowed = null);
}
+ /// The zoom step a key asks for: 1 in, -1 out, 0 back to 100%; null for any other key.
internal static int? ZoomFor(Key key, KeyModifiers modifiers) => (key, modifiers) switch
{
- (Key.OemPlus, KeyModifiers.Control or (KeyModifiers.Control | KeyModifiers.Shift)) => 10,
- (Key.Add, KeyModifiers.Control) => 10,
- (Key.OemMinus or Key.Subtract, KeyModifiers.Control) => -10,
+ (Key.OemPlus, KeyModifiers.Control or (KeyModifiers.Control | KeyModifiers.Shift)) => 1,
+ (Key.Add, KeyModifiers.Control) => 1,
+ (Key.OemMinus or Key.Subtract, KeyModifiers.Control) => -1,
(Key.D0 or Key.NumPad0, KeyModifiers.Control) => 0,
_ => null,
};
+ ///
+ /// The zoom steps a Ctrl+wheel movement asks for, one per notch (a browser's), a touchpad's fractions carried in
+ /// ; null when Ctrl alone is not held, so the wheel scrolls as it always did.
+ ///
+ internal static int? ZoomForWheel(ref double remainder, KeyModifiers modifiers, double delta)
+ {
+ if (modifiers != KeyModifiers.Control)
+ {
+ remainder = 0;
+ return null;
+ }
+
+ return MouseMapper.WheelNotches(ref remainder, delta);
+ }
+
// ── Keys ────────────────────────────────────────────────────────────────────────────────────
protected override void OnKeyDown(KeyEventArgs e)
@@ -441,7 +533,7 @@ internal void HandleKeyDown(KeyEventArgs e)
_swallowText = null;
if (ZoomFor(e.Key, e.KeyModifiers) is { } zoom)
{
- SetZoom(zoom == 0 ? 100 : _zoomPercent + zoom);
+ RequestZoom(zoom);
_swallowText = e.Key switch
{
Key.D0 or Key.NumPad0 => '0',
@@ -1466,7 +1558,7 @@ private void DrawFieldText(DrawingContext context, ScreenFrame frame, InputBand
var hint = new FormattedText(
NativeKeys.Words(band.Hint), System.Globalization.CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
- new Typeface(NativeTokens.UiFont), NativeTokens.Small, Brush(SKColors.White));
+ new Typeface(NativeTokens.UiFont), NativeTokens.Small * InterfaceScale, Brush(SKColors.White));
// Moved with the field's edge where the field runs on past the grid's last column (FieldRect).
var right = ((end - band.Pad) * cell.Width)
+ (end >= frame.Width ? Math.Clamp(Bounds.Width - (frame.Width * cell.Width), 0, cell.Width) : 0);
diff --git a/src/SharpMUTerm.Avalonia/VitalsGauges.cs b/src/SharpMUTerm.Avalonia/VitalsGauges.cs
index 1c0f6354..977e6230 100644
--- a/src/SharpMUTerm.Avalonia/VitalsGauges.cs
+++ b/src/SharpMUTerm.Avalonia/VitalsGauges.cs
@@ -161,8 +161,12 @@ internal static double NeededHeight(int meters, bool target, double cellHeight)
/// gaps between the blocks (), so the last bar still ends above the
/// map. What the window answers for .
///
- internal static int CardRows(int meters, bool target, double cellHeight) =>
- (int)Math.Ceiling((NeededHeight(meters, target, cellHeight) / cellHeight) - 0.001);
+ ///
+ /// The gauges are drawn at the Text size page's panel : laid out on cells of
+ /// cellHeight / scale and magnified, so their height on screen is that layout's times the scale.
+ ///
+ internal static int CardRows(int meters, bool target, double cellHeight, double scale = 1) =>
+ (int)Math.Ceiling((NeededHeight(meters, target, cellHeight / scale) * scale / cellHeight) - 0.001);
private bool StackedFor(double height, VitalsModel model)
{
diff --git a/src/SharpMUTerm.Avalonia/WindowHost.cs b/src/SharpMUTerm.Avalonia/WindowHost.cs
index 0284dbd8..cb14c109 100644
--- a/src/SharpMUTerm.Avalonia/WindowHost.cs
+++ b/src/SharpMUTerm.Avalonia/WindowHost.cs
@@ -70,6 +70,7 @@ public static ClientHost Create(AvaloniaConsoleDriver driver, Action? cl
{
var host = Build(driver, clipboard, emulated ? HostChrome.None : chrome);
driver.Pointer.CellsOnly = emulated;
+ driver.TextSize.SizesAreas = !emulated;
return emulated
? host with
{
@@ -144,6 +145,9 @@ public static ClientHost Create(AvaloniaConsoleDriver driver, Action? cl
// F7's background transparency is drawn here: every plane at the level, over a translucent desktop window.
Translucency = driver.Translucency,
+ // The Text size page and the zoom: the window draws its cells and its native views at the sizes the client says.
+ TextSize = driver.TextSize,
+
// Set by the live launch alone (ClientLaunch.Run), so the demo and every test ask GitHub nothing.
Updates = null,
};
diff --git a/src/SharpMUTerm.Avalonia/WindowOptions.cs b/src/SharpMUTerm.Avalonia/WindowOptions.cs
index a1f41c1e..fe40de83 100644
--- a/src/SharpMUTerm.Avalonia/WindowOptions.cs
+++ b/src/SharpMUTerm.Avalonia/WindowOptions.cs
@@ -27,7 +27,8 @@ internal sealed record WindowOptions(
bool CellChrome = false,
float LineHeight = WindowOptions.DefaultLineHeight,
int? Transparency = null,
- bool Emulated = false)
+ bool Emulated = false,
+ bool FontSizeGiven = false)
{
///
/// --emulated: the terminal in a window. The cells draw everything, as in a terminal: no native header,
@@ -69,6 +70,7 @@ public static WindowOptions Parse(string[] args)
var rest = new List();
string? font = null, view = null, theme = null, png = null;
var points = DefaultPoints;
+ var fontSizeGiven = false;
var lineHeight = DefaultLineHeight;
var demo = false;
int? transparency = null;
@@ -86,6 +88,7 @@ public static WindowOptions Parse(string[] args)
if (float.TryParse(Next(), NumberStyles.Float, CultureInfo.InvariantCulture, out var p))
{
points = Math.Clamp(p, 6, 48);
+ fontSizeGiven = true;
}
break;
@@ -123,7 +126,7 @@ public static WindowOptions Parse(string[] args)
}
}
- return new WindowOptions([.. rest], font, points, demo, view, theme, png, columns, rows, titleBar, cellChrome, lineHeight, transparency, emulated);
+ return new WindowOptions([.. rest], font, points, demo, view, theme, png, columns, rows, titleBar, cellChrome, lineHeight, transparency, emulated, fontSizeGiven);
}
/// Applies --theme, the way the snapshot does: name and inline theme both; and --transparency.
@@ -174,7 +177,7 @@ public static string Usage
("Window:",
[
new("--font ", "An installed monospace font (default: the bundled JetBrains Mono)."),
- new("--font-size ", "Font size (default 10)."),
+ new("--font-size ", "Output font size for this run, over the Text size page's (default 10)."),
new("--line-height ", "Row height as a multiple of the font's (1 to 2; default 1.12)."),
new("--size ", "The window's first size in cells, and the grid for --render-png (default 120x36)."),
new("--title-bar", "Keep the system title bar. Without it, drag the blank part of the header",
diff --git a/src/SharpMUTerm.Avalonia/WindowPointer.cs b/src/SharpMUTerm.Avalonia/WindowPointer.cs
index 9f046a8a..555d2089 100644
--- a/src/SharpMUTerm.Avalonia/WindowPointer.cs
+++ b/src/SharpMUTerm.Avalonia/WindowPointer.cs
@@ -113,8 +113,14 @@ internal double CellHeightMeasured
set => Volatile.Write(ref _cellHeight, value);
}
+ ///
+ /// The Text size page's panel scale the gauges are drawn at, read when the client asks; 1 until the main window says
+ /// how to read it. It reads the sizes the client last said, so a change is seen as soon as it is said.
+ ///
+ internal Func PanelScale { get; set; } = () => 1;
+
int? IPointerPresenter.VitalsCardRows(int meters, bool target) =>
- CellsOnly ? null : CellHeightMeasured is > 0 and var cell ? VitalsGauges.CardRows(meters, target, cell) : null;
+ CellsOnly ? null : CellHeightMeasured is > 0 and var cell ? VitalsGauges.CardRows(meters, target, cell, PanelScale()) : null;
public void Scrollbars(IReadOnlyList bars)
{
diff --git a/src/SharpMUTerm.Client/Commands/BuiltInCommands.cs b/src/SharpMUTerm.Client/Commands/BuiltInCommands.cs
index b431f0e5..77facdb3 100644
--- a/src/SharpMUTerm.Client/Commands/BuiltInCommands.cs
+++ b/src/SharpMUTerm.Client/Commands/BuiltInCommands.cs
@@ -104,6 +104,18 @@ public static class BuiltInCommands
/// Windowed full screen, and back (⇧F11 in the window): a borderless window the size of the screen.
public const string WindowedFullScreenCommand = "window:windowed-full-screen";
+ /// Zooms the window in a step (Ctrl+= in the window). Offered only by a host that draws its own text.
+ public const string ZoomInCommand = "window:zoom-in";
+
+ /// Zooms the window out a step (Ctrl+- in the window).
+ public const string ZoomOutCommand = "window:zoom-out";
+
+ /// Puts the window's zoom back to 100% (Ctrl+0 in the window).
+ public const string ZoomResetCommand = "window:zoom-reset";
+
+ /// Opens the Text size & zoom page (the window only).
+ public const string TextSizeCommand = "window:text-size";
+
/// Asks the release feed for a newer SharpMUTerm now, whatever F7's check for updates says.
public const string CheckUpdatesCommand = "app:check-updates";
@@ -273,6 +285,18 @@ void Family(string id, string prefix, Func c.State.WindowMode is not null,
titleFor: c => c.State.WindowMode == Core.Configuration.WindowMode.WindowedFullScreen ? "Leave windowed full screen" : "Windowed full screen");
+ // Zoom, where the host draws its own text (TextZoom is null in a terminal, whose emulator zooms its own font).
+ Add(ZoomInCommand, CommandGroup.Layout, "Zoom in", "⌃=", "make everything in the window bigger",
+ when: c => c.State.TextZoom is { } zoom && zoom < Core.Configuration.ZoomLevels.Maximum,
+ titleFor: c => "Zoom in to " + Core.Configuration.ZoomLevels.Label(Core.Configuration.ZoomLevels.Next(c.State.TextZoom ?? 100)));
+ Add(ZoomOutCommand, CommandGroup.Layout, "Zoom out", "⌃-", "make everything in the window smaller",
+ when: c => c.State.TextZoom is { } zoom && zoom > Core.Configuration.ZoomLevels.Minimum,
+ titleFor: c => "Zoom out to " + Core.Configuration.ZoomLevels.Label(Core.Configuration.ZoomLevels.Previous(c.State.TextZoom ?? 100)));
+ Add(ZoomResetCommand, CommandGroup.Layout, "Reset zoom", "⌃0", "back to 100% · sizes by area are on the Text size page",
+ when: c => c.State.TextZoom is { } zoom && zoom != 100,
+ titleFor: c => "Reset zoom (" + Core.Configuration.ZoomLevels.Label(c.State.TextZoom ?? 100) + ")");
+ Add(TextSizeCommand, CommandGroup.Settings, "Text size & zoom…", detail: "font sizes for the output, composer, panels and menus",
+ when: c => c.State.TextSizePage);
Add("term:walk-stop", CommandGroup.Map, "Stop walking", detail: "ends the speedwalk", when: c => c.State.Walking);
// Game sound, for this run. The levels and the off switch are F7's; this is the quick one.
diff --git a/src/SharpMUTerm.Client/Help/HelpTopics.cs b/src/SharpMUTerm.Client/Help/HelpTopics.cs
index 359830f1..4a4d8eec 100644
--- a/src/SharpMUTerm.Client/Help/HelpTopics.cs
+++ b/src/SharpMUTerm.Client/Help/HelpTopics.cs
@@ -43,6 +43,16 @@ public static IReadOnlyList For(string newline) =>
"",
"Copy: drag across output, then ⌃C.",
]),
+ new("text-size", "Text size", "make the text bigger or smaller", [
+ "In the window: ⌃= zooms in, ⌃- zooms out and ⌃0 goes back to 100%, as in a browser; so does ⌃ with",
+ "the wheel, and the output's right-click menu. The zoom covers the whole window and is kept between runs.",
+ "⌃P ▸ Text size & zoom sets the output's size in points, and the composer, the header and sidebar,",
+ "the panels, and menus and dialogs each on their own.",
+ "",
+ "In a terminal the text is the terminal's own, and nothing a program sends can change its size: use",
+ "the terminal's zoom (kitty ⌃⇧= and ⌃⇧-; WezTerm, Ghostty and Windows Terminal ⌃= and ⌃-).",
+ "The panes are re-measured and each game is told its pane's new size either way.",
+ ]),
new("layouts", "Layouts", "panes, panels, and saving an arrangement", [
"⌥M lists the layouts: Roleplay (who is here, a composer), Combat (vitals, a map, a quick bar)",
"and your own. ⏎ applies one; n saves the panes as they are now as a new layout.",
diff --git a/src/SharpMUTerm.Client/Settings/BuiltInSettings.TextSize.cs b/src/SharpMUTerm.Client/Settings/BuiltInSettings.TextSize.cs
new file mode 100644
index 00000000..721620c2
--- /dev/null
+++ b/src/SharpMUTerm.Client/Settings/BuiltInSettings.TextSize.cs
@@ -0,0 +1,85 @@
+using SharpMUTerm.Client.Contributions;
+using SharpMUTerm.Core.Configuration;
+
+namespace SharpMUTerm.Client.Settings;
+
+public static partial class BuiltInSettings
+{
+ /// The Text size page's first group: the zoom over everything, and the output's own size.
+ public const string ZoomGroup = "Zoom";
+
+ /// The Text size page's second group: each area's size against its designed one.
+ public const string AreasGroup = "Text size by area";
+
+ /// The id of the Text size page, which ⌃P's zoom entries' detail and the help topic name.
+ public const string TextSizeSectionId = "text-size";
+
+ /// The zoom levels as the page lists them: 50% … 300%.
+ public static IReadOnlyList ZoomChoices { get; } = [.. ZoomLevels.Steps.Select(ZoomLevels.Label)];
+
+ /// An area's sizes as the page lists them: 75% … 200%.
+ public static IReadOnlyList AreaChoices { get; } = [.. ZoomLevels.AreaSteps.Select(ZoomLevels.Label)];
+
+ ///
+ /// The desktop window's text sizes: a zoom over the whole window (Ctrl+= / Ctrl+- / Ctrl+0, Ctrl+wheel), the
+ /// output's size in points, and the composer, interface, panels and dialogs each as a percentage. Not in
+ /// and on no F-key, like : a host registers it only when it draws its own text
+ /// (the window), so a terminal, whose font size is its emulator's, never lists a choice that does nothing there.
+ ///
+ public static SettingsSectionContribution TextSize { get; } = new()
+ {
+ Id = TextSizeSectionId,
+ Title = "Text size & zoom",
+ Order = 65,
+ Fields =
+ [
+ new ChoiceField("zoom", "zoom", ZoomChoices,
+ "everything in the window, as a browser zooms; Ctrl+= and Ctrl+- step it, Ctrl+0 resets, Ctrl+wheel too")
+ {
+ Group = ZoomGroup,
+ Keywords = "zoom scale magnify bigger smaller font size text size accessibility dpi",
+ Binding = Percent(c => c.Display.Zoom, (c, v) => c.Display.Zoom = ZoomLevels.Clamp(v)),
+ },
+ Number("output-points", "output text (pt)", DisplaySettings.MinOutputPoints, DisplaySettings.MaxOutputPoints,
+ ZoomGroup,
+ c => c.Display.OutputPoints, (c, v) => c.Display.OutputPoints = v,
+ "the game's text and the command line, before the zoom; the panes are re-measured and the game told their new size",
+ "font size points output console monospace game text naws"),
+
+ Area("composer-text", "composer text", c => c.Display.ComposerText, (c, v) => c.Display.ComposerText = v,
+ "what you write in F1 and the docked composer, against the output's size", "compose editor post font"),
+ Area("interface-text", "interface", c => c.Display.InterfaceText, (c, v) => c.Display.InterfaceText = v,
+ "the header, sidebar, tabs, status row and notices", "chrome sidebar header tabs footer status toast font"),
+ Area("panel-text", "panels", c => c.Display.PanelText, (c, v) => c.Display.PanelText = v,
+ "vitals, who is here, the quick bar and the map", "vitals map who here quick bar gauges font"),
+ Area("dialog-text", "menus & dialogs", c => c.Display.DialogText, (c, v) => c.Display.DialogText = v,
+ "the command palette, dialogs, right-click menus and this settings window", "palette modal menu dialog settings font"),
+ ],
+ };
+
+ private static ChoiceField Area(
+ string id,
+ string label,
+ Func get,
+ Action set,
+ string help,
+ string keywords) =>
+ new(id, label, AreaChoices, help)
+ {
+ Group = AreasGroup,
+ Keywords = "font size text size bigger smaller " + keywords,
+ Binding = Percent(get, (c, v) => set(c, ZoomLevels.ClampArea(v))),
+ };
+
+ /// A percentage shown and chosen as 125%; a word that names none leaves the value as it was.
+ private static SettingBinding Percent(Func get, Action set) =>
+ new(
+ c => ZoomLevels.Label(get(c)),
+ (c, v) =>
+ {
+ if (ZoomLevels.Parse(v) is { } percent)
+ {
+ set(c, percent);
+ }
+ });
+}
diff --git a/src/SharpMUTerm.Core/Commands/CommandCatalog.cs b/src/SharpMUTerm.Core/Commands/CommandCatalog.cs
index 03e8ee33..4380c39b 100644
--- a/src/SharpMUTerm.Core/Commands/CommandCatalog.cs
+++ b/src/SharpMUTerm.Core/Commands/CommandCatalog.cs
@@ -81,6 +81,11 @@ public sealed record CharacterRef(
/// in a host with no window to size (the terminal).
///
/// Where this copy's update stands; null in a host that cannot update itself.
+/// Whether the host has the Text size & zoom page (the window, not --emulated).
+///
+/// The desktop window's zoom, a percentage, so ⌃P offers zooming in, out and back; null in a host that draws no text of
+/// its own (the terminal, whose font size is its emulator's).
+///
public sealed record CommandContext(
bool LoggingOn = false,
bool Zoomed = false,
@@ -110,7 +115,9 @@ public sealed record CommandContext(
int RecentAlerts = 0,
IReadOnlyList? Alerts = null,
Configuration.WindowMode? WindowMode = null,
- UpdateState? Update = null);
+ UpdateState? Update = null,
+ int? TextZoom = null,
+ bool TextSizePage = false);
///
/// Where this copy's update stands, for ⌃P's update entries: null when the host cannot update itself at all (a build
diff --git a/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs b/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs
index f20f0ffd..8badbf8d 100644
--- a/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs
+++ b/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs
@@ -142,6 +142,12 @@ public sealed class AppConfiguration
///
public WindowMode WindowStart { get; set; }
+ ///
+ /// The desktop window's text sizes and zoom (the Text size settings page, Ctrl+= / Ctrl+- / Ctrl+0). Read by the
+ /// window alone; a terminal's font size is its emulator's.
+ ///
+ public DisplaySettings Display { get; set; } = new();
+
/// The package index Browse packages reads: an https link to an index.json.
public string PackageIndexUrl { get; set; } = DefaultPackageIndexUrl;
diff --git a/src/SharpMUTerm.Core/Configuration/DisplaySettings.cs b/src/SharpMUTerm.Core/Configuration/DisplaySettings.cs
new file mode 100644
index 00000000..3502033d
--- /dev/null
+++ b/src/SharpMUTerm.Core/Configuration/DisplaySettings.cs
@@ -0,0 +1,130 @@
+namespace SharpMUTerm.Core.Configuration;
+
+///
+/// How big the desktop window draws its text, area by area, and the zoom over all of them. Read by the window alone: a
+/// terminal draws its cells in the font size its emulator was told, and nothing the client writes can change that, so
+/// the terminal lists none of these (its help says to use the terminal's own zoom).
+///
+/// The output is in points, because a monospace console's size is a font size (Mudlet, Windows Terminal and kitty all
+/// say it that way). Every other area is a percentage of its designed size, because each is a ramp of sizes (a heading,
+/// a label, a caption) that has to move together. multiplies all of them, the way a browser's does,
+/// and is what Ctrl+= / Ctrl+- / Ctrl+0 and Ctrl+wheel change.
+///
+///
+public sealed class DisplaySettings
+{
+ /// The zoom over the whole window, a percentage on .
+ public int Zoom { get; set; } = 100;
+
+ /// The game's text, the command line and every cell panel, in points, before .
+ public int OutputPoints { get; set; } = DefaultOutputPoints;
+
+ /// The composer's text (F1 and the docked panel), as a percentage of the output's.
+ public int ComposerText { get; set; } = 100;
+
+ /// The header, sidebar, tab strips, footer, scrolled-back band and notices.
+ public int InterfaceText { get; set; } = 100;
+
+ /// The panels a layout places: vitals, who is here, the quick bar, the map, the docked composer's frame.
+ public int PanelText { get; set; } = 100;
+
+ /// The command palette, the dialogs, the right-click menus and the settings window.
+ public int DialogText { get; set; } = 100;
+
+ /// The output's size with nothing chosen: 10 pt, the mockups' 13 px game text.
+ public const int DefaultOutputPoints = 10;
+
+ /// The smallest output size a player can choose.
+ public const int MinOutputPoints = 6;
+
+ /// The largest output size a player can choose.
+ public const int MaxOutputPoints = 48;
+
+ /// A copy held to every range, so a hand-edited file cannot ask for a 2,000% sidebar.
+ public DisplaySettings Clamped() => new()
+ {
+ Zoom = ZoomLevels.Clamp(Zoom),
+ OutputPoints = Math.Clamp(OutputPoints, MinOutputPoints, MaxOutputPoints),
+ ComposerText = ZoomLevels.ClampArea(ComposerText),
+ InterfaceText = ZoomLevels.ClampArea(InterfaceText),
+ PanelText = ZoomLevels.ClampArea(PanelText),
+ DialogText = ZoomLevels.ClampArea(DialogText),
+ };
+}
+
+///
+/// The zoom levels the window steps through, and the percentages an area's text size is chosen from. The steps are the
+/// browsers' (Chrome and Firefox step 50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250, 300): small near 100%,
+/// where a reader is fine-tuning, and large past 200%, where they need it big.
+///
+public static class ZoomLevels
+{
+ /// Every zoom level, smallest first.
+ public static IReadOnlyList Steps { get; } = [50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250, 300];
+
+ /// The percentages an area's text can be set to: the zoom steps an area is useful at.
+ public static IReadOnlyList AreaSteps { get; } = [75, 80, 90, 100, 110, 125, 150, 175, 200];
+
+ /// The smallest zoom.
+ public static int Minimum => Steps[0];
+
+ /// The largest zoom.
+ public static int Maximum => Steps[^1];
+
+ /// The level after , or the largest; a percentage between steps goes to the next one up.
+ public static int Next(int percent)
+ {
+ foreach (var step in Steps)
+ {
+ if (step > percent)
+ {
+ return step;
+ }
+ }
+
+ return Maximum;
+ }
+
+ /// The level before , or the smallest.
+ public static int Previous(int percent)
+ {
+ for (var i = Steps.Count - 1; i >= 0; i--)
+ {
+ if (Steps[i] < percent)
+ {
+ return Steps[i];
+ }
+ }
+
+ return Minimum;
+ }
+
+ ///
+ /// The level moves to from : a positive step zooms in, a negative
+ /// one out, zero resets to 100%.
+ ///
+ public static int Stepped(int percent, int step) => step switch
+ {
+ > 0 => Next(percent),
+ < 0 => Previous(percent),
+ _ => 100,
+ };
+
+ /// held to the zoom's range (a value between steps is kept, as a file may say one).
+ public static int Clamp(int percent) => Math.Clamp(percent, Minimum, Maximum);
+
+ /// held to an area's range.
+ public static int ClampArea(int percent) => Math.Clamp(percent, AreaSteps[0], AreaSteps[^1]);
+
+ /// How a percentage is written on every surface: 125%.
+ public static string Label(int percent) => percent.ToString(System.Globalization.CultureInfo.InvariantCulture) + "%";
+
+ /// The percentage names (125% or 125), or null.
+ public static int? Parse(string? words)
+ {
+ var text = words?.Trim().TrimEnd('%').Trim();
+ return int.TryParse(text, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var value)
+ ? value
+ : null;
+ }
+}
diff --git a/src/SharpMUTerm.Tui/Hosting/ClientHost.cs b/src/SharpMUTerm.Tui/Hosting/ClientHost.cs
index 13fe73f3..f8c92c99 100644
--- a/src/SharpMUTerm.Tui/Hosting/ClientHost.cs
+++ b/src/SharpMUTerm.Tui/Hosting/ClientHost.cs
@@ -185,6 +185,14 @@ internal sealed record ClientHost
///
public required IWindowTranslucency? Translucency { get; init; }
+ ///
+ /// Draws its own text and so can size it (): the window, which is told the Text size
+ /// page's sizes and the zoom, and asks the client to step the zoom on Ctrl+= / Ctrl+- / Ctrl+0 and Ctrl+wheel. Null in
+ /// the terminal (its font size is its emulator's), the snapshot, the tests and --plain, where the client lists
+ /// none of it.
+ ///
+ public required IWindowTextSize? TextSize { get; init; }
+
///
/// Finding, downloading and applying a newer release (): only an installed copy of
/// the terminal or the window has one. Null for a build run from a folder, the snapshot, the tests, --plain
@@ -242,6 +250,7 @@ public static ClientHost Terminal(TerminalCapabilities capabilities)
FilePicker = null,
WindowModes = null,
Translucency = null,
+ TextSize = null,
Updates = VelopackUpdates.Detect(window: false),
};
@@ -278,6 +287,7 @@ public static ClientHost Terminal(TerminalCapabilities capabilities)
FilePicker = null,
WindowModes = null,
Translucency = null,
+ TextSize = null,
Updates = null,
};
diff --git a/src/SharpMUTerm.Tui/Hosting/IWindowTextSize.cs b/src/SharpMUTerm.Tui/Hosting/IWindowTextSize.cs
new file mode 100644
index 00000000..27fc92b4
--- /dev/null
+++ b/src/SharpMUTerm.Tui/Hosting/IWindowTextSize.cs
@@ -0,0 +1,30 @@
+using SharpMUTerm.Core.Configuration;
+
+namespace SharpMUTerm.Tui;
+
+///
+/// A host that draws its own text and so can size it: the desktop window. The client owns the sizes (they are the
+/// configuration's , saved like any setting) and tells the host each time they
+/// change; the host owns the keys and the wheel that zoom, and asks the client to step, so a zoom is saved and offered on
+/// ⌃P exactly as a change on the settings page is.
+///
+internal interface IWindowTextSize
+{
+ ///
+ /// Says the sizes to draw at, already held to their ranges. Called on the client's thread, once a frame has been
+ /// painted, and only when they changed.
+ ///
+ void Show(DisplaySettings sizes);
+
+ ///
+ /// Whether the host draws native views the page's areas (composer, interface, panels, dialogs) size. False where
+ /// everything is cells (the window's --emulated), where only the zoom and the output's size apply.
+ ///
+ bool SizesAreas { get; }
+
+ ///
+ /// Raised on the host's thread when the player zooms with the host's own keys or wheel: a positive step zooms in, a
+ /// negative one out, zero resets to 100%.
+ ///
+ event Action? ZoomRequested;
+}
diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.TextSize.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.TextSize.cs
new file mode 100644
index 00000000..820e1cd1
--- /dev/null
+++ b/src/SharpMUTerm.Tui/SharpMUTermApp.TextSize.cs
@@ -0,0 +1,122 @@
+using SharpMUTerm.Client.Settings;
+using SharpMUTerm.Core.Configuration;
+using SharpMUTerm.Core.Diagnostics;
+
+namespace SharpMUTerm.Tui;
+
+///
+/// Text size and zoom, where the host draws its own text (: the desktop window). The
+/// sizes are the configuration's : the Text size page edits them through the
+/// settings funnel like any setting, the window's Ctrl+= / Ctrl+- / Ctrl+0 and Ctrl+wheel and ⌃P's zoom entries step the
+/// zoom here, and the host is told the result. A terminal has none of it: its font size is its emulator's, and nothing an
+/// application writes changes it (xterm's OSC 50 picks a font, not a size, and is off in every terminal this client
+/// targets), so the client lists nothing that would do nothing and the help topic says to use the terminal's own zoom.
+///
+internal sealed partial class SharpMUTermApp
+{
+ /// Whether the host draws its own text and so can size it.
+ internal bool CanSizeText => Host.TextSize is not null;
+
+ /// The sizes last said to the host; null before the first, so the host is always told once.
+ private DisplaySettings? _textSizesShown;
+
+ /// The sizes in force, held to their ranges.
+ internal DisplaySettings TextSizes => _config.Display.Clamped();
+
+ ///
+ /// Whether the Text size & zoom page is offered: the host sizes its own text and draws the page natively. The
+ /// window's --emulated mode draws everything in cells and has no native settings window, so it keeps the zoom
+ /// (keys, wheel, right-click menu, ⌃P) and takes the output's size from --font-size, with no page.
+ ///
+ internal bool HasTextSizePage => Host.TextSize is { SizesAreas: true } && Host.NativeSettings is not null;
+
+ /// Registers the Text size page and listens for the host's zoom keys, where the host can size text.
+ private void RegisterTextSize()
+ {
+ if (Host.TextSize is not { } host)
+ {
+ return;
+ }
+
+ if (HasTextSizePage)
+ {
+ _contributions.SettingsSections.Add(BuiltInSettings.TextSize);
+ }
+
+ host.ZoomRequested += step => OnUiThread(() => StepZoom(step));
+ }
+
+ /// Opens the Text size & zoom page in the host's settings window (⌃P's entry).
+ private bool OpenTextSizePage()
+ {
+ if (!HasTextSizePage)
+ {
+ Notice("text size is your terminal's own here: use its zoom (often Ctrl+= and Ctrl+-)", toast: false);
+ return false;
+ }
+
+ return TryOpenNativeSettings(Section(BuiltInSettings.TextSizeSectionId));
+ }
+
+ ///
+ /// Steps the zoom (in for a positive , out for a negative one, back to 100% for zero), saves it
+ /// and tells the host. Saved through , the view-preference path, because the settings
+ /// funnel re-periodises every timer and a zoom is pressed in runs. Refused out loud where the host draws no text.
+ ///
+ internal bool StepZoom(int step)
+ {
+ if (Host.TextSize is null)
+ {
+ Notice("text size is your terminal's own here: use its zoom (often Ctrl+= and Ctrl+-)", toast: false);
+ return false;
+ }
+
+ var before = ZoomLevels.Clamp(_config.Display.Zoom);
+ var after = ZoomLevels.Stepped(before, step);
+ if (after == before && _config.Display.Zoom == before)
+ {
+ Notice(step > 0 ? $"zoom is at its largest, {ZoomLevels.Label(after)}"
+ : step < 0 ? $"zoom is at its smallest, {ZoomLevels.Label(after)}"
+ : "zoom is already 100%", MessageSeverity.Info, toast: false);
+ SyncTextSizes();
+ return true;
+ }
+
+ _config.Display.Zoom = after;
+ PersistConfiguration();
+ SyncTextSizes();
+ Notice($"zoom {ZoomLevels.Label(after)}", MessageSeverity.Info, key: step == 0 ? "⌃0" : step > 0 ? "⌃=" : "⌃-", toast: false);
+ return true;
+ }
+
+ ///
+ /// Tells the host the sizes when they differ from what it was last told: after every settings commit, every zoom step
+ /// and every frame (a frame is cheap to check and covers a change made any other way, such as an undo).
+ ///
+ private void SyncTextSizes()
+ {
+ if (Host.TextSize is not { } host)
+ {
+ return;
+ }
+
+ var sizes = TextSizes;
+ if (_textSizesShown is { } shown && Same(shown, sizes))
+ {
+ return;
+ }
+
+ _textSizesShown = sizes;
+ host.Show(sizes);
+
+ // A vitals card is as tall as its gauges, which are drawn at the panel size: a change there can need other rows.
+ foreach (var windowId in _vitalsCardRows.Keys.ToArray())
+ {
+ ResizeVitalsCard(windowId);
+ }
+ }
+
+ private static bool Same(DisplaySettings a, DisplaySettings b) =>
+ a.Zoom == b.Zoom && a.OutputPoints == b.OutputPoints && a.ComposerText == b.ComposerText
+ && a.InterfaceText == b.InterfaceText && a.PanelText == b.PanelText && a.DialogText == b.DialogText;
+}
diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs
index 03bef7c3..e54743f5 100644
--- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs
+++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs
@@ -979,6 +979,7 @@ public SharpMUTermApp(
RegisterBuiltInContributions();
RegisterBuiltInSettings();
+ RegisterTextSize();
_palette = new CommandPalette(
_system,
BuildCatalog,
@@ -1089,6 +1090,7 @@ public SharpMUTermApp(
ReportPaneSizes();
SyncTabStripWidths();
SyncTransparentColours();
+ SyncTextSizes();
MeasureHeadingRuns(buffer);
PaintHeaderDividers(buffer);
PaintTabOverflow(buffer);
@@ -8016,6 +8018,9 @@ internal void SaveConfiguration()
// repainted below is painted in it. See SharpMUTermApp.Theme.cs.
ApplyThemeIfChanged();
+ // The Text size page: the window redraws at the new sizes now, not at the next frame the cells happen to paint.
+ SyncTextSizes();
+
// F8 edits the live InputSettings, so a committed height applies to the bars immediately rather
// than at the next launch.
SyncInputBars();
@@ -8995,6 +9000,8 @@ private CommandContext CommandState()
RecentAlerts: _recentAlerts.Count,
Alerts: _alertsListed ? AlertRefs() : null,
WindowMode: CanSetWindowMode ? Host.WindowModes!.Mode : null,
+ TextZoom: CanSizeText ? TextSizes.Zoom : null,
+ TextSizePage: HasTextSizePage,
Update: UpdateCommandState(),
ScriptFiles: !_scriptsListed || (_scriptsRoot is null && !DemoScriptsShown) ? null : ScriptFileRefs());
}
@@ -9486,6 +9493,14 @@ private bool RunBuiltInCommand(string id)
return true;
case SharpMUTerm.Client.Commands.BuiltInCommands.FullScreenCommand:
return ToggleWindowMode(WindowMode.FullScreen);
+ case SharpMUTerm.Client.Commands.BuiltInCommands.ZoomInCommand:
+ return StepZoom(1);
+ case SharpMUTerm.Client.Commands.BuiltInCommands.ZoomOutCommand:
+ return StepZoom(-1);
+ case SharpMUTerm.Client.Commands.BuiltInCommands.ZoomResetCommand:
+ return StepZoom(0);
+ case SharpMUTerm.Client.Commands.BuiltInCommands.TextSizeCommand:
+ return OpenTextSizePage();
case SharpMUTerm.Client.Commands.BuiltInCommands.WindowedFullScreenCommand:
return ToggleWindowMode(WindowMode.WindowedFullScreen);
case "term:mute":
diff --git a/tests/SharpMUTerm.Avalonia.Tests/HostParityTests.cs b/tests/SharpMUTerm.Avalonia.Tests/HostParityTests.cs
index 98421342..d6532147 100644
--- a/tests/SharpMUTerm.Avalonia.Tests/HostParityTests.cs
+++ b/tests/SharpMUTerm.Avalonia.Tests/HostParityTests.cs
@@ -68,7 +68,7 @@ public async Task EveryViewDrawsTheSameCellsInTheWindowAsInTheTerminal(string na
using var driver = new AvaloniaConsoleDriver(Width, Height);
await using (var window = new SharpMUTermApp(
DemoScene.Build(),
- WindowHost.Create(driver, clipboard: null, chrome) with { Capabilities = Caps, Keys = HostKeys.Terminal, Audio = null, Presence = null, Announcer = null, NativeSettings = null, NativePalette = null, FloatingPanels = null, WindowModes = null, RunsInline = true }))
+ WindowHost.Create(driver, clipboard: null, chrome) with { Capabilities = Caps, Keys = HostKeys.Terminal, Audio = null, Presence = null, Announcer = null, NativeSettings = null, NativePalette = null, FloatingPanels = null, WindowModes = null, TextSize = null, RunsInline = true }))
{
window.RenderSnapshot(view);
}
@@ -107,7 +107,7 @@ public async Task EveryViewDrawsTheSameCellsWhenTheWindowsLoopIsRunning(string n
using var driver = new AvaloniaConsoleDriver(Width, Height);
await using var window = new SharpMUTermApp(
DemoScene.Build(),
- WindowHost.Create(driver, clipboard: null, chrome) with { Capabilities = Caps, Keys = HostKeys.Terminal, Audio = null, Presence = null, Announcer = null, NativeSettings = null, NativePalette = null, FloatingPanels = null, WindowModes = null });
+ WindowHost.Create(driver, clipboard: null, chrome) with { Capabilities = Caps, Keys = HostKeys.Terminal, Audio = null, Presence = null, Announcer = null, NativeSettings = null, NativePalette = null, FloatingPanels = null, WindowModes = null, TextSize = null });
window.OnUiThread(() => _ = window.RenderSnapshot(view));
var loop = new Thread(() => window.Run([])) { IsBackground = true };
loop.Start();
diff --git a/tests/SharpMUTerm.Avalonia.Tests/TerminalZoomTests.cs b/tests/SharpMUTerm.Avalonia.Tests/TerminalZoomTests.cs
index 57db70e8..147f35ee 100644
--- a/tests/SharpMUTerm.Avalonia.Tests/TerminalZoomTests.cs
+++ b/tests/SharpMUTerm.Avalonia.Tests/TerminalZoomTests.cs
@@ -17,7 +17,7 @@ public async Task ReservedZoomChordsMatchTheirActions()
var key = Enum.Parse(reserved.Chord.Key.ToString());
var modifiers = KeyModifiers.Control;
if (reserved.Chord.Modifiers.HasFlag(ConsoleModifiers.Shift)) modifiers |= KeyModifiers.Shift;
- var expected = reserved.Action switch { "zooms in" => 10, "zooms out" => -10, "resets terminal zoom" => 0, _ => int.MinValue };
+ var expected = reserved.Action switch { "zooms in" => 1, "zooms out" => -1, "resets terminal zoom" => 0, _ => int.MinValue };
await Assert.That(TerminalView.ZoomFor(key, modifiers)).IsEqualTo(expected);
}
}
@@ -139,28 +139,73 @@ public async Task ZoomWorksWhileANativeEditorOwnsFocus()
}
[Test]
- public async Task ZoomIsBoundedAndMenuActionsResetToLaunchSize()
+ public async Task ZoomStepsThroughTheLevelsIsBoundedAndTheMenuResets()
{
var result = await TerminalViewTests.Session.Dispatch(() =>
{
using var driver = new AvaloniaConsoleDriver(80, 24);
var view = new TerminalView(driver, SKColors.White, SKColors.Black, 20, null);
- view.SetZoom(999);
+ var seen = new List();
+ for (var i = 0; i < 20; i++)
+ {
+ view.RequestZoom(1);
+ seen.Add(view.ZoomPercent);
+ }
+
var max = view.ZoomPercent;
var disabledIn = !view.ZoomItems[0].Enabled;
- view.SetZoom(-1);
+ for (var i = 0; i < 20; i++)
+ {
+ view.RequestZoom(-1);
+ }
+
var min = view.ZoomPercent;
var disabledOut = !view.ZoomItems[1].Enabled;
view.ZoomItems[2].Run();
- return (max, min, disabledIn, disabledOut, reset: view.ZoomPercent, text: view.CellTextSize);
+ return (seen, max, min, disabledIn, disabledOut, reset: view.ZoomPercent, text: view.CellTextSize);
}, CancellationToken.None);
- await Assert.That(result.max).IsEqualTo(200);
+ await Assert.That(result.seen.Take(4)).IsEquivalentTo(new[] { 110, 125, 150, 175 });
+ await Assert.That(result.max).IsEqualTo(300);
await Assert.That(result.min).IsEqualTo(50);
await Assert.That(result.disabledIn && result.disabledOut).IsTrue();
await Assert.That(result.reset).IsEqualTo(100);
await Assert.That(result.text).IsEqualTo(20);
}
+ [Test]
+ public async Task WithAListenerTheViewAsksAndDoesNotZoomItself()
+ {
+ var result = await TerminalViewTests.Session.Dispatch(() =>
+ {
+ using var driver = new AvaloniaConsoleDriver(80, 24);
+ var view = new TerminalView(driver, SKColors.White, SKColors.Black, 16, null);
+ var asked = new List();
+ view.ZoomRequested += asked.Add;
+ view.ZoomShown = () => 125;
+ view.HandleKeyDown(new KeyEventArgs { Key = Key.OemMinus, KeyModifiers = KeyModifiers.Control });
+ return (asked, view.ZoomPercent, menu: view.ZoomItems.Select(i => i.Label).ToArray());
+ }, CancellationToken.None);
+ await Assert.That(result.asked).IsEquivalentTo(new[] { -1 });
+ await Assert.That(result.ZoomPercent).IsEqualTo(100);
+ await Assert.That(result.menu[0]).IsEqualTo("Zoom in (150%)");
+ await Assert.That(result.menu[2]).IsEqualTo("Reset zoom (125%)");
+ }
+
+ [Test]
+ public async Task CtrlAndTheWheelZoomANotchAtATime()
+ {
+ var remainder = 0.0;
+ await Assert.That(TerminalView.ZoomForWheel(ref remainder, KeyModifiers.Control, 1)).IsEqualTo(1);
+ await Assert.That(TerminalView.ZoomForWheel(ref remainder, KeyModifiers.Control, -1)).IsEqualTo(-1);
+ await Assert.That(TerminalView.ZoomForWheel(ref remainder, KeyModifiers.None, 1)).IsNull();
+ await Assert.That(TerminalView.ZoomForWheel(ref remainder, KeyModifiers.Control | KeyModifiers.Shift, 1)).IsNull();
+
+ // A touchpad's small deltas add up to a notch before they zoom.
+ remainder = 0;
+ await Assert.That(TerminalView.ZoomForWheel(ref remainder, KeyModifiers.Control, 0.4)).IsEqualTo(0);
+ await Assert.That(TerminalView.ZoomForWheel(ref remainder, KeyModifiers.Control, 0.7)).IsEqualTo(1);
+ }
+
[Test]
public async Task ZoomKeysAreLocalAndOrdinaryTypingStillReachesTheClient()
{
@@ -184,7 +229,7 @@ public async Task ZoomKeysAreLocalAndOrdinaryTypingStillReachesTheClient()
await Assert.That(result.key).IsEqualTo(ConsoleKey.A);
await Assert.That(TerminalView.ZoomFor(Key.OemPlus, KeyModifiers.None)).IsNull();
await Assert.That(TerminalView.ZoomFor(Key.D0, KeyModifiers.Control | KeyModifiers.Alt)).IsNull();
- await Assert.That(TerminalView.ZoomFor(Key.Subtract, KeyModifiers.Control)).IsEqualTo(-10);
+ await Assert.That(TerminalView.ZoomFor(Key.Subtract, KeyModifiers.Control)).IsEqualTo(-1);
await Assert.That(TerminalView.ZoomFor(Key.NumPad0, KeyModifiers.Control)).IsEqualTo(0);
}
}
diff --git a/tests/SharpMUTerm.Avalonia.Tests/TextScaleTests.cs b/tests/SharpMUTerm.Avalonia.Tests/TextScaleTests.cs
new file mode 100644
index 00000000..0bda70b5
--- /dev/null
+++ b/tests/SharpMUTerm.Avalonia.Tests/TextScaleTests.cs
@@ -0,0 +1,139 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+using SharpMUTerm.Core.Configuration;
+using SharpMUTerm.Tui;
+using SkiaSharp;
+using Window = Avalonia.Controls.Window;
+
+namespace SharpMUTerm.Avalonia.Tests;
+
+///
+/// The Text size page's areas in the window: each native view is drawn at its area's scale and still covers exactly the
+/// rectangle it covered at 100%, the chrome beside the cells grows on the 4 px grid, and the cells move out of its way.
+///
+[NotInParallel]
+public class TextScaleTests
+{
+ [Test]
+ public async Task AreasMultiplyTheZoomAndTheComposerFollowsTheCellsOwnZoom()
+ {
+ var scales = NativeScales.From(new DisplaySettings { Zoom = 125, InterfaceText = 110, PanelText = 150, DialogText = 90, ComposerText = 120 });
+
+ await Assert.That(scales.Interface).IsEqualTo(1.25 * 1.1).Within(1e-9);
+ await Assert.That(scales.Panels).IsEqualTo(1.25 * 1.5).Within(1e-9);
+ await Assert.That(scales.Dialogs).IsEqualTo(1.25 * 0.9).Within(1e-9);
+ await Assert.That(scales.Composer).IsEqualTo(1.2).Within(1e-9);
+ await Assert.That(NativeScales.From(new DisplaySettings())).IsEqualTo(NativeScales.One);
+ }
+
+ [Test]
+ public async Task ChromeSizesStayOnTheFourPixelGridAndAreUnchangedAt100()
+ {
+ var at100 = NativeLayout.Insets(HostChrome.Desktop, collapsed: false);
+ var at150 = NativeLayout.Insets(HostChrome.Desktop, collapsed: false, 1.5);
+
+ await Assert.That(at100.Top).IsEqualTo(NativeLayout.HeaderHeight);
+ await Assert.That(at150.Top).IsEqualTo(NativeLayout.Scaled(NativeLayout.HeaderHeight, 1.5));
+ foreach (var size in new[] { at150.Left, at150.Top, at150.Bottom, NativeLayout.Scaled(NativeLayout.HeaderHeight, 1.1) })
+ {
+ await Assert.That(size % 4).IsEqualTo(0);
+ }
+
+ await Assert.That(at150.Top).IsGreaterThan(at100.Top);
+ await Assert.That(at150.Left).IsGreaterThan(at100.Left);
+ }
+
+ [Test]
+ public async Task AScaledViewCoversItsAreaAndIsLaidOutSmaller()
+ {
+ var border = new Border();
+ NativeScale.Cover(border, 300, 120, 1.5);
+
+ await Assert.That(border.Width).IsEqualTo(200);
+ await Assert.That(border.Height).IsEqualTo(80);
+ await Assert.That(NativeScale.Of(border)).IsEqualTo(1.5);
+ await Assert.That(NativeScale.Extent(border)).IsEqualTo(new Size(300, 120));
+
+ NativeScale.Cover(border, 300, 120, 1);
+ await Assert.That(border.RenderTransform).IsNull();
+ await Assert.That(border.Width).IsEqualTo(300);
+ }
+
+ [Test]
+ public async Task TheLayerMovesTheCellsForAScaledHeaderAndSidebar_AndTheHeaderIsDrawnLarger()
+ {
+ var result = await TerminalViewTests.Session.Dispatch(() =>
+ {
+ var driver = new AvaloniaConsoleDriver(140, 40);
+ var terminal = new TerminalView(driver, SKColors.White, new SKColor(0x1e, 0x1e, 0x24), 10f, null, 1.12f);
+ var root = new Panel();
+ root.Children.Add(terminal);
+ var layer = new DesktopLayer(terminal, HostChrome.Desktop);
+ root.Children.Add(layer);
+ var window = new Window { Content = root, Width = 1400, Height = 900 };
+ window.Show();
+ var app = new SharpMUTermApp(DemoScene.Build(), WindowHost.Create(driver, clipboard: null) with
+ {
+ Capabilities = WindowOptions.Capabilities,
+ Audio = null,
+ Presence = null,
+ Announcer = null,
+ RunsInline = true,
+ });
+ try
+ {
+ app.RenderSnapshot("layout-combat");
+ app.RenderNextFrame();
+ DesktopLayerTests.Drain();
+ window.UpdateLayout();
+ var before = (margin: terminal.Margin, header: layer.Header.Bounds.Size, scale: NativeScale.Of(layer.Header));
+ var gaugesBefore = layer.Gauges.Values.Single();
+ var gaugeArea = NativeScale.Extent(gaugesBefore);
+
+ layer.Scales = new NativeScales(1.5, 1.5, 1, 1);
+ app.RenderNextFrame();
+ DesktopLayerTests.Drain();
+ window.UpdateLayout();
+ var gauges = layer.Gauges.Values.Single();
+ return (before, after: (margin: terminal.Margin, header: layer.Header.Bounds.Size, scale: NativeScale.Of(layer.Header)),
+ gaugeScale: NativeScale.Of(gauges), gaugeArea, gaugeAreaAfter: NativeScale.Extent(gauges),
+ headerOnScreen: layer.Header.Bounds.Height * NativeScale.Of(layer.Header));
+ }
+ finally
+ {
+ app.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ window.Close();
+ driver.Dispose();
+ }
+ }, CancellationToken.None);
+
+ await Assert.That(result.before.scale).IsEqualTo(1);
+ await Assert.That(result.before.margin.Top).IsEqualTo(NativeLayout.HeaderHeight);
+ await Assert.That(result.after.margin.Top).IsEqualTo(NativeLayout.Scaled(NativeLayout.HeaderHeight, 1.5));
+ await Assert.That(result.after.margin.Left).IsGreaterThan(result.before.margin.Left);
+ await Assert.That(result.after.scale).IsEqualTo(1.5);
+ await Assert.That(result.headerOnScreen).IsEqualTo(result.after.margin.Top).Within(0.01);
+ await Assert.That(result.gaugeScale).IsEqualTo(1.5);
+ }
+
+ [Test]
+ public async Task WithNoClientTheWindowStepsTheZoomItself_AndAClientIsAskedInstead()
+ {
+ var alone = new WindowTextSize();
+ var said = new List();
+ alone.Changed += s => said.Add(s.Zoom);
+ alone.RequestZoom(1);
+
+ var asked = new List();
+ var withClient = new WindowTextSize();
+ withClient.Changed += s => said.Add(-s.Zoom);
+ ((IWindowTextSize)withClient).ZoomRequested += asked.Add;
+ withClient.RequestZoom(-1);
+
+ await Assert.That(alone.Current.Zoom).IsEqualTo(110);
+ await Assert.That(said).IsEquivalentTo(new[] { 110 });
+ await Assert.That(asked).IsEquivalentTo(new[] { -1 });
+ await Assert.That(withClient.Current.Zoom).IsEqualTo(100);
+ }
+}
diff --git a/tests/SharpMUTerm.Core.Tests/Configuration/DisplaySettingsTests.cs b/tests/SharpMUTerm.Core.Tests/Configuration/DisplaySettingsTests.cs
new file mode 100644
index 00000000..d2b63c86
--- /dev/null
+++ b/tests/SharpMUTerm.Core.Tests/Configuration/DisplaySettingsTests.cs
@@ -0,0 +1,55 @@
+using System.Text.Json;
+using SharpMUTerm.Core.Configuration;
+
+namespace SharpMUTerm.Core.Tests.Configuration;
+
+/// The zoom's levels and the text sizes the configuration keeps.
+public class DisplaySettingsTests
+{
+ [Test]
+ public async Task TheZoomStepsThroughTheBrowsersLevelsAndStopsAtTheEnds()
+ {
+ await Assert.That(ZoomLevels.Next(100)).IsEqualTo(110);
+ await Assert.That(ZoomLevels.Previous(100)).IsEqualTo(90);
+ await Assert.That(ZoomLevels.Next(ZoomLevels.Maximum)).IsEqualTo(ZoomLevels.Maximum);
+ await Assert.That(ZoomLevels.Previous(ZoomLevels.Minimum)).IsEqualTo(ZoomLevels.Minimum);
+ await Assert.That(ZoomLevels.Stepped(175, 0)).IsEqualTo(100);
+ }
+
+ [Test]
+ public async Task ALevelBetweenStepsStepsToTheNextOneAcross()
+ {
+ await Assert.That(ZoomLevels.Next(117)).IsEqualTo(125);
+ await Assert.That(ZoomLevels.Previous(117)).IsEqualTo(110);
+ }
+
+ [Test]
+ public async Task AHandEditedFileIsHeldToTheRanges()
+ {
+ var held = new DisplaySettings { Zoom = 5000, OutputPoints = 1, ComposerText = 10, PanelText = 999 }.Clamped();
+
+ await Assert.That(held.Zoom).IsEqualTo(ZoomLevels.Maximum);
+ await Assert.That(held.OutputPoints).IsEqualTo(DisplaySettings.MinOutputPoints);
+ await Assert.That(held.ComposerText).IsEqualTo(ZoomLevels.AreaSteps[0]);
+ await Assert.That(held.PanelText).IsEqualTo(ZoomLevels.AreaSteps[^1]);
+ }
+
+ [Test]
+ public async Task APercentReadsWithOrWithoutItsSign()
+ {
+ await Assert.That(ZoomLevels.Parse("125%")).IsEqualTo(125);
+ await Assert.That(ZoomLevels.Parse(" 90 ")).IsEqualTo(90);
+ await Assert.That(ZoomLevels.Parse("big")).IsNull();
+ await Assert.That(ZoomLevels.Label(125)).IsEqualTo("125%");
+ }
+
+ [Test]
+ public async Task AFileFromBeforeTheSettingReadsAsEverythingAtItsDesignedSize()
+ {
+ var config = JsonSerializer.Deserialize("{}", ConfigurationStore.SerializerOptions)!;
+
+ await Assert.That(config.Display.Zoom).IsEqualTo(100);
+ await Assert.That(config.Display.OutputPoints).IsEqualTo(DisplaySettings.DefaultOutputPoints);
+ await Assert.That(config.Display.DialogText).IsEqualTo(100);
+ }
+}
diff --git a/tests/SharpMUTerm.Tui.Tests/TextSizeTests.cs b/tests/SharpMUTerm.Tui.Tests/TextSizeTests.cs
new file mode 100644
index 00000000..cc804057
--- /dev/null
+++ b/tests/SharpMUTerm.Tui.Tests/TextSizeTests.cs
@@ -0,0 +1,172 @@
+using SharpConsoleUI.Drivers;
+using SharpMUTerm.Client.Commands;
+using SharpMUTerm.Client.Contributions;
+using SharpMUTerm.Client.Help;
+using SharpMUTerm.Client.Settings;
+using SharpMUTerm.Core.Configuration;
+using SharpMUTerm.Graphics;
+
+namespace SharpMUTerm.Tui.Tests;
+
+///
+/// Zoom and per-area text sizes are the client's settings, offered only where the host draws its own text: the window
+/// lists the Text size page and ⌃P's zoom entries, steps and saves the zoom, and is told every change; a terminal lists
+/// none of it and says to use the terminal's own zoom.
+///
+[NotInParallel]
+public class TextSizeTests
+{
+ private static readonly TerminalCapabilities Headless =
+ new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false);
+
+ private sealed class FakeTextSize(bool areas = true) : IWindowTextSize
+ {
+ public List Shown { get; } = [];
+
+ public bool SizesAreas => areas;
+
+ public event Action? ZoomRequested;
+
+ public void Show(DisplaySettings sizes) => Shown.Add(sizes);
+
+ public void Press(int step) => ZoomRequested?.Invoke(step);
+ }
+
+ private sealed class Settings : INativeSettings
+ {
+ public List Shown { get; } = [];
+
+ public void Show(SettingsForm form) => Shown.Add(form);
+
+ public void Close()
+ {
+ }
+ }
+
+ private static SharpMUTermApp App(IWindowTextSize? sizes, INativeSettings? settings = null, AppConfiguration? config = null, List? saves = null)
+ {
+ Console.SetIn(TextReader.Null);
+ var host = ClientHost.ForHarness(Headless, new HeadlessConsoleDriver(120, 40), null, null, null)
+ with { TextSize = sizes, NativeSettings = settings };
+ return new SharpMUTermApp(config ?? new AppConfiguration(), host, save: saves is null ? null : saves.Add);
+ }
+
+ [Test]
+ public async Task ATerminalListsNoTextSizePageAndNoZoom_AndSaysToUseItsOwn()
+ {
+ var app = App(null);
+ var ids = app.BuildCatalog().Select(c => c.Id).ToArray();
+
+ await Assert.That(app.Contributions.SettingsSections.Find(BuiltInSettings.TextSizeSectionId)).IsNull();
+ await Assert.That(ids).DoesNotContain(BuiltInCommands.ZoomInCommand);
+ await Assert.That(ids).DoesNotContain(BuiltInCommands.ZoomOutCommand);
+ await Assert.That(ids).DoesNotContain(BuiltInCommands.ZoomResetCommand);
+ await Assert.That(ids).DoesNotContain(BuiltInCommands.TextSizeCommand);
+
+ await Assert.That(app.StepZoom(1)).IsFalse();
+ await Assert.That(app.LastNoticeModel?.Text ?? "").Contains("terminal's own");
+ await Assert.That(app.Configuration.Display.Zoom).IsEqualTo(100);
+ }
+
+ [Test]
+ public async Task TheWindowListsThePageAndZoomEntries_AndIsToldTheSizesOnce()
+ {
+ var sizes = new FakeTextSize();
+ var app = App(sizes, new Settings());
+
+ await Assert.That(app.Contributions.SettingsSections.Find(BuiltInSettings.TextSizeSectionId)).IsNotNull();
+ var catalog = app.BuildCatalog();
+ await Assert.That(catalog.Single(c => c.Id == BuiltInCommands.ZoomInCommand).Title).IsEqualTo("Zoom in to 110%");
+ await Assert.That(catalog.Single(c => c.Id == BuiltInCommands.ZoomOutCommand).Title).IsEqualTo("Zoom out to 90%");
+ await Assert.That(catalog.Select(c => c.Id)).DoesNotContain(BuiltInCommands.ZoomResetCommand);
+ await Assert.That(catalog.Select(c => c.Id)).Contains(BuiltInCommands.TextSizeCommand);
+
+ app.RenderWholeFrame();
+ app.RenderWholeFrame();
+ await Assert.That(sizes.Shown.Count).IsEqualTo(1);
+ await Assert.That(sizes.Shown[0].Zoom).IsEqualTo(100);
+ }
+
+ [Test]
+ public async Task TheWindowsZoomKeysStepTheZoom_SaveIt_AndTellTheWindow()
+ {
+ var sizes = new FakeTextSize();
+ var saves = new List();
+ var app = App(sizes, new Settings(), saves: saves);
+
+ sizes.Press(1);
+ sizes.Press(1);
+
+ await Assert.That(app.Configuration.Display.Zoom).IsEqualTo(125);
+ await Assert.That(saves.Count).IsGreaterThanOrEqualTo(2);
+ await Assert.That(sizes.Shown[^1].Zoom).IsEqualTo(125);
+ await Assert.That(app.LastNoticeModel?.Text ?? "").Contains("125%");
+
+ await Assert.That(app.BuildCatalog().Single(c => c.Id == BuiltInCommands.ZoomResetCommand).Title).IsEqualTo("Reset zoom (125%)");
+ await Assert.That(app.DispatchCommand(BuiltInCommands.ZoomResetCommand)).IsTrue();
+ await Assert.That(app.Configuration.Display.Zoom).IsEqualTo(100);
+ await Assert.That(sizes.Shown[^1].Zoom).IsEqualTo(100);
+ }
+
+ [Test]
+ public async Task ZoomStopsAtItsEnds()
+ {
+ var config = new AppConfiguration();
+ config.Display.Zoom = ZoomLevels.Maximum;
+ var app = App(new FakeTextSize(), new Settings(), config);
+
+ await Assert.That(app.BuildCatalog().Select(c => c.Id)).DoesNotContain(BuiltInCommands.ZoomInCommand);
+ await Assert.That(app.StepZoom(1)).IsTrue();
+ await Assert.That(app.Configuration.Display.Zoom).IsEqualTo(ZoomLevels.Maximum);
+ await Assert.That(app.LastNoticeModel?.Text ?? "").Contains("largest");
+ }
+
+ [Test]
+ public async Task AnEditOnThePageReachesTheWindowThroughTheSaveFunnel()
+ {
+ var sizes = new FakeTextSize();
+ var app = App(sizes, new Settings());
+ app.RenderWholeFrame();
+ var field = (ChoiceField)BuiltInSettings.TextSize.Fields.Single(f => f.Id == "panel-text");
+
+ ((SettingBinding)field.Binding!).Set(app.Configuration, "150%");
+ app.SaveConfiguration();
+
+ await Assert.That(sizes.Shown[^1].PanelText).IsEqualTo(150);
+ }
+
+ [Test]
+ public async Task ThePalettesEntryOpensThePageInTheWindowsSettings()
+ {
+ var settings = new Settings();
+ var app = App(new FakeTextSize(), settings);
+
+ await Assert.That(app.DispatchCommand(BuiltInCommands.TextSizeCommand)).IsTrue();
+ await Assert.That(settings.Shown.Count).IsEqualTo(1);
+ await Assert.That(settings.Shown[0].Initial).IsEqualTo(BuiltInSettings.TextSizeSectionId);
+ }
+
+ [Test]
+ public async Task EmulatedModeKeepsTheZoomButHasNoPage()
+ {
+ var sizes = new FakeTextSize(areas: false);
+ var app = App(sizes, null);
+
+ await Assert.That(app.Contributions.SettingsSections.Find(BuiltInSettings.TextSizeSectionId)).IsNull();
+ var ids = app.BuildCatalog().Select(c => c.Id).ToArray();
+ await Assert.That(ids).Contains(BuiltInCommands.ZoomInCommand);
+ await Assert.That(ids).DoesNotContain(BuiltInCommands.TextSizeCommand);
+ await Assert.That(app.StepZoom(-1)).IsTrue();
+ await Assert.That(app.Configuration.Display.Zoom).IsEqualTo(90);
+ }
+
+ [Test]
+ public async Task HelpSaysTheTerminalsTextIsItsOwn()
+ {
+ var topic = HelpTopics.For("⌥⏎").Single(t => t.Id == "text-size");
+ var text = string.Join(" ", topic.Lines);
+
+ await Assert.That(text).Contains("terminal's own");
+ await Assert.That(text).Contains("⌃=");
+ }
+}