From 3154389ac02cfcb420f853d43ed123864670ebf2 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Tue, 30 Jun 2026 17:24:09 -0500 Subject: [PATCH 1/3] fix: device UI crashes (scrollback race, MSDP/FSharp.Core, insets/keyboard) + B-polish Device-reported fixes - Scrollback race crash: Session.Scrollback handed out the live List which the telnet read thread mutated mid-enumeration on the Blazor render thread ("Collection was modified" -> dead UI). The getter now returns an immutable ToArray() snapshot under a lock guarding both append sites. Regression test: ScrollbackConcurrencyTests (bounded/deterministic). - MSDP native crash: the server's MSDP negotiation crashed Mono with a SIGSEGV because TelnetNegotiationCore < 2.5.1 shipped the F# MSDPLibrary but never declared FSharp.Core, so it was missing from the APK. Bumped TNC to 2.5.1 (which now declares it); FSharp.Core now flows in transitively and is packaged. - Status-bar inset + soft keyboard: pure CSS env()/visualViewport can't work on the device's Android System WebView 133 (needs WebView >= 136/139/144). Added a native WindowInsets -> CSS-variable bridge (Platforms/Android/WebViewInsetsBridge.cs, wired from MainPage on BlazorWebViewInitialized) that pushes --sc-safe-* and --sc-keyboard-height; MainActivity sets adjustResize; app.css consumes them (max(env, var) padding, height minus keyboard). B-polish - Protocol-toggle button got a real <> SVG icon (was literal "{ }"); session header styled with a connection state-pill; ProtocolPanel fully styled (was 0 CSS). - History-search UI: HistorySearchViewModel + HistorySearchView + /history page + 4th bottom-nav entry, wired to the existing FTS5 ISessionHistory.SearchAsync, character names resolved via IWorldStore; registered in both hosts. - Web preferences are now file-backed (App_Data/preferences.json) so they survive reload; WebSecretStore documented as deliberately in-memory (no plaintext creds on disk). All test suites pass (184 TUnit + 46 bUnit + 17 Data); Android head and Web host build 0-warning. On-device inset/keyboard behavior needs verification after install. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HHCRc5CJ6595iMzEgYYdQp --- src/SharpClient.App/MainPage.xaml.cs | 7 + src/SharpClient.App/MauiProgram.cs | 4 + .../Platforms/Android/MainActivity.cs | 8 + .../Platforms/Android/WebViewInsetsBridge.cs | 77 +++++++ src/SharpClient.App/SharpClient.App.csproj | 6 +- .../Presentation/HistorySearchViewModel.cs | 106 +++++++++ src/SharpClient.Core/Sessions/Session.cs | 30 ++- src/SharpClient.Core/SharpClient.Core.csproj | 4 +- .../Components/HistorySearchView.razor | 66 ++++++ .../Components/SessionScreen.razor | 43 +++- src/SharpClient.UI/Layout/MainLayout.razor | 23 ++ src/SharpClient.UI/Pages/HistoryPage.razor | 7 + src/SharpClient.UI/wwwroot/app.css | 211 +++++++++++++++++- src/SharpClient.UI/wwwroot/sc-interop.js | 28 +++ src/SharpClient.Web/Program.cs | 4 + src/SharpClient.Web/SharpClient.Web.csproj | 2 +- src/SharpClient.Web/WebPreferences.cs | 103 ++++++++- src/SharpClient.Web/WebSecretStore.cs | 9 + .../Sessions/ScrollbackConcurrencyTests.cs | 78 +++++++ 19 files changed, 792 insertions(+), 24 deletions(-) create mode 100644 src/SharpClient.App/Platforms/Android/WebViewInsetsBridge.cs create mode 100644 src/SharpClient.Core/Presentation/HistorySearchViewModel.cs create mode 100644 src/SharpClient.UI/Components/HistorySearchView.razor create mode 100644 src/SharpClient.UI/Pages/HistoryPage.razor create mode 100644 tests/SharpClient.Tests/Sessions/ScrollbackConcurrencyTests.cs diff --git a/src/SharpClient.App/MainPage.xaml.cs b/src/SharpClient.App/MainPage.xaml.cs index 0f03501..06d6dc0 100644 --- a/src/SharpClient.App/MainPage.xaml.cs +++ b/src/SharpClient.App/MainPage.xaml.cs @@ -5,5 +5,12 @@ public partial class MainPage : ContentPage public MainPage() { InitializeComponent(); +#if ANDROID + // Wire the native WindowInsets -> CSS-variable bridge once the platform WebView exists, so + // 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); +#endif } } diff --git a/src/SharpClient.App/MauiProgram.cs b/src/SharpClient.App/MauiProgram.cs index d5ddc7e..954cf98 100644 --- a/src/SharpClient.App/MauiProgram.cs +++ b/src/SharpClient.App/MauiProgram.cs @@ -93,6 +93,10 @@ public static MauiApp CreateMauiApp() sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService())); + builder.Services.AddTransient(sp => + new HistorySearchViewModel( + sp.GetRequiredService(), + sp.GetRequiredService())); // ── Session launcher (real telnet) ──────────────────────────────── builder.Services.AddTransient(); diff --git a/src/SharpClient.App/Platforms/Android/MainActivity.cs b/src/SharpClient.App/Platforms/Android/MainActivity.cs index 58ffd89..9958b63 100644 --- a/src/SharpClient.App/Platforms/Android/MainActivity.cs +++ b/src/SharpClient.App/Platforms/Android/MainActivity.cs @@ -1,6 +1,7 @@ using Android.App; using Android.Content.PM; using Android.OS; +using Android.Views; namespace SharpClient.App; @@ -12,6 +13,13 @@ public class MainActivity : MauiAppCompatActivity protected override void OnCreate(Bundle? savedInstanceState) { base.OnCreate(savedInstanceState); + + // adjustResize is the prerequisite for keyboard handling. On Android 15+ edge-to-edge it no + // longer resizes the window by itself (the system expects us to consume the IME inset), which + // WebViewInsetsBridge does via setOnApplyWindowInsetsListener(Type.ime()); we still set it so + // pre-15 devices resize normally. + Window?.SetSoftInputMode(SoftInput.AdjustResize); + RequestPostNotificationsIfNeeded(); } diff --git a/src/SharpClient.App/Platforms/Android/WebViewInsetsBridge.cs b/src/SharpClient.App/Platforms/Android/WebViewInsetsBridge.cs new file mode 100644 index 0000000..a80f1aa --- /dev/null +++ b/src/SharpClient.App/Platforms/Android/WebViewInsetsBridge.cs @@ -0,0 +1,77 @@ +using System.Globalization; +using AndroidX.Core.View; +using AView = Android.Views.View; +using AWebView = Android.Webkit.WebView; + +namespace SharpClient.App.Platforms.Android; + +/// +/// Bridges Android WindowInsets into the Blazor/HTML layer as CSS custom properties. +/// +/// WHY THIS EXISTS: on Android 15/16 edge-to-edge is enforced and the HTML draws under the status +/// bar / display cutout and behind the soft keyboard. The pure-web fixes (CSS +/// env(safe-area-inset-*) and window.visualViewport) only work on Android System +/// WebView >= 136/139/144 — on older WebViews (the device hit this on 133) env() resolves to +/// 0 and visualViewport does not track the IME, so the top bar overlapped and the keyboard +/// hid the input bar. Reading the insets natively and pushing them in as CSS variables is +/// version-independent and is the approach Google documents for "content you own". +/// +/// Sets --sc-safe-top/-bottom/-left/-right (system bars + display cutout) and +/// --sc-keyboard-height (IME); app.css consumes them via max(env(...), var(--sc-safe-*)) +/// and subtracts --sc-keyboard-height from the shell height. +/// +internal static class WebViewInsetsBridge +{ + public static void Attach(AWebView webView) + { + ViewCompat.SetOnApplyWindowInsetsListener(webView, new Listener(webView)); + // Force an initial inset pass so the variables are set before the first keyboard/rotation. + ViewCompat.RequestApplyInsets(webView); + } + + private sealed class Listener : Java.Lang.Object, IOnApplyWindowInsetsListener + { + private readonly AWebView _webView; + + public Listener(AWebView webView) => _webView = webView; + + public WindowInsetsCompat? OnApplyWindowInsets(AView? view, WindowInsetsCompat? insets) + { + if (insets is null) + { + return insets; + } + + var zero = AndroidX.Core.Graphics.Insets.Of(0, 0, 0, 0)!; + var bars = insets.GetInsets(WindowInsetsCompat.Type.SystemBars() | WindowInsetsCompat.Type.DisplayCutout()) ?? zero; + var ime = insets.GetInsets(WindowInsetsCompat.Type.Ime()) ?? zero; + + var density = view?.Resources?.DisplayMetrics?.Density ?? 1f; + if (density <= 0f) + { + density = 1f; + } + + string Px(int physical) => (physical / density).ToString("0.##", CultureInfo.InvariantCulture); + + // The IME inset already includes the bottom system bar; the keyboard height we want to + // subtract from the layout is the part of the IME that exceeds the nav-bar inset. + var keyboard = System.Math.Max(0, ime.Bottom - bars.Bottom); + + var js = + "(function(s){" + + $"s.setProperty('--sc-safe-top','{Px(bars.Top)}px');" + + $"s.setProperty('--sc-safe-bottom','{Px(bars.Bottom)}px');" + + $"s.setProperty('--sc-safe-left','{Px(bars.Left)}px');" + + $"s.setProperty('--sc-safe-right','{Px(bars.Right)}px');" + + $"s.setProperty('--sc-keyboard-height','{Px(keyboard)}px');" + + "})(document.documentElement.style);"; + + _webView.EvaluateJavascript(js, null); + + // Return the insets unconsumed; app.css applies the padding, so we deliberately do NOT + // also pad the native view (that would double-count). + return insets; + } + } +} diff --git a/src/SharpClient.App/SharpClient.App.csproj b/src/SharpClient.App/SharpClient.App.csproj index 9bd4a3d..322b56e 100644 --- a/src/SharpClient.App/SharpClient.App.csproj +++ b/src/SharpClient.App/SharpClient.App.csproj @@ -98,7 +98,11 @@ directly so its types (ITelnetInterpreterFactory, AddTelnetClient) are visible under net10.0-android; transitive exposure from SharpClient.Core does not flow through for the MAUI Android TFM. --> - + + diff --git a/src/SharpClient.Core/Presentation/HistorySearchViewModel.cs b/src/SharpClient.Core/Presentation/HistorySearchViewModel.cs new file mode 100644 index 0000000..f8684de --- /dev/null +++ b/src/SharpClient.Core/Presentation/HistorySearchViewModel.cs @@ -0,0 +1,106 @@ +using SharpClient.Core.Persistence; + +namespace SharpClient.Core.Presentation; + +/// A single full-text history match, decorated with a human-readable character label. +public sealed record HistorySearchResult(string Line, string CharacterLabel, long Sequence); + +/// +/// Drives the history-search screen: takes a free-text query, runs it against the FTS5-backed +/// , and resolves each hit's to a +/// "Character @ World" label via . Mirrors the other presentation +/// view-models (synchronous state + a event the Razor layer subscribes to). +/// +public sealed class HistorySearchViewModel +{ + private readonly ISessionHistory _history; + private readonly IWorldStore _worldStore; + + public HistorySearchViewModel(ISessionHistory history, IWorldStore worldStore) + { + _history = history; + _worldStore = worldStore; + } + + /// Raised whenever query, results, or busy state change. + public event Action? Changed; + + public string Query { get; private set; } = string.Empty; + + public IReadOnlyList Results { get; private set; } = []; + + public bool IsSearching { get; private set; } + + /// True once at least one search has run (so the UI can distinguish "no results" from "not searched yet"). + public bool HasSearched { get; private set; } + + public void SetQuery(string query) + { + if (query == Query) + { + return; + } + + Query = query; + Changed?.Invoke(); + } + + public async Task SearchAsync(CancellationToken cancellationToken = default) + { + var query = Query.Trim(); + if (query.Length == 0) + { + Results = []; + HasSearched = true; + Changed?.Invoke(); + return; + } + + IsSearching = true; + Changed?.Invoke(); + + try + { + var labels = await GetCharacterLabelsAsync(cancellationToken); + var hits = await _history.SearchAsync(query, cancellationToken: cancellationToken); + + Results = [.. hits.Select(h => new HistorySearchResult( + h.Line, + labels.TryGetValue(h.CharacterId, out var label) ? label : "Unknown character", + h.Sequence))]; + } + finally + { + IsSearching = false; + HasSearched = true; + Changed?.Invoke(); + } + } + + public void Clear() + { + Query = string.Empty; + Results = []; + HasSearched = false; + Changed?.Invoke(); + } + + private async Task> GetCharacterLabelsAsync(CancellationToken cancellationToken) + { + // Rebuild the label map on each fresh search so newly-added characters resolve, but reuse a + // cached map within a single search invocation. + var worlds = await _worldStore.GetWorldsAsync(cancellationToken); + var map = new Dictionary(); + foreach (var world in worlds) + { + foreach (var character in world.Characters) + { + map[character.Id] = string.IsNullOrEmpty(world.Name) + ? character.Name + : $"{character.Name} @ {world.Name}"; + } + } + + return map; + } +} diff --git a/src/SharpClient.Core/Sessions/Session.cs b/src/SharpClient.Core/Sessions/Session.cs index 71aa4c1..88b05c5 100644 --- a/src/SharpClient.Core/Sessions/Session.cs +++ b/src/SharpClient.Core/Sessions/Session.cs @@ -19,8 +19,11 @@ public sealed class Session : ISession private readonly INotifier? _notifier; private readonly ISessionHistory? _history; - // NOTE: not thread-safe; LineReceived/protocol events may fire off the network thread — - // UI consumers must marshal. TODO: guard if accessed concurrently. + // LineReceived fires off the network read thread while Blazor enumerates Scrollback on the + // render thread — appending mid-enumeration throws "Collection was modified" and kills the UI. + // _scrollbackLock guards every read and write of _scrollback; the Scrollback getter hands out an + // immutable snapshot so callers can enumerate freely without holding the lock. + private readonly object _scrollbackLock = new(); private readonly List _scrollback = []; private readonly List _negotiationLog = []; private readonly List _gmcpLog = []; @@ -58,7 +61,16 @@ public Session( _connection.MxpEnabled += OnMxpEnabled; } - public IReadOnlyList Scrollback => _scrollback; + public IReadOnlyList Scrollback + { + get + { + lock (_scrollbackLock) + { + return _scrollback.ToArray(); + } + } + } public IReadOnlyList NegotiationLog => _negotiationLog; @@ -98,7 +110,11 @@ public async Task SendAsync(string line) // Local echo: MUSH/MUD servers normally don't echo your commands back, so // show what was typed in the scrollback (dim, prefixed) for visibility. var echo = new ScrollbackLine([new StyledSegment("> " + line, EchoStyle)]); - _scrollback.Add(echo); + lock (_scrollbackLock) + { + _scrollback.Add(echo); + } + LineAppended?.Invoke(echo); await _connection.SendAsync(expanded); @@ -156,7 +172,11 @@ private async void OnLineReceived(string raw) } var line = new ScrollbackLine(segments); - _scrollback.Add(line); + lock (_scrollbackLock) + { + _scrollback.Add(line); + } + LineAppended?.Invoke(line); foreach (var cmd in sendCommands) diff --git a/src/SharpClient.Core/SharpClient.Core.csproj b/src/SharpClient.Core/SharpClient.Core.csproj index 0850bd2..365ee1f 100644 --- a/src/SharpClient.Core/SharpClient.Core.csproj +++ b/src/SharpClient.Core/SharpClient.Core.csproj @@ -5,7 +5,9 @@ - + + diff --git a/src/SharpClient.UI/Components/HistorySearchView.razor b/src/SharpClient.UI/Components/HistorySearchView.razor new file mode 100644 index 0000000..81681fb --- /dev/null +++ b/src/SharpClient.UI/Components/HistorySearchView.razor @@ -0,0 +1,66 @@ +@using SharpClient.Core.Presentation +@implements IDisposable + + + +@code { + [Parameter] + public HistorySearchViewModel Vm { get; set; } = null!; + + protected override void OnInitialized() => Vm.Changed += OnChanged; + + private Task RunSearchAsync() => Vm.SearchAsync(); + + private void Clear() => Vm.Clear(); + + private void OnChanged() => InvokeAsync(StateHasChanged); + + public void Dispose() => Vm.Changed -= OnChanged; +} diff --git a/src/SharpClient.UI/Components/SessionScreen.razor b/src/SharpClient.UI/Components/SessionScreen.razor index f682832..50314cf 100644 --- a/src/SharpClient.UI/Components/SessionScreen.razor +++ b/src/SharpClient.UI/Components/SessionScreen.razor @@ -1,4 +1,5 @@ @using Microsoft.JSInterop +@using SharpClient.Core.Connection @using SharpClient.Core.Presentation @using SharpClient.Core.Sessions @@ -10,10 +11,26 @@
- @if (ProtocolVm is not null) - { - - } +
+ @if (Vm.Active is not null) + { + + + @StateLabel(Vm.Active.State) + + } + @if (ProtocolVm is not null) + { + + } +
@if (Vm.Active is null) @@ -117,6 +134,24 @@ private void ToggleProtocol() => _showProtocol = !_showProtocol; + private static string StateClass(ConnectionState state) => state switch + { + ConnectionState.Connected => "connected", + ConnectionState.Connecting => "connecting", + ConnectionState.Reconnecting => "reconnecting", + ConnectionState.Error => "error", + _ => "disconnected", + }; + + private static string StateLabel(ConnectionState state) => state switch + { + ConnectionState.Connected => "Connected", + ConnectionState.Connecting => "Connecting", + ConnectionState.Reconnecting => "Reconnecting", + ConnectionState.Error => "Error", + _ => "Disconnected", + }; + public async ValueTask DisposeAsync() { Vm.Changed -= OnVmChanged; diff --git a/src/SharpClient.UI/Layout/MainLayout.razor b/src/SharpClient.UI/Layout/MainLayout.razor index 725c23b..d849aa2 100644 --- a/src/SharpClient.UI/Layout/MainLayout.razor +++ b/src/SharpClient.UI/Layout/MainLayout.razor @@ -1,7 +1,9 @@ @inherits LayoutComponentBase @using Microsoft.AspNetCore.Components.Routing +@using Microsoft.JSInterop @using SharpClient.Core.Presentation @inject SettingsViewModel SettingsVm +@inject IJSRuntime JS @implements IDisposable
Session + + + History +
@code { + private IJSObjectReference? _interop; + protected override void OnInitialized() => SettingsVm.Changed += OnChanged; + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + // Bind .sc-shell height to the visual viewport so the soft keyboard doesn't hide the + // input bar. Shared across the Android (BlazorWebView) and Web (Blazor Server) hosts. + _interop = await JS.InvokeAsync( + "import", "./_content/SharpClient.UI/sc-interop.js"); + await _interop.InvokeVoidAsync("syncViewport"); + } + } + private void OnChanged() => InvokeAsync(StateHasChanged); public void Dispose() => SettingsVm.Changed -= OnChanged; diff --git a/src/SharpClient.UI/Pages/HistoryPage.razor b/src/SharpClient.UI/Pages/HistoryPage.razor new file mode 100644 index 0000000..0ebc3cd --- /dev/null +++ b/src/SharpClient.UI/Pages/HistoryPage.razor @@ -0,0 +1,7 @@ +@page "/history" + +@inject HistorySearchViewModel Vm + +SharpClient · History + + diff --git a/src/SharpClient.UI/wwwroot/app.css b/src/SharpClient.UI/wwwroot/app.css index 5ac6ef8..a40a1e6 100644 --- a/src/SharpClient.UI/wwwroot/app.css +++ b/src/SharpClient.UI/wwwroot/app.css @@ -140,6 +140,172 @@ body { background: var(--bg); } +/* ── Session header (tabs + state pill + protocol toggle) ──────── */ +.sc-session-header { + display: flex; + align-items: stretch; + background: var(--panel); + border-bottom: 1px solid var(--bd2); + flex-shrink: 0; +} + +/* SessionTabs already paints its own bottom border; let the header own it instead + so the two don't double up. */ +.sc-session-header .sc-tab-bar { + flex: 1; + min-width: 0; + border-bottom: none; +} + +.sc-session-actions { + display: flex; + align-items: center; + gap: 8px; + padding: 0 8px; + flex-shrink: 0; +} + +.sc-state-pill { + display: inline-flex; + align-items: center; + gap: 5px; + font-family: var(--ui); + font-size: 0.68rem; + font-weight: 500; + letter-spacing: .03em; + color: var(--dim); + background: var(--elev); + border: 1px solid var(--bd2); + border-radius: 999px; + padding: 3px 9px 3px 7px; + white-space: nowrap; +} + +.sc-state-pill-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; + background: currentColor; +} + +.sc-state-connected { color: #8fc16f; border-color: rgba(143,193,111,.4); } +.sc-state-connecting, +.sc-state-reconnecting { color: #e5c07b; border-color: rgba(229,192,123,.4); } +.sc-state-error { color: #e06c75; border-color: rgba(224,108,117,.45); } +.sc-state-disconnected { color: var(--faint); } + +.sc-protocol-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 8px; + background: transparent; + border: 1px solid var(--bd2); + color: var(--dim); + cursor: pointer; + transition: color .12s, border-color .12s, background .12s; +} + +.sc-protocol-toggle svg { + width: 17px; + height: 17px; + display: block; +} + +.sc-protocol-toggle:hover { + color: var(--tx); + border-color: var(--acc-line); +} + +.sc-protocol-toggle-active { + color: var(--acc2); + border-color: var(--acc-line); + background: var(--acc-soft); +} + +/* ── Protocol panel (negotiation + GMCP inspector) ─────────────── */ +.sc-protocol-panel { + flex-shrink: 0; + max-height: 40%; + overflow-y: auto; + background: var(--panel); + border-top: 1px solid var(--bd2); + padding: 10px 12px 12px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.sc-negotiation-log, +.sc-gmcp-log { + display: flex; + flex-direction: column; + gap: 5px; +} + +.sc-protocol-section-title { + font-family: var(--ui); + font-size: 0.66rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .08em; + color: var(--dim); + margin-bottom: 2px; +} + +.sc-protocol-empty { + font-family: var(--mono); + font-size: 11px; + color: var(--faint); +} + +.sc-negotiation-row { + display: flex; + align-items: baseline; + gap: 8px; + font-family: var(--mono); + font-size: 11px; + line-height: 1.5; +} + +.sc-negotiation-key { + flex-shrink: 0; + color: var(--acc2); + min-width: 70px; +} + +.sc-negotiation-detail { + color: var(--dim); + word-break: break-all; +} + +.sc-gmcp-row { + display: flex; + flex-direction: column; + gap: 2px; + border-left: 2px solid var(--acc-line); + padding-left: 8px; +} + +.sc-gmcp-package { + font-family: var(--mono); + font-size: 11px; + font-weight: 600; + color: var(--acc2); +} + +.sc-gmcp-json { + font-family: var(--mono); + font-size: 11px; + color: var(--tx); + white-space: pre-wrap; + word-break: break-all; + margin: 0; +} + /* ── Tab Bar ───────────────────────────────────────────────────── */ .sc-tab-bar { display: flex; @@ -418,7 +584,10 @@ body { display: flex; background: var(--panel); border-top: 1px solid var(--bd); - padding-bottom: env(safe-area-inset-bottom, 0px); + /* Bottom system bar / gesture inset. env() on new WebViews, native --sc-safe-bottom fallback + on old ones (see WebViewInsetsBridge). When the keyboard is up the shell has already shrunk + by --sc-keyboard-height, so the nav rides just above the keyboard. */ + padding-bottom: max(env(safe-area-inset-bottom, 0px), var(--sc-safe-bottom, 0px)); } .sc-nav-link { flex: 1; @@ -441,6 +610,29 @@ body { .sc-nav-link:hover { color: var(--dim); } .sc-nav-link.active { color: var(--acc2); border-top-color: var(--acc2); } +/* ── History Search ─────────────────────────────────────────────────────── */ +.sc-history-search { max-width: 640px; margin: 0 auto; padding: 18px 14px 24px; display: flex; flex-direction: column; gap: 14px; } +.sc-history-search-header { display: flex; flex-direction: column; gap: 3px; } +.sc-history-search-title { font-size: 19px; font-weight: 600; color: #eff3f7; letter-spacing: -.01em; } +.sc-history-search-sub { font-family: var(--mono); font-size: 11px; color: var(--dim); } + +.sc-history-search-bar { display: flex; align-items: center; gap: 8px; } +.sc-history-search-input { flex: 1; min-width: 0; background: var(--outbg); border: 1px solid var(--bd2); border-radius: 9px; color: var(--tx); font-family: var(--mono); font-size: 13px; padding: 10px 12px; outline: none; } +.sc-history-search-input:focus { border-color: var(--acc-line); } +.sc-history-search-input::placeholder { color: var(--faint); } +.sc-history-search-btn { flex: none; font-family: var(--ui); font-size: 13px; font-weight: 600; color: #0a0c10; background: var(--acc2); border: none; border-radius: 9px; padding: 10px 16px; cursor: pointer; } +.sc-history-search-btn:disabled { opacity: .5; cursor: not-allowed; } +.sc-history-clear-btn { flex: none; width: 36px; height: 36px; border-radius: 9px; background: transparent; border: 1px solid var(--bd2); color: var(--dim); font-size: 16px; line-height: 1; cursor: pointer; } +.sc-history-clear-btn:hover { color: var(--tx); border-color: var(--acc-line); } + +.sc-history-status { font-family: var(--mono); font-size: 12px; color: var(--faint); padding: 8px 2px; } +.sc-history-count { font-family: var(--mono); font-size: 11px; color: var(--dim); text-transform: uppercase; letter-spacing: .06em; } + +.sc-history-results { list-style: none; display: flex; flex-direction: column; gap: 2px; } +.sc-history-result { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; border-radius: 8px; background: var(--panel); border: 1px solid var(--bd); } +.sc-history-result-char { font-family: var(--ui); font-size: 10.5px; font-weight: 600; color: var(--acc2); letter-spacing: .02em; } +.sc-history-result-line { font-family: var(--mono); font-size: 12px; color: var(--pho); white-space: pre-wrap; word-break: break-all; } + /* ── Trigger & Alias Editor ─────────────────────────────────────────────── */ .sc-rule-editor { max-width: 640px; margin: 0 auto; padding: 18px 14px 24px; display: flex; flex-direction: column; gap: 16px; } .sc-rule-section { background: var(--panel); border: 1px solid var(--bd); border-radius: 12px; overflow: hidden; } @@ -468,7 +660,22 @@ body { .sc-shell { display: flex; flex-direction: column; - height: 100vh; + /* Height = visual viewport minus the soft-keyboard height. + --sc-app-height : visual-viewport height from sc-interop.js (works on web + Android System + WebView >= 139, where visualViewport tracks the IME). Falls back to 100dvh. + --sc-keyboard-height : IME height pushed natively by WebViewInsetsBridge (Android). This is + the ONLY reliable keyboard signal on older WebViews (< 139) where + visualViewport does NOT shrink for the keyboard, so subtract it explicitly + to keep the input bar above the keyboard. Defaults to 0 on web/desktop. */ + height: calc(100dvh - var(--sc-keyboard-height, 0px)); + height: calc(var(--sc-app-height, 100vh) - var(--sc-keyboard-height, 0px)); + /* targetSDK 35+ forces edge-to-edge: content draws under the status bar / display cutout. + On a new-enough WebView, env(safe-area-inset-*) resolves these. On older Android System + WebView (< 136/144) env() is 0, so WebViewInsetsBridge pushes the real inset px into + --sc-safe-* natively; max() takes whichever is correct on the running WebView. */ + padding-top: max(env(safe-area-inset-top, 0px), var(--sc-safe-top, 0px)); + padding-left: max(env(safe-area-inset-left, 0px), var(--sc-safe-left, 0px)); + padding-right: max(env(safe-area-inset-right, 0px), var(--sc-safe-right, 0px)); } .sc-content { diff --git a/src/SharpClient.UI/wwwroot/sc-interop.js b/src/SharpClient.UI/wwwroot/sc-interop.js index 13c4283..986fa76 100644 --- a/src/SharpClient.UI/wwwroot/sc-interop.js +++ b/src/SharpClient.UI/wwwroot/sc-interop.js @@ -74,6 +74,34 @@ export function attachAutoScroll(element) { element.scrollTop = element.scrollHeight; } +/** + * Tracks the visual viewport and publishes its height to the --sc-app-height CSS variable on + * . The shell (.sc-shell) sizes itself to this variable, so when the Android soft keyboard + * opens — which shrinks the visual viewport without changing 100vh — the layout shrinks too and the + * input bar stays visible above the keyboard. Idempotent: only the first call wires the listeners. + */ +let _viewportSynced = false; +export function syncViewport() { + if (_viewportSynced) { + return; + } + _viewportSynced = true; + + const vv = window.visualViewport; + const apply = () => { + const h = vv ? vv.height : window.innerHeight; + document.documentElement.style.setProperty('--sc-app-height', h + 'px'); + }; + + apply(); + if (vv) { + vv.addEventListener('resize', apply); + vv.addEventListener('scroll', apply); + } + window.addEventListener('resize', apply); + window.addEventListener('orientationchange', apply); +} + /** * Stops observing the element (called on component dispose). * @param {HTMLElement} element diff --git a/src/SharpClient.Web/Program.cs b/src/SharpClient.Web/Program.cs index 47cbdaf..f7a2b8f 100644 --- a/src/SharpClient.Web/Program.cs +++ b/src/SharpClient.Web/Program.cs @@ -57,6 +57,10 @@ builder.Services.AddScoped(sp => new TriggerAliasEditorViewModel( sp.GetRequiredService())); +builder.Services.AddScoped(sp => + new HistorySearchViewModel( + sp.GetRequiredService(), + sp.GetRequiredService())); var app = builder.Build(); diff --git a/src/SharpClient.Web/SharpClient.Web.csproj b/src/SharpClient.Web/SharpClient.Web.csproj index 6fb908e..4079eb2 100644 --- a/src/SharpClient.Web/SharpClient.Web.csproj +++ b/src/SharpClient.Web/SharpClient.Web.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/SharpClient.Web/WebPreferences.cs b/src/SharpClient.Web/WebPreferences.cs index f499f74..b75838f 100644 --- a/src/SharpClient.Web/WebPreferences.cs +++ b/src/SharpClient.Web/WebPreferences.cs @@ -1,24 +1,107 @@ +using System.Globalization; +using System.Text.Json; using SharpClient.Core.Platform; namespace SharpClient.Web; +/// +/// File-backed for the Blazor Server preview host. Settings are kept in +/// memory for fast synchronous access (the contract is synchronous) and +/// written through to App_Data/preferences.json on every change, so they survive page reloads +/// and host restarts — matching the MAUI host, where MauiPreferences uses +/// Preferences.Default. Only non-sensitive UI settings flow through here; secrets go through +/// . +/// public sealed class WebPreferences : IPreferences { - private readonly Dictionary _store = []; + private readonly string _path; + private readonly object _gate = new(); + private readonly Dictionary _store; - public string GetString(string key, string defaultValue) => - _store.TryGetValue(key, out var v) ? v : defaultValue; + public WebPreferences(IAppStorage storage) + { + var dir = Path.GetDirectoryName(storage.GetDatabasePath()) ?? "."; + Directory.CreateDirectory(dir); + _path = Path.Combine(dir, "preferences.json"); + _store = Load(_path); + } - public void SetString(string key, string value) => _store[key] = value; + public string GetString(string key, string defaultValue) + { + lock (_gate) + { + return _store.TryGetValue(key, out var v) ? v : defaultValue; + } + } - public int GetInt(string key, int defaultValue) => - _store.TryGetValue(key, out var v) && int.TryParse(v, out var i) ? i : defaultValue; + public void SetString(string key, string value) => Set(key, value); + + public int GetInt(string key, int defaultValue) + { + lock (_gate) + { + return _store.TryGetValue(key, out var v) + && int.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i) + ? i : defaultValue; + } + } public void SetInt(string key, int value) => - _store[key] = value.ToString(System.Globalization.CultureInfo.InvariantCulture); + Set(key, value.ToString(CultureInfo.InvariantCulture)); + + public bool GetBool(string key, bool defaultValue) + { + lock (_gate) + { + return _store.TryGetValue(key, out var v) && bool.TryParse(v, out var b) ? b : defaultValue; + } + } + + public void SetBool(string key, bool value) => + Set(key, value ? "true" : "false"); + + private void Set(string key, string value) + { + lock (_gate) + { + _store[key] = value; + Save(); + } + } + + // Caller holds _gate. + private void Save() + { + try + { + File.WriteAllText(_path, JsonSerializer.Serialize(_store)); + } + catch (IOException) + { + // Best-effort: a failed write just means this change isn't persisted; the in-memory + // value still applies for the current session. + } + } - public bool GetBool(string key, bool defaultValue) => - _store.TryGetValue(key, out var v) && bool.TryParse(v, out var b) ? b : defaultValue; + private static Dictionary Load(string path) + { + try + { + if (File.Exists(path)) + { + var json = File.ReadAllText(path); + var loaded = JsonSerializer.Deserialize>(json); + if (loaded is not null) + { + return loaded; + } + } + } + catch (Exception ex) when (ex is IOException or JsonException) + { + // Corrupt or unreadable preferences file — start fresh rather than crash the host. + } - public void SetBool(string key, bool value) => _store[key] = value.ToString(); + return []; + } } diff --git a/src/SharpClient.Web/WebSecretStore.cs b/src/SharpClient.Web/WebSecretStore.cs index c466da6..35c4d8d 100644 --- a/src/SharpClient.Web/WebSecretStore.cs +++ b/src/SharpClient.Web/WebSecretStore.cs @@ -3,6 +3,15 @@ namespace SharpClient.Web; +/// +/// In-memory for the Blazor Server preview host. This is a deliberate +/// design choice, not a stub: connect-string secrets (passwords) are held only for the lifetime of +/// the host process and never written to disk, so the localhost preview tool never leaves plaintext +/// credentials on the filesystem. Persistent, encrypted secret storage is the MAUI host's job — +/// MauiSecretStore uses the platform keystore via SecureStorage.Default. If durable +/// secrets are ever needed on the web host, back this with an OS keychain / DPAPI / data-protection +/// API — do NOT serialize secrets to a plaintext file alongside preferences. +/// public sealed class WebSecretStore : ISecretStore { private readonly ConcurrentDictionary _store = new(); diff --git a/tests/SharpClient.Tests/Sessions/ScrollbackConcurrencyTests.cs b/tests/SharpClient.Tests/Sessions/ScrollbackConcurrencyTests.cs new file mode 100644 index 0000000..57538e8 --- /dev/null +++ b/tests/SharpClient.Tests/Sessions/ScrollbackConcurrencyTests.cs @@ -0,0 +1,78 @@ +using SharpClient.Core.Sessions; + +namespace SharpClient.Tests.Sessions; + +/// +/// Guards the fix for the "Collection was modified; enumeration operation may not execute" crash +/// that killed the WebView UI: the telnet read loop appends to Scrollback off the network thread +/// while Blazor enumerates it on the render thread. Session must hand out an immutable snapshot and +/// guard its backing list so concurrent appends never corrupt an in-flight enumeration. +/// +public sealed class ScrollbackConcurrencyTests +{ + [Test] + public async Task EnumeratingScrollbackWhileLinesArriveDoesNotThrow() + { + const int lineCount = 3_000; + + var conn = new FakeTelnetConnection(); + await using var session = new Session(conn); + + // Writer: append a bounded number of lines, mimicking the network read loop. Bounded (not + // "until cancelled") so the test terminates fast and the snapshot stays small even when the + // whole suite runs in parallel — an unbounded writer let the list grow without limit and + // timed out under load. + var writer = Task.Run(() => + { + for (var i = 0; i < lineCount; i++) + { + conn.Emit("line " + i); + } + }); + + // Reader: enumerate the public Scrollback the way OutputView's @foreach does, continuously + // until the writer finishes. Pre-fix this raced against Emit's _scrollback.Add and threw + // InvalidOperationException ("Collection was modified"). + Exception? readerError = null; + var reader = Task.Run(() => + { + try + { + while (!writer.IsCompleted) + { + var seen = 0; + foreach (var line in session.Scrollback) + { + seen += line.Segments.Count; + } + } + } + catch (Exception ex) + { + readerError = ex; + } + }); + + await Task.WhenAll(writer, reader); + + await Assert.That(readerError).IsNull(); + await Assert.That(session.Scrollback.Count).IsEqualTo(lineCount); + } + + [Test] + public async Task SnapshotIsDecoupledFromLaterAppends() + { + var conn = new FakeTelnetConnection(); + await using var session = new Session(conn); + + conn.Emit("first"); + var snapshot = session.Scrollback; + var countAtSnapshot = snapshot.Count; + + conn.Emit("second"); + + // The previously-returned snapshot must not grow when new lines arrive. + await Assert.That(snapshot.Count).IsEqualTo(countAtSnapshot); + await Assert.That(session.Scrollback.Count).IsEqualTo(countAtSnapshot + 1); + } +} From 1ab0964f94f7e8bb8dd0a6e7a0a5ee93e1ac890b Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Tue, 30 Jun 2026 17:44:36 -0500 Subject: [PATCH 2/3] =?UTF-8?q?fix(android):=20disable=20Release=20size-tr?= =?UTF-8?q?im=20=E2=80=94=20FSharp.Core=20ILLink=20substitutions=20break?= =?UTF-8?q?=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Release Android build's trimmer ("optimize assemblies for size") aborts with IL2040 / NETSDK1144 on FSharp.Core's embedded ILLink.Substitutions.xml: it tries to remove F# signature/optimization embedded resources it can't find. FSharp.Core now enters the closure via TelnetNegotiationCore's F# MSDP assembly (needed to fix the MSDP runtime crash), and the substitution step can't be suppressed or skipped (rooting the assembly does not bypass it). Set PublishTrimmed=false for the Android Release config (AOT was already disabled). The APK is larger but builds; trimming can return if FSharp.Core's linker metadata is fixed upstream. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HHCRc5CJ6595iMzEgYYdQp --- src/SharpClient.App/SharpClient.App.csproj | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/SharpClient.App/SharpClient.App.csproj b/src/SharpClient.App/SharpClient.App.csproj index 322b56e..a4dc0dd 100644 --- a/src/SharpClient.App/SharpClient.App.csproj +++ b/src/SharpClient.App/SharpClient.App.csproj @@ -51,10 +51,17 @@ + publish -c Release` produces an APK; the app runs under the JIT/interpreter. + + PublishTrimmed=false: the Release trimmer ("optimize assemblies for size") fails on + FSharp.Core's embedded ILLink.Substitutions.xml — it tries to remove F# signature/ + optimization resources it can't find and aborts with IL2040 / NETSDK1144. That substitution + step can't be suppressed, so disable trimming for the Android Release build. The APK is + larger but correct; size-trim can be revisited if FSharp.Core's linker metadata is fixed. --> false false + false From 1f3f0a829cdc6cbe2904e0ecec99acb4345d8620 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Tue, 30 Jun 2026 18:26:35 -0500 Subject: [PATCH 3/3] fix(naws): measure the real character grid; stop assuming 0.6em advance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The column math hard-coded a 0.6em monospace advance to pick the font and compute NAWS. The real rendered advance is wider (~0.616), so an advertised MinColumns (78) physically fit only ~76 and the server's 78-wide lines wrapped two columns short. Port SharpMUSH.Client's terminalMetrics.js approach into sc-interop.js `measureGrid`: - advance + line-height MEASURED from hidden probes (200-char run averages sub-pixel rounding; 5-line probe for line-height), copying the resolved --mono family, letter-spacing and font-feature-settings — never derived from font-size; - fit the font so exactly MinColumns span the content width, with a closed-loop correction (re-measure MinColumns chars, shrink on overflow) for glyph-advance non-linearity from hinting; - padding-aware content width (matches the ResizeObserver contentRect basis); - report exactly MinColumns over NAWS and publish --out-fs/--sc-cols on the element. OutputView's track now holds a `calc(var(--sc-cols) * 1ch)` min-width and the output area scrolls horizontally, so on a screen too narrow to fit MinColumns at the minimum font the lines scroll instead of wrapping — what the player sees matches the server's wrap width. Removed the now-unused setFontSize interop. Tests: 184 TUnit + 46 bUnit green; UI/Web build 0-warning. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HHCRc5CJ6595iMzEgYYdQp --- .../Components/SessionScreen.razor | 23 ++-- src/SharpClient.UI/wwwroot/app.css | 9 ++ src/SharpClient.UI/wwwroot/sc-interop.js | 122 ++++++++++++++++-- 3 files changed, 132 insertions(+), 22 deletions(-) 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))) }; } /**