Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions src/SharpMUTerm.Avalonia/AvaloniaConsoleDriver.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using SharpMUTerm.Core.Configuration;
using Color = SharpConsoleUI.Color;
using Point = System.Drawing.Point;
using SharpConsoleUI;
Expand Down Expand Up @@ -96,6 +97,9 @@ public AvaloniaConsoleDriver(int columns, int rows)
/// <summary>F7's background transparency, which the main window draws (<see cref="WindowTranslucency"/>).</summary>
public WindowTranslucency Translucency { get; } = new();

/// <summary>The Text size page's sizes and the zoom, which the main window draws at (<see cref="WindowTextSize"/>).</summary>
public WindowTextSize TextSize { get; } = new();

/// <summary>The window's alerts while it is behind another (<see cref="WindowAttention"/>).</summary>
public WindowAttention Attention { get; } = new();

Expand Down Expand Up @@ -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);
}

/// <summary>
/// The window's half of <see cref="IWindowTextSize"/>: 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 <see cref="RequestZoom"/>. With no client listening (a test of the view alone) a request steps
/// <see cref="Current"/> here, so the keys still zoom.
/// </summary>
internal sealed class WindowTextSize : IWindowTextSize
{
private DisplaySettings _current = new();

/// <summary>The sizes in force: the configuration's until the client says otherwise.</summary>
public DisplaySettings Current => Volatile.Read(ref _current);

/// <summary>Whether the window draws native views whose sizes the page's areas set; false in <c>--emulated</c>.</summary>
public bool SizesAreas { get; set; } = true;

/// <summary>Raised with each new set of sizes, on whichever thread said them.</summary>
public event Action<DisplaySettings>? Changed;

/// <inheritdoc/>
public event Action<int>? ZoomRequested;

/// <summary>Starts from the configuration's sizes, before the client exists, so the first frame is drawn at them.</summary>
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);
}

/// <summary>
/// The window's own zoom keys or wheel: in for a positive <paramref name="step"/>, out for a negative one, 100% for
/// zero. The client steps, saves and says the result; with none, the step is taken here.
/// </summary>
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);
}
}

/// <summary>
/// The window's half of <see cref="IWindowTitle"/>: the client hands it <c>Character@World — SharpMUTerm</c> on its own
/// thread, and the main window, which subscribes, puts it on the title bar and the taskbar entry on Avalonia's.
Expand Down
4 changes: 4 additions & 0 deletions src/SharpMUTerm.Avalonia/ComposerPresenter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ public void Attach(Panel root, Control below, TerminalView view, Action refocus)
_attached = true;
}

/// <summary>The window's text sizes, read when F1 opens (MainWindow sets it; designed sizes until then).</summary>
public Func<NativeScales> 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);
Expand All @@ -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);
Expand Down
37 changes: 34 additions & 3 deletions src/SharpMUTerm.Avalonia/ComposerView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,25 @@ private void Paint()
_marks.InvalidateVisual();
}

/// <summary>
/// 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).
/// </summary>
internal double TextScale
{
get => _textScale;
set
{
if (Math.Abs(value - _textScale) > 0.0005)
{
_textScale = value;
ApplyFont();
}
}
}

private double _textScale = 1;

/// <summary>Refreshes an open composer after zoom without changing its selected face.</summary>
private void RefreshFontMetrics()
{
Expand All @@ -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 })
{
Expand All @@ -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;
Expand All @@ -571,6 +593,8 @@ private void ApplyFont()
}
}

private static double ZoomOf(TerminalView view) => view.ZoomPercent / 100.0;

/// <summary>Where line <paramref name="line"/> (from 1) of <paramref name="text"/> starts; the end when it has fewer.</summary>
internal static int LineStart(string text, int line)
{
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading