diff --git a/src/SharpClient.UI/Components/SessionScreen.razor b/src/SharpClient.UI/Components/SessionScreen.razor index 50314cf..a52f198 100644 --- a/src/SharpClient.UI/Components/SessionScreen.razor +++ b/src/SharpClient.UI/Components/SessionScreen.razor @@ -108,23 +108,18 @@ return; } - var minCols = Settings.MinColumns; - // Largest font that still fits at least MinColumns columns, but never above - // the user's max-font setting and never below 6px. + // Measure the real character grid and fit the font so MinColumns span the width, then report + // the resulting grid over NAWS. The measurement (advance + line-height from probes, closed-loop + // fit for hinting non-linearity, padding-aware) lives in measureGrid, ported from + // SharpMUSH.Client's terminalMetrics.js so both clients agree — no hard-coded 0.6em advance, + // 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 rawFontPx = (int)Math.Floor(widthPx / (minCols * 0.6)); - var fontPx = Math.Clamp(rawFontPx, 6, cap); - - // Apply on the output element directly so it wins over the layout-level - // accent/font CSS variables. - await _interop.InvokeVoidAsync("setFontSize", _outputRef, fontPx); + var grid = await _interop.InvokeAsync("measureGrid", _outputRef, Settings.MinColumns, 6, cap); if (Vm.Active is not null) { - // Advertise the ACTUAL columns/rows that fit at this size (>= MinColumns). - var cols = Math.Max(minCols, (int)Math.Floor(widthPx / (fontPx * 0.6))); - var rows = heightPx > 0 ? (int)Math.Floor(heightPx / (fontPx * 1.2)) : 24; - await Vm.Active.SendWindowSizeAsync(cols, rows); + await Vm.Active.SendWindowSizeAsync(grid.Cols, grid.Rows); } } @@ -171,4 +166,6 @@ } private sealed record SizeDims(int Width, int Height); + + private sealed record GridDims(int Cols, int Rows); } diff --git a/src/SharpClient.UI/wwwroot/app.css b/src/SharpClient.UI/wwwroot/app.css index a40a1e6..fe8ac03 100644 --- a/src/SharpClient.UI/wwwroot/app.css +++ b/src/SharpClient.UI/wwwroot/app.css @@ -74,6 +74,12 @@ body { /* ── OutputView component classes (.sc-output / .sc-line) ──────── */ .sc-output { display: block; + /* Hold a floor of --sc-cols character columns (set by measureGrid alongside --out-fs). On a + screen too narrow to fit them at the minimum font, the output scrolls horizontally instead of + wrapping the server's NAWS-width lines — so what the player sees matches what the server wrapped + to. font-size must equal the line font so 1ch is the real column width. */ + font-size: var(--out-fs); + min-width: calc(var(--sc-cols, 0) * 1ch); } .sc-line { @@ -379,6 +385,9 @@ body { .sc-output-area { flex: 1; overflow-y: auto; + /* Horizontal scroll when the --sc-cols track is wider than the viewport (narrow screen at min + font), so NAWS-width lines are shown intact rather than wrapped. */ + overflow-x: auto; background: var(--outbg); padding: 0.75rem 1rem; } diff --git a/src/SharpClient.UI/wwwroot/sc-interop.js b/src/SharpClient.UI/wwwroot/sc-interop.js index 986fa76..7d2ca49 100644 --- a/src/SharpClient.UI/wwwroot/sc-interop.js +++ b/src/SharpClient.UI/wwwroot/sc-interop.js @@ -29,20 +29,124 @@ export function observeResize(dotNetRef, element) { observer.observe(element); _observers.set(element, observer); - return { width: Math.floor(element.clientWidth), height: Math.floor(element.clientHeight) }; + // Return the *content-box* size (padding excluded) to match what the ResizeObserver reports via + // contentRect, so the initial NAWS/font calc and subsequent resizes use the same basis. + const cs = getComputedStyle(element); + const padX = parseFloat(cs.paddingLeft || '0') + parseFloat(cs.paddingRight || '0'); + const padY = parseFloat(cs.paddingTop || '0') + parseFloat(cs.paddingBottom || '0'); + return { + width: Math.floor(element.clientWidth - padX), + height: Math.floor(element.clientHeight - padY), + }; } /** - * Sets the --out-fs CSS variable on the element itself. Because this element is a - * closer ancestor of the output lines than .sc-shell, its --out-fs wins the cascade - * over the layout-level value (which is derived from MaxFontSize). - * @param {HTMLElement} element - * @param {number} px + * Measure the monospace character grid for the output element and SIZE THE FONT so exactly + * `targetCols` columns span the available width — then report the resulting {cols, rows} for NAWS. + * + * The advance and line-height are MEASURED from hidden probes (a 200-char run averages out + * 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). + * + * 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 + * { cols, rows }. + * + * @param {HTMLElement} element the scrollable output element (padding is excluded) + * @param {number} targetCols preferred column width (e.g. MinColumns 78); 0 = natural grid + * @param {number} minFont minimum font px + * @param {number} maxFont maximum font px + * @returns {{cols:number, rows:number}} */ -export function setFontSize(element, px) { - if (element) { - element.style.setProperty('--out-fs', px + 'px'); +export function measureGrid(element, targetCols, minFont, maxFont) { + const minF = minFont > 0 ? minFont : 6; + const maxF = Math.max(minF, maxFont > 0 ? maxFont : 24); + const fallback = { cols: targetCols > 0 ? targetCols : 80, rows: 24 }; + if (!element) { + return fallback; + } + + const cs = getComputedStyle(element); + const family = (cs.getPropertyValue('--mono') || '').trim() || 'monospace'; + const letter = cs.letterSpacing; + const feature = cs.fontFeatureSettings; + const lineHeightCss = cs.lineHeight && cs.lineHeight !== 'normal' ? cs.lineHeight : '1.2'; + const clampGrid = v => (v < 1 ? 1 : (v > 1000 ? 1000 : v)); + + const runWidth = (fontPx, text) => { + const p = document.createElement('span'); + p.style.cssText = 'position:absolute;visibility:hidden;white-space:pre;left:-9999px;top:0'; + p.style.fontFamily = family; + p.style.fontSize = fontPx + 'px'; + p.style.letterSpacing = letter; + p.style.fontFeatureSettings = feature; + p.textContent = text; + element.appendChild(p); + const w = p.getBoundingClientRect().width; + p.remove(); + return w; + }; + + const REF = 100; + const advanceRatio = runWidth(REF, '0'.repeat(200)) / 200 / REF; + if (!(advanceRatio > 0)) { + return fallback; } + + let lineRatio = 1.2; + { + const p = document.createElement('span'); + p.style.cssText = 'position:absolute;visibility:hidden;white-space:pre;left:-9999px;top:0'; + p.style.fontFamily = family; + p.style.fontSize = REF + 'px'; + p.style.lineHeight = lineHeightCss; + p.textContent = '0\n0\n0\n0\n0'; + element.appendChild(p); + const h = p.getBoundingClientRect().height / 5; + p.remove(); + if (h > 0) { + lineRatio = h / REF; + } + } + + const padX = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0); + const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0); + const contentW = element.clientWidth - padX; + 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; + if (fit > maxF) { + fit = maxF; + } + for (let i = 0; i < 4; i++) { + if (fit <= minF) { + fit = minF; + break; + } + const actual = runWidth(fit, '0'.repeat(targetCols)); + if (actual <= targetW) { + break; + } + 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))); + } + + element.style.setProperty('--out-fs', fontPx + 'px'); + element.style.setProperty('--sc-cols', String(cols)); + + return { cols, rows: clampGrid(Math.floor(contentH / (lineRatio * fontPx))) }; } /**