diff --git a/src/SharpClient.App/MainPage.xaml.cs b/src/SharpClient.App/MainPage.xaml.cs index 06d6dc0..e268359 100644 --- a/src/SharpClient.App/MainPage.xaml.cs +++ b/src/SharpClient.App/MainPage.xaml.cs @@ -10,7 +10,21 @@ public MainPage() // status-bar/cutout insets and the soft-keyboard height reach the HTML layer even on Android // System WebViews too old for CSS env()/visualViewport (the device was on WebView 133). blazorWebView.BlazorWebViewInitialized += (_, e) => + { SharpClient.App.Platforms.Android.WebViewInsetsBridge.Attach(e.WebView); + + // Android WebView clamps CSS font-size to a MinimumFontSize (default 8px) and + // MinimumLogicalFontSize (default 8px). The terminal fits the font down to a few px to pack + // a high "Min columns" onto a narrow screen; the clamp silently rendered those at 8px, so + // e.g. 90 columns never fit and NAWS stayed pinned at ~78. Drop the floor so the fitted size + // is actually honoured. (measureGrid still measures the real rendered advance and reports + // only the columns that fit, so nothing wraps if a value is genuinely too large.) + if (e.WebView.Settings is { } settings) + { + settings.MinimumFontSize = 1; + settings.MinimumLogicalFontSize = 1; + } + }; #endif } } diff --git a/src/SharpClient.UI/Components/SessionScreen.razor b/src/SharpClient.UI/Components/SessionScreen.razor index 10503bc..2425b30 100644 --- a/src/SharpClient.UI/Components/SessionScreen.razor +++ b/src/SharpClient.UI/Components/SessionScreen.razor @@ -1,3 +1,4 @@ +@using Microsoft.Extensions.Logging @using Microsoft.JSInterop @using SharpClient.Core.Connection @using SharpClient.Core.Presentation @@ -5,6 +6,7 @@ @inject SettingsViewModel Settings @inject IJSRuntime JS +@inject ILogger Logger @implements IAsyncDisposable @@ -110,7 +112,24 @@ // which had wrapped an advertised 78 columns ~2 short. measureGrid applies --out-fs (fitted // size) and --sc-cols (column track width) on the element. var cap = Math.Max(6, Settings.MaxFontSize); - var grid = await _interop.InvokeAsync("measureGrid", _outputRef, Settings.MinColumns, 6, cap); + // Min font 4px (not 6): a high MinColumns on a narrow phone needs a small font to fit — e.g. 90 + // columns in ~376px needs ~5.9px. Flooring at 6px stopped short, so 90 wrapped. measureGrid + // reports the honest fitted column count, so if even 4px can't fit MinColumns it advertises what + // does fit instead of over-advertising. + const int minFont = 4; + var grid = await _interop.InvokeAsync("measureGrid", _outputRef, Settings.MinColumns, minFont, cap); + + // Diagnostics for column/wrap issues — captured to the on-device log (FileLoggerProvider) so it + // can be exported. actualCols < targetCols means the advertised width can't be shown and lines + // wrap; compare fontPx against the 6px floor and contentW against clientW (scrollbar/inset loss). + Logger.LogInformation( + "NAWS fit: minCols(setting)={MinColumns} maxFont(cap)={Cap} probeW={ProbeWidth} probeH={ProbeHeight} " + + "clientW={ClientW} contentW={ContentW} padX={PadX} advanceRatio={AdvanceRatio} fontPx={FontPx} " + + "targetCols={TargetCols} actualColsThatFit={ActualCols} reportedCols(NAWS)={Cols} rows={Rows} vScroll={VScroll} fits={Fits}", + Settings.MinColumns, cap, widthPx, heightPx, + grid.ClientW, grid.ContentW, grid.PadX, grid.AdvanceRatio, grid.FontPx, + grid.TargetCols, grid.ActualCols, grid.Cols, grid.Rows, grid.VScroll, + grid.ActualCols >= grid.TargetCols); if (Vm.Active is not null) { @@ -144,5 +163,15 @@ private sealed record SizeDims(int Width, int Height); - private sealed record GridDims(int Cols, int Rows); + private sealed record GridDims( + int Cols, + int Rows, + double FontPx, + double ClientW, + double ContentW, + double PadX, + double AdvanceRatio, + int TargetCols, + int ActualCols, + bool VScroll); } diff --git a/src/SharpClient.UI/Components/SettingsView.razor b/src/SharpClient.UI/Components/SettingsView.razor index 6bd950b..3523f13 100644 --- a/src/SharpClient.UI/Components/SettingsView.razor +++ b/src/SharpClient.UI/Components/SettingsView.razor @@ -28,12 +28,15 @@
Layout
Min columns -
- + + - @Vm.MinColumns + @onchange="OnMinColumnsInput" /> +
@@ -103,6 +106,22 @@ private bool _exporting; + // Typeable/stepped bounds for Min columns. Wider than the old 60–120 slider so unusual widths + // are reachable; the value is clamped on every change. + private const int MinColumnsMin = 20; + private const int MinColumnsMax = 500; + + private void AdjustMinColumns(int delta) => + Vm.MinColumns = Math.Clamp(Vm.MinColumns + delta, MinColumnsMin, MinColumnsMax); + + private void OnMinColumnsInput(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out var value)) + { + Vm.MinColumns = Math.Clamp(value, MinColumnsMin, MinColumnsMax); + } + } + protected override void OnInitialized() => Vm.Changed += OnChanged; private void OnChanged() => InvokeAsync(StateHasChanged); diff --git a/src/SharpClient.UI/wwwroot/app.css b/src/SharpClient.UI/wwwroot/app.css index f0e0518..d4dad0f 100644 --- a/src/SharpClient.UI/wwwroot/app.css +++ b/src/SharpClient.UI/wwwroot/app.css @@ -754,6 +754,14 @@ body { .sc-slider-row { display: flex; align-items: center; gap: 10px; } .sc-slider { flex: 1; accent-color: var(--acc); cursor: pointer; } +/* Stepper (− value +) — easier to set an exact value than a slider */ +.sc-stepper { display: inline-flex; align-items: stretch; gap: 0; border: 1px solid var(--bd2); border-radius: 9px; overflow: hidden; background: var(--elev); } +.sc-stepper-btn { width: 38px; border: none; background: transparent; color: var(--acc2); font-family: var(--ui); font-size: 20px; line-height: 1; cursor: pointer; display: flex; align-items: center; justify-content: center; } +.sc-stepper-btn:active { background: var(--acc-soft); } +.sc-stepper-input { width: 56px; text-align: center; border: none; border-left: 1px solid var(--bd2); border-right: 1px solid var(--bd2); background: var(--panel); color: var(--tx); font-family: var(--mono); font-size: 14px; padding: 8px 4px; -moz-appearance: textfield; appearance: textfield; } +.sc-stepper-input::-webkit-outer-spin-button, +.sc-stepper-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } + /* Toggle */ .sc-toggle { width: 36px; height: 20px; appearance: none; background: var(--elev); border-radius: 10px; position: relative; cursor: pointer; border: 1px solid var(--bd2); transition: background .15s; } .sc-toggle::before { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--dim); transition: transform .15s, background .15s; } diff --git a/src/SharpClient.UI/wwwroot/sc-interop.js b/src/SharpClient.UI/wwwroot/sc-interop.js index 83e381e..e746477 100644 --- a/src/SharpClient.UI/wwwroot/sc-interop.js +++ b/src/SharpClient.UI/wwwroot/sc-interop.js @@ -69,8 +69,9 @@ export function observeResize(dotNetRef, element) { * sub-pixel rounding), never derived from a hard-coded 0.6em — that guess made an advertised 78 * columns wrap ~2 short. A closed loop corrects for glyph-advance non-linearity (hinting): after the * ideal size is computed it re-measures `targetCols` chars and shrinks if they overflow. Font growth - * is capped at `maxFont` (line-length cap; content left-aligns), floored at `minFont` (below that the - * caller's --sc-cols track scrolls horizontally rather than rendering illegibly small). + * is capped at `maxFont` (line-length cap; content left-aligns), floored at `minFont`. If even at + * `minFont` the targetCols don't fit, the RETURNED column count is the number that actually fits (not + * targetCols), so NAWS never advertises more than is visible and lines don't wrap on the client. * * Ported from SharpMUSH.Client's terminalMetrics.js so the two clients agree on column math. * Applies the fitted size as --out-fs and the column count as --sc-cols on the element, and returns @@ -139,7 +140,6 @@ export function measureGrid(element, targetCols, minFont, maxFont) { const contentH = element.clientHeight - padY; let fontPx; - let cols; if (targetCols > 0) { const targetW = contentW - 1; // a hair inside the box (avoid a sub-pixel scrollbar) let fit = targetW / targetCols / advanceRatio; @@ -158,16 +158,39 @@ export function measureGrid(element, targetCols, minFont, maxFont) { fit = Math.max(minF, fit * (targetW / actual)); } fontPx = fit; - cols = targetCols; // honour the preferred width; the --sc-cols track scrolls if it overflows } else { fontPx = parseFloat(cs.getPropertyValue('--out-fs')) || 14; - cols = clampGrid(Math.floor(contentW / (advanceRatio * fontPx))); } + // Columns that ACTUALLY fit — measured with the REAL glyph advance at the fitted size, NOT the + // linear advanceRatio (sampled at 100px). Glyph advance is proportionally larger at tiny sizes + // (hinting/rounding), so the linear value over-counts near the min-font floor: it once reported 104 + // columns "fit" at 6px when only ~78 truly did, so an advertised 90 wrapped. Reporting the honest + // count means NAWS never advertises more than is visible, so the server wraps to fit and the client + // never wraps a second time. + const realAdvance = runWidth(fontPx, '0'.repeat(100)) / 100; + const fitCols = realAdvance > 0 ? Math.floor(contentW / realAdvance) : 1; + const cols = targetCols > 0 + ? Math.min(targetCols, Math.max(1, fitCols)) + : clampGrid(fitCols); + const vscroll = element.scrollHeight > element.clientHeight; + element.style.setProperty('--out-fs', fontPx + 'px'); element.style.setProperty('--sc-cols', String(cols)); - return { cols, rows: clampGrid(Math.floor(contentH / (lineRatio * fontPx))) }; + return { + cols, + rows: clampGrid(Math.floor(contentH / (lineRatio * fontPx))), + // diagnostics (see SessionScreen logging) + fontPx: Math.round(fontPx * 100) / 100, + clientW: element.clientWidth, + contentW, + padX, + advanceRatio: Math.round(advanceRatio * 10000) / 10000, + targetCols, + actualCols: fitCols, + vscroll, + }; } /** diff --git a/tests/SharpClient.UI.Tests/SettingsViewTests.cs b/tests/SharpClient.UI.Tests/SettingsViewTests.cs index 0c958b2..0ac4430 100644 --- a/tests/SharpClient.UI.Tests/SettingsViewTests.cs +++ b/tests/SharpClient.UI.Tests/SettingsViewTests.cs @@ -100,7 +100,7 @@ public async Task ClickingFontOptionUpdatesVmFont() } [Test] - public async Task MinColumnsSliderRendersWithCorrectValue() + public async Task MinColumnsStepperRendersWithCorrectValue() { using var ctx = NewContext(); var vm = MakeVm(); @@ -108,9 +108,26 @@ public async Task MinColumnsSliderRendersWithCorrectValue() var cut = ctx.Render(p => p.Add(c => c.Vm, vm)); - // The slider for MinColumns should have value="90" - var slider = cut.Find("input[type='range'][min='60']"); - await Assert.That(slider.GetAttribute("value")).IsEqualTo("90"); + // Min columns is now a typeable number stepper (not a slider). + var input = cut.Find("input.sc-stepper-input"); + await Assert.That(input.GetAttribute("value")).IsEqualTo("90"); + } + + [Test] + public async Task MinColumnsStepperButtonsAdjustValue() + { + using var ctx = NewContext(); + var vm = MakeVm(); + vm.MinColumns = 90; + + var cut = ctx.Render(p => p.Add(c => c.Vm, vm)); + + var buttons = cut.FindAll(".sc-stepper-btn"); + buttons[1].Click(); // "+" + await Assert.That(vm.MinColumns).IsEqualTo(91); + buttons[0].Click(); // "−" + buttons[0].Click(); + await Assert.That(vm.MinColumns).IsEqualTo(89); } [Test]