From 936b3f2dd6f7d5d51a10f232daccd40c43149af0 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Tue, 30 Jun 2026 20:32:36 -0500 Subject: [PATCH 1/3] Width setting: number stepper + NAWS fit diagnostics logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Min columns is now a typeable −/value/+ stepper instead of a slider — far easier to set an exact width on a phone. Range widened to 20–500 (clamped on every change). Max text size stays a slider. 2. Added diagnostic logging around the column-fit math so wrap issues can be traced from the exported log. measureGrid now returns the values it bases its decision on (clientW, contentW, padX, advanceRatio, fitted fontPx, targetCols, and actualColsThatFit — the count that genuinely fits at the fitted font), and SessionScreen logs them at Information (captured by FileLoggerProvider -> the on-device log). When actualColsThatFit < targetCols, the advertised width can't be shown and lines wrap — now visible in the log with fontPx (vs the 6px floor) and contentW (vs clientW, showing scrollbar/inset loss). Verified: Web + Android build clean; UI bUnit 47/47; stepper works in the Web preview (buttons adjust, old slider gone); the "NAWS fit: ..." line is emitted. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HHCRc5CJ6595iMzEgYYdQp --- .../Components/SessionScreen.razor | 26 ++++++++++++++++- .../Components/SettingsView.razor | 29 +++++++++++++++---- src/SharpClient.UI/wwwroot/app.css | 8 +++++ src/SharpClient.UI/wwwroot/sc-interop.js | 21 +++++++++++++- .../SharpClient.UI.Tests/SettingsViewTests.cs | 25 +++++++++++++--- 5 files changed, 98 insertions(+), 11 deletions(-) diff --git a/src/SharpClient.UI/Components/SessionScreen.razor b/src/SharpClient.UI/Components/SessionScreen.razor index 10503bc..99aff98 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 @@ -112,6 +114,18 @@ var cap = Math.Max(6, Settings.MaxFontSize); var grid = await _interop.InvokeAsync("measureGrid", _outputRef, Settings.MinColumns, 6, 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) { await Vm.Active.SendWindowSizeAsync(grid.Cols, grid.Rows); @@ -144,5 +158,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..9b0cc52 100644 --- a/src/SharpClient.UI/wwwroot/sc-interop.js +++ b/src/SharpClient.UI/wwwroot/sc-interop.js @@ -167,7 +167,26 @@ export function measureGrid(element, targetCols, minFont, maxFont) { element.style.setProperty('--out-fs', fontPx + 'px'); element.style.setProperty('--sc-cols', String(cols)); - return { cols, rows: clampGrid(Math.floor(contentH / (lineRatio * fontPx))) }; + // Columns that ACTUALLY fit at the fitted font in the current content box. If this is less than + // targetCols, the advertised width can't be shown and lines will wrap — the diagnostic the caller + // logs so wrap issues can be traced from the exported log. + const advancePx = advanceRatio * fontPx; + const actualCols = advancePx > 0 ? Math.floor(contentW / advancePx) : 0; + const vscroll = element.scrollHeight > element.clientHeight; + + 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, + 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] From 328892a59b6a2017cb69fa4ab5e48a683cd25da3 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Tue, 30 Jun 2026 20:48:03 -0500 Subject: [PATCH 2/3] Honor high MinColumns: lower font floor + report true fit (fixes 90 wrap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnosis from the exported device log: at MinColumns=90 on a 384px screen (contentW=376), the font floored at 6px and 90 still wrapped, yet the log said actualColsThatFit=104/fits=True. Two bugs: 1. The min-font floor was 6px. Fitting 90 columns in 376px needs ~5.9–6.9px; the closed-loop fit was clamped at 6px and stopped short, so 90 didn't fit and wrapped. Lowered the floor to 4px (passed from SessionScreen) so the font can shrink enough to honor a high MinColumns on a narrow screen. 2. actualColsThatFit was computed with the LINEAR advanceRatio (sampled at 100px). Glyph advance is proportionally larger at tiny sizes (hinting), so it over-counted (104 at 6px when ~78 truly fit) — which is why the diagnostic wrongly said it fit. measureGrid now measures the REAL advance at the fitted size and reports the honest column count; the returned cols (NAWS) is min(targetCols, whatActuallyFits), so it never advertises more than is visible and the client never double-wraps. Verified at ~device width: MinColumns 90 now fits at ~6.7px with no wrap (actualCols 90); 78 stays comfortable at 7.7px; an extreme 200 floors at 4px and honestly reports 150 (server wraps to that, no client wrap). Web + Android build clean; UI bUnit 47/47. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HHCRc5CJ6595iMzEgYYdQp --- .../Components/SessionScreen.razor | 7 ++++- src/SharpClient.UI/wwwroot/sc-interop.js | 30 +++++++++++-------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/SharpClient.UI/Components/SessionScreen.razor b/src/SharpClient.UI/Components/SessionScreen.razor index 99aff98..2425b30 100644 --- a/src/SharpClient.UI/Components/SessionScreen.razor +++ b/src/SharpClient.UI/Components/SessionScreen.razor @@ -112,7 +112,12 @@ // 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 diff --git a/src/SharpClient.UI/wwwroot/sc-interop.js b/src/SharpClient.UI/wwwroot/sc-interop.js index 9b0cc52..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,22 +158,26 @@ 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)); - // Columns that ACTUALLY fit at the fitted font in the current content box. If this is less than - // targetCols, the advertised width can't be shown and lines will wrap — the diagnostic the caller - // logs so wrap issues can be traced from the exported log. - const advancePx = advanceRatio * fontPx; - const actualCols = advancePx > 0 ? Math.floor(contentW / advancePx) : 0; - const vscroll = element.scrollHeight > element.clientHeight; - return { cols, rows: clampGrid(Math.floor(contentH / (lineRatio * fontPx))), @@ -184,7 +188,7 @@ export function measureGrid(element, targetCols, minFont, maxFont) { padX, advanceRatio: Math.round(advanceRatio * 10000) / 10000, targetCols, - actualCols, + actualCols: fitCols, vscroll, }; } From 6a2b6303c220c976aba24960a57e1c5648f8eb15 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Tue, 30 Jun 2026 21:22:40 -0500 Subject: [PATCH 3/3] Disable Android WebView minimum font size so high MinColumns is honored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the honest-column reporting: on device, NAWS was pinned at ~78 for any Min columns >= ~79. Root cause is the Android WebView MinimumFontSize / MinimumLogicalFontSize (both default 8px): the terminal fits the font down to a few px to pack e.g. 90 columns onto a 376px-wide screen, but the WebView clamped that to 8px, so only ~78 columns actually fit — and measureGrid (now honest) correctly reported ~78, hence the pin. Drop both floors to 1px on the BlazorWebView's Android WebView (next to the insets bridge). The fitted sub-8px size now renders for real, so 90 columns fit (~6.5px) and NAWS reports 90. measureGrid still measures the real rendered advance and reports only what fits, so a genuinely-too-large value degrades gracefully instead of wrapping. Android-only change; builds clean. The existing "NAWS fit: …" log will confirm on the next export: with the clamp gone, fontPx drops below 8 and actualColsThatFit rises to the requested value. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HHCRc5CJ6595iMzEgYYdQp --- src/SharpClient.App/MainPage.xaml.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 } }