prism branch T-1830/bugfix-raw-source-nowrap-width commits 2 files 11 touched lines +1064 / -107 targeted tests 82 / 82 passed

Pre-push review: T-1830 raw-source no-wrap width

Second pass over PR #410 (head 7f2b650e), reviewing git diff origin/main...HEAD. The previous review of ce71c4a5 returned Needs fixes; this pass verifies the follow-up commit and judges the branch as it will land on main. Strictly read-only: nothing was edited, and the one verification run was made against a git archive export.

At a glance

  • Previous blockers verified fixed: T-700 tests staleTaskDoesNotClearLoading / rapidLoadContentLeavesConsistentState pass; TypographyResolver.baselineSize and its tables are nonisolated and the changed production files produce no compiler warnings; document-reader.md and raw-source-view.md updated.
  • Previous recommendations verified taken: maxLineLength removed with its tests; MonospaceMetricsRenderParityTests bodies are async in a @MainActor suite; single-slot memo retained with a written rationale.
  • Verification: one targeted macOS xcodebuild test (MonospaceMetricsTests, MonospaceMetricsRenderParityTests, RawSourceViewModelTests, RawSourceViewModelHighlightingTests) on a git archive export: 82 passed, 0 failed. SwiftLint on the seven changed Swift files: no violations. Full make test-quick/make test not run (single-xcodebuild constraint).
  • New in this pass: three test-target warnings from MonospaceMetricsRenderParityTests.sampleLines (implicitly @MainActor static read by the @Test(arguments:) thunk) — same class as the warning the previous review flagged, invisible to the app-only build gate, one-keyword fix.
  • Best follow-up candidate: drive the font-change re-selection from .task(id: MonoFontSpec(resolver:)) instead of .onChange + unstructured Task, and add a Task.isCancelled checkpoint inside MonospaceMetrics.widestLine. Today a held text-size stepper runs every superseded measurement pass to completion and nothing orders their landings.
  • Docs: the bugfix report contradicts itself on maxLineLength (retained vs removed), its Affected Files table misses four touched files, and its run command omits the parity suite it says passed.

Verdict

Ready to push

All three blocking items from the previous review are resolved: the two T-700 concurrency tests pass (both are in the 82/82 targeted run), the baselineSize default-argument actor-isolation warning is gone and the changed production files compile with zero warnings, and both agent notes now describe the shipped design. The three recommendations were also taken up: dead maxLineLength removed, hosting-view parity tests made async in a @MainActor suite, and the process-global memo kept but explicitly justified as a single slot. The fix itself is sound — the plain-ASCII shortcut is exact by construction on a fixed-pitch font and is pinned against exhaustive measurement. What remains is non-blocking: an uncancellable/unordered re-measure on rapid font changes (correctness self-heals on the next change; needs a pathological document to matter), one new Swift-6-future warning in the test target, dead charWidth code, and internal contradictions in the bugfix report. Worth a follow-up ticket, not a re-review.

Review findings

11 raised · 0 fixed · 11 skipped

Jump to findings →

Tests

Pass rate: 100% (82 of 82)

New tests: 30

Diff coverage: n/a

Jump to tests →

Commits

Three-level explanation

What changed

Prism has a "Raw Source" mode that shows the markdown exactly as written. With "Wrap Long Lines" off, the app has to decide how wide the scrollable area is. It used to guess: take the line with the most characters and multiply by the width of one letter "M". That guess is wrong for tabs (which jump to fixed tab stops), for Chinese/Japanese/Korean characters (roughly twice as wide) and for emoji (also wide). A line full of those could stick out past the scrollable area, and you could never scroll to its end.

Now the app measures real text with the real font. While parsing a document it asks the text system which line actually draws widest, remembers that line, and measures it again with whatever font is current whenever the view is drawn.

Why it matters

Any document with a tab-indented table, CJK prose or emoji in a long line was partly unreadable in no-wrap mode on both iPhone/iPad and Mac. The tail of the line was simply unreachable.

Key concepts

  • Monospace / fixed pitch: a font where every ordinary Latin letter is the same width. The fix uses this to skip work: among plain-ASCII lines only the longest can be the widest, so only that one is measured.
  • Tab stops: tabs do not have a width of their own; they advance to the next fixed position (about 28pt apart) regardless of font size. That is why a bigger font can make a plain line overtake a tab-heavy line, and why the app now re-picks the widest line whenever the mono font or text size changes.
  • Main actor: the UI thread. Measuring every tab/CJK line of a big document could stall the interface, so the measuring happens on a background task and only the answer comes back to the UI.

Changes overview

  • prism/Services/MonospaceMetrics.swift: font resolution factored into a nonisolated resolveFont; new lineWidth(_:family:size:) (NSString drawing, no paragraph style) and widestLine(in:family:size:); new MonoFontSpec (family + effective size, Hashable/Sendable); RawSourceContentWidth.compute now takes widestLineText and memoises the last (text, family, size) measurement in one @MainActor static slot.
  • prism/ViewModels/RawSourceViewModel.swift: maxLineLength replaced by widestLineText; loadContent gains monoFontFamily/monoFontSize and runs widestLine inside the existing detached parse task; new updateWidestLine(font:) re-selects without reparsing via a cancellable detached remeasureTask, guarded on linesGeneration and the recorded widestLineFont; loadingContentHash made private(set) for the T-700 tests.
  • prism/Views/RawSourceView.swift: passes the resolver's family and effective size into loadContent, and watches MonoFontSpec(resolver:) with .onChange to call updateWidestLine.
  • prism/Theme/TypographyResolver.swift: baselineSize(for:) and its static tables are explicitly nonisolated so the function is usable as a default argument under SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor.
  • Tests: 24 new/rewritten cases across MonospaceMetricsTests, RawSourceViewModelTests and the new hosted-Text parity suite.

Implementation approach

The design splits two questions. Which line is widest is font-dependent (tab stops are fixed-point) and O(lines), so it runs off-main at parse time and again on every mono font change. How wide is that line is O(1) and runs on every render, memoised. The selection pass measures only lines that can win: on a fixed-pitch font (checked via isFixedPitch, not assumed) a printable-ASCII line's width is exactly count × advance, so only the longest such line is measured; every tab/non-ASCII line is measured because no per-glyph factor bounds them. A font change that lands mid-parse is not lost: updateWidestLine records the newest font and returns while isLoading, and loadContent compares after its parse and re-selects.

Trade-offs

  • Real measurement over a column heuristic: only real layout reproduces fixed-point tab stops.
  • Re-select on font change instead of reparse: avoids redoing syntax highlighting for a width-only change, at the cost of a second state machine (widestLineFont, remeasureTask).
  • No isDocumentTooLarge bound: that threshold exists to cap highlighting; capping measurement would reintroduce the clipping for the largest documents. A CJK-only document therefore measures every line.
  • Single-slot process-global memo: bounded memory, but thrashes when two windows alternate renders.

Technical deep dive

The exactness claim for the shortcut rests on two facts: (1) NSFont.isFixedPitch / .traitMonoSpace is checked on the resolved font rather than assumed from the family; (2) within 0x20–0x7E every glyph in a fixed-pitch font shares one advance, so width is monotonic in count. testWidestLineMatchesExhaustiveMeasurement pins prefiltered == exhaustive over a mixed corpus at three sizes and two families. Residual: a fixed-pitch font that applies width-changing ligature substitution to ASCII (rare; programming ligatures are designed advance-preserving) or has ASCII fallback glyphs. Since Menlo and SF Mono are the app's defaults this is theoretical.

Concurrency: resolveFont, lineWidth, widestLine, isFixedPitch, isPlainASCII are explicitly nonisolated, and both Task.detached closures capture only Sendable values. NSStringDrawing and NSFont(name:) are documented thread-safe; note the pre-existing measure stays @MainActor, so the file now holds two opinions about the same API's isolation. The widestLine loop has no Task.isCancelled checkpoint; remeasureTask?.cancel() therefore only discards results. The .onChange handler spawns an unstructured Task per change — from a MainActor context these enqueue FIFO in practice but with no language guarantee; an inversion pins widestLineFont to the older font until the next change.

The post-parse re-selection guard loadingContentHash == contentHash, !isLoading, widestLineFont != font is correct; the first two clauses are optimisations, since remeasureWidestLine's own generation/font guards already drop stale writes.

Architecture impact

MonoFontSpec is the right seam and should become the parameter of loadContent (the view already constructs one two lines away); that would also delete the default-argument that forced nonisolated onto baselineSize and its tables. MonospaceMetrics.charWidth, its cache and clearCache now have no production callers, and measure is lineWidth("M"). RawSourceContentWidth's memo re-derives a width widestLine already computed and threw away; returning (text, width) and re-measuring only when render font differs from selection font removes the global static and both test hooks.

Potential issues

  • Worst case is a full layout pass per font change over a 10 MB CJK/tab-heavy document (~250k NSString.size calls, seconds), repeated per stepper tick with no cancellation. Correct, but a plausible stall.
  • iOS Dynamic Type: scaledMonoFont uses relativeTo: .body for custom families, which scales further than MonoFontSpec.size. Pre-existing from T-995; the parity tests run at default text size and would not catch it.
  • testPlainShortcutIsLive asserts "Courier" reports mono-space on iOS too when run under make test; unverified here.
  • 3 new test-target warnings on sampleLines become errors in Swift 6 language mode.

Important changes — detailed

MonospaceMetrics: widestLine / lineWidth replace count x "M"

prism/Services/MonospaceMetrics.swift

Why it matters. The fix itself. Real text measurement is the only way to reproduce fixed-point tab stops and wide glyphs; the plain-ASCII shortcut keeps the pass from being a full layout of every document.

What to look at. MonospaceMetrics.swift: resolveFont, lineWidth, widestLine, isPlainASCII, usesPlainShortcut, isFixedPitch (lines ~114-281 of the diff)

Takeaway. When a prefilter must be exact, derive it from a property you can check on the resolved object (isFixedPitch) rather than a heuristic factor, and pin prefiltered == exhaustive in a test. The doc comment explicitly forbids the tempting count-times-factor prefilter and says why.
Rationale. A tab's advance depends on its position relative to fixed-point default tab stops, and a single grapheme cluster can render from zero width to several ems, so no per-glyph factor bounds those lines; only printable ASCII on a fixed-pitch font is exactly count x advance.

RawSourceViewModel: font-change re-selection state machine

prism/ViewModels/RawSourceViewModel.swift

Why it matters. Which line is widest depends on the font, so a scale change must re-pick, not just re-measure. This introduces widestLineFont, remeasureTask, updateWidestLine and a post-parse re-selection in loadContent; the main correctness surface of the branch.

What to look at. RawSourceViewModel.swift: loadContent (post-parse check), updateWidestLine(font:), remeasureWidestLine(), reset()

Takeaway. Record the newest input first and let the in-flight worker notice the mismatch when it lands; that handles the mid-parse race without locking. The guard on apply (generation + font + isCancelled) is what makes the write safe, not the ordering of callers.
Rationale. Reparsing on a font change would redo syntax highlighting for a width-only change; re-selection over the existing lines is far cheaper. A font change landing mid-parse is applied by loadContent once its parse lands.

RawSourceContentWidth.compute: measure one line, memoise one slot

prism/Services/MonospaceMetrics.swift

Why it matters. Runs on every body evaluation of the scroll content; the memo is what keeps the render path O(1) now that the measurement is a real layout call rather than an integer multiply.

What to look at. MonospaceMetrics.swift: MeasurementKey, lastMeasurement, compute(widestLineText:viewportWidth:resolver:), clearCache

Takeaway. A single-slot memo keyed on the full input is a bounded, unbounded-growth-proof alternative to a dictionary when the common case is 'inputs unchanged since last call'. It is defeated by two alternating callers (two windows), which the author accepts.
Rationale. Keying a dictionary on arbitrary line text would grow without bound over a session that opens many documents; there are only ever a handful of (family, size) pairs but unbounded texts.

TypographyResolver.baselineSize: explicit nonisolated

prism/Theme/TypographyResolver.swift

Why it matters. Resolves the one compiler warning from the previous review and makes the off-main measurement path genuinely off-main; under SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor an unannotated static is main-actor-isolated even if a comment says otherwise.

What to look at. TypographyResolver.swift: baselineSizes, defaultBaseline, baselineSize(for:)

Takeaway. A default-argument expression compiles into its own nonisolated thunk, so anything it calls must be nonisolated; and a nonisolated function cannot read an implicitly @MainActor static let even when the value is Sendable. Both keywords are needed.
Rationale. Stated in the commit body and the doc comment: the warning is a hard error under Swift 6, and the 'nonisolated' claim in the original change was only a comment.

T-700 tests: assert against loadingContentHash, not declaration order

prismTests/RawSourceViewModelTests.swift

Why it matters. These were the failing tests that blocked the previous review. The rewrite asserts the actual T-700 invariant (lines belong to the last-started load) rather than an ordering async let never guaranteed.

What to look at. RawSourceViewModelTests.swift: lastStartedContent(in:of:), staleTaskDoesNotClearLoading, rapidLoadContentLeavesConsistentState

Takeaway. async let children start nonisolated and race their hop to the main actor; a test that assumes declaration order is asserting scheduler behaviour. Deriving the oracle from the SUT's own guard is a mild weakening (a stale hash overwrite would agree with itself); one sequential test pinning the literal result would close that.
Rationale. The extra measurement in loadContent shifted timing enough that the old assumption failed 2/2 on the branch tip; the hash guard is what the view model actually promises.

MonospaceMetricsRenderParityTests: hosted Text vs lineWidth

prismTests/MonospaceMetricsRenderParityTests.swift

Why it matters. The measurement (NSString drawing, no paragraph style) and the render (SwiftUI Text) are two different layout entry points; a systematic tab-stop disagreement would reproduce the original bug with all unit tests green.

What to look at. MonospaceMetricsRenderParityTests.swift: renderedWidth(of:family:size:), three parameterised @Test functions over sampleLines

Takeaway. When a fix assumes two APIs agree, host the real view and compare. Tolerance is one 'M' advance, absolute; all samples are under 45 characters, so a proportional 3-5% divergence would still pass, and a 200+ character sample would tighten that.
Rationale. Stated in the file header: an assumption about two layout entry points is not a guarantee; the previous review asked for the hosting to be async in a @MainActor suite, which it now is.

Key decisions

Measure real text rather than a column-weighting heuristic.

Tab stops are fixed-point (~28pt regardless of font size), so no font-relative column count reproduces them. Source: bugfix report, Investigation and Approach rationale.

Split 'which line is widest' from 'how wide is it'.

Selection is O(lines) and font-dependent, so it runs off-main at parse time and on font change; width of the selected line is O(1) and memoised at render. Source: report Approach rationale and compute doc comment.

Plain-ASCII shortcut checked on <code>isFixedPitch</code>; no count-times-factor prefilter.

A reviewer suggested a 'count within a factor of the max' prefilter; rejected because no factor bounds a tab or a grapheme cluster. Only the longest printable-ASCII line is measured, and only when the resolved font reports fixed pitch. Source: commit body, widestLine doc comment, agent note.

Do not bound measurement by <code>isDocumentTooLarge</code>.

That threshold exists to cap highlighting; skipping measurement above it would reintroduce the clipping for the largest documents. Source: report Review follow-up. Consequence accepted: a CJK-only document measures every line on each font change.

Re-select on mono font change without reparsing.

RawSourceView watches MonoFontSpec(resolver:) and calls updateWidestLine; a reparse would redo highlighting for a width-only change. A mid-parse change is recorded first and applied by loadContent after its parse lands. Source: commit body and report.

Use <code>.onChange</code> + unstructured <code>Task</code> rather than <code>.task(id:)</code> for the font watch.

No rationale given anywhere. The adjacent recolor watch uses the same shape, so consistency is the likely reason. .task(id: MonoFontSpec(resolver:)) would give cancellation on change, ordering, and view-lifetime scoping for free.

(inferred — not stated by the author.)
Explicit <code>nonisolated</code> on the measurement path and <code>baselineSize</code>.

Under SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor the 'nonisolated' claim was only a comment; the default-argument thunk produced the build warning. Source: commit body, TypographyResolver doc comment, agent note.

Single-slot process-global memo in <code>RawSourceContentWidth</code>.

Kept from the first commit despite the previous review's note; justified in code as bounded (a dictionary keyed on line text would grow without bound). Source: lastMeasurement doc comment.

T-700 tests identify the last-started load via <code>loadingContentHash</code>.

Declaration order of async let children is not their arrival order on the main actor; the hash guard is the view model's actual invariant. loadingContentHash made private(set) for this. Source: commit body and test doc comments.

Review findings

SeverityAreaFindingResolution
minorRawSourceView.onChange / MonospaceMetrics.widestLineThe font-change re-selection is an unstructured Task per change with no ordering guarantee, and widestLine's loop has no Task.isCancelled checkpoint, so remeasureTask?.cancel() only discards results. A held text-size Stepper (key-repeat) on a tab/CJK-heavy document runs every superseded full pass to completion; an inversion of two Tasks pins widestLineFont to the older font until the next change.Not blocking: correctness self-heals on the next font change and a pathological document is required. Recommend .task(id: MonoFontSpec(resolver:)) (cancels on change, ordered, scoped to the view) plus a periodic Task.isCancelled check in widestLine, as RawSourceHighlightParser.parse already does every 500 lines. Read-only review: not applied.
minorprismTests/MonospaceMetricsRenderParityTests.swift:88Three new test-target warnings: 'main actor-isolated static property sampleLines cannot be accessed from outside of the actor; this is an error in the Swift 6 language mode'. The @Test(arguments:) macro reads the static from a nonisolated thunk. Same class as the baselineSize warning the previous review flagged; invisible to make build-macos/build-ios because they build the app target only.Declare it nonisolated private static let sampleLines. Read-only review: not applied.
minorspecs/bugfixes/raw-source-nowrap-width/report.mdInternal contradictions: 'Changes made' says maxLineLength is 'retained unchanged as a simple statistic' while 'Review follow-up' says it was removed (the code has no maxLineLength); the Affected Files table omits TypographyResolver.swift, the new MonospaceMetricsRenderParityTests.swift and both agent notes; the Run command omits the parity suite the Verification section says passed; Verification calls the T-700 test 'pre-existing, unrelated' while the follow-up says this branch exposed it.Rewrite the stale 'retained' sentence, add the missing rows, add -only-testing:prismTests/MonospaceMetricsRenderParityTests to the run command, and reword the T-700 note. Read-only review: not applied.
minorMonospaceMetrics.charWidth / cache / clearCache / measureNo production callers remain (only tests use charWidth); measure is now a special case of lineWidth("M"). Dead code plus a permanently live @MainActor dictionary, and specs/font-settings/*.md and docs/agent-notes/typography-font-settings.md still describe the char-width path as live.Either delete the char-width API (tests use it as a tolerance helper; lineWidth("M") serves) or shrink it to a memo over lineWidth, and update the font-settings docs. Read-only review: not applied.
minorloadContent(monoFontFamily:monoFontSize:) and repeated effective-size expressionloadContent takes family and size separately and rebuilds MonoFontSpec at once; RawSourceView splits apart a spec it constructs two lines later; baselineSize(for: .body) * scaleFactor is repeated at RawSourceView:103, MonospaceMetrics:261 and :327 on top of five in-type repeats in TypographyResolver. The defaults silently mean 'system mono at 100%' and a future caller that omits them gets a wrong selection that updateWidestLine never corrects (it fires only on change).Take font: MonoFontSpec (no default) and add TypographyResolver.scaledMonoSize; this also removes the reason baselineSize had to become nonisolated. Read-only review: not applied.
minorRawSourceContentWidth memowidestLine computes widestWidth and discards it; compute then re-measures the same string behind a process-global single slot, which thrashes when two windows alternate renders and needs a clearCache test hook. Retained from the previous review.Return (text, width) from widestLine, store alongside widestLineText, and re-measure in compute only when the render font differs from the selection font; deletes the static and both clearCache calls. Read-only review: not applied.
minorTest coverage gapsNo widestLine case for a proportional family (shortcut off); no compute-memo or updateWidestLine case for a FAMILY change (only text and size); updateWidestLineDuringParseIsAppliedAfterwards accepts both race outcomes so may never exercise the mid-parse branch, and uses a 20,000-line filler with up to 10 s of 1 ms polling; updateWidestLineSameFontIsNoOp asserts a guard clause; testPlainShortcutIsLive asserts an implementation detail via a test-only production API and assumes 'Courier' reports mono-space on iOS.Add a Helvetica-vs-exhaustive case and a family-change case each; assert viewModel.isLoading before the mid-parse updateWidestLine call so a fast host fails loudly instead of degenerating; bound by deadline. Read-only review: not applied.
nitMonospaceMetrics.isFixedPitch vs FontUtilitiesiOS branch duplicates FontUtilities.swift:33 verbatim; macOS branch uses NSFont.isFixedPitch while FontUtilities uses symbolicTraits.contains(.monoSpace), so the codebase now answers 'is this monospace?' two ways.One FontUtilities.isFixedPitch(_ font:) used by both. Read-only review: not applied.
nitMonospaceMetricsRenderParityTests header commentThe header cites make verify-test-isolation, but Tools/check-webkit-test-isolation.py seeds only WebKit types; NSHostingView/UIHostingController are not covered by either the sync-construction rule or the .liveWebKit budget. The suite follows the rule by hand (async bodies, @MainActor suite), which is correct, but the comment overstates the guard.Reword to 'follows the same rule by hand' or add the hosting types to the guard's seed list in a separate change. Read-only review: not applied.
nitRawSourceView.contentMinWidthStill constructs TypographyResolver(from: settings) inline while the file has a typographyResolver property the new code uses at lines 97 and 106; also a duplicate construction in scaledMonoFont.Use the property. Read-only review: not applied.
nitDecision-log conventionOther bugfixes (e.g. b249ae37, fb4afdf7) appended numbered entries to the owning feature's decision_log.md. Two contested decisions here (re-select on font change rather than reparse; no isDocumentTooLarge bound) live only in the bugfix report.Add two entries to specs/raw-source-toggle/decision_log.md. Read-only review: not applied.

Tests

Source: local run at 2026-09-06T02:55:00+10:00 · snapshot 7f2b650e

Baseline: none

Execution: passed · JUnit: 1 file · Coverage: none · Baseline: absent

Coverage scope: as the project configures it

Totals: 82 passed · 0 failed · 0 skipped · 0 errored · 0 flaky

New and removed tests

Derived by declaration name, from the diff (no baseline run).

Blast radius

Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base 2b10c6dc4944d8e50c99e6b60e962591a230b39f.

addedmodifieddeletedrenamedunchangedcollapsed package group or +N more⚑N test files with an edge to the node

Skipped files

Per-file diffs

Click to expand.

prism/Services/MonospaceMetrics.swift Modified +237 / -30
diff --git a/prism/Services/MonospaceMetrics.swift b/prism/Services/MonospaceMetrics.swiftindex 26ed3a6b..3ea5c914 100644--- a/prism/Services/MonospaceMetrics.swift+++ b/prism/Services/MonospaceMetrics.swift@@ -11,6 +11,11 @@ //  long lines becomes unreachable when the user scales text up or selects //  a wider custom mono font (T-995). //+//  `charWidth` alone is not enough to size a whole line: a single "M"+//  advance times a grapheme count assumes every character is exactly one+//  "M" wide, which tabs, CJK, and emoji are not (T-1830). `lineWidth` and+//  `widestLine` measure real text instead.+//  import SwiftUI @@ -66,25 +71,29 @@ enum MonospaceMetrics {     ///     /// 0.6em is the usual advance ratio for a monospace "M" and only has to be     /// good enough to keep a scroll width sane; the alternative is aborting.-    private static let fallbackAdvanceRatio: CGFloat = 0.6+    nonisolated private static let fallbackAdvanceRatio: CGFloat = 0.6 -    @MainActor-    private static func measure(family: String?, size: CGFloat) -> CGFloat {-        // `monospacedSystemFont(ofSize:weight:)` is imported as NON-optional,-        // but it is an ObjC API and that annotation is not a guarantee. It has-        // been observed returning nil on a saturated host: the nil then lands-        // in the attributes dictionary below, and CoreText raises-        // `NSInvalidArgumentException` from-        // `-[__NSPlaceholderDictionary initWithObjects:forKeys:count:]` while-        // copying it. That is an ObjC exception, so it does not fail the-        // measurement — it ABORTS the process, taking every queued test down-        // with it (observed: one abort here cascaded 572 tests).-        //-        // Binding through an Optional makes the lie checkable. Cheap insurance-        // on a cached path, and it matters in the app too: this runs on the-        // raw-source width computation, where a nil font would otherwise crash-        // the reader rather than mis-size a scroll view.-        #if os(macOS)+    // `monospacedSystemFont(ofSize:weight:)` is imported as NON-optional, but+    // it is an ObjC API and that annotation is not a guarantee. It has been+    // observed returning nil on a saturated host: the nil then lands in the+    // attributes dictionary below, and CoreText raises+    // `NSInvalidArgumentException` from+    // `-[__NSPlaceholderDictionary initWithObjects:forKeys:count:]` while+    // copying it. That is an ObjC exception, so it does not fail the+    // measurement — it ABORTS the process, taking every queued test down with+    // it (observed: one abort here cascaded 572 tests).+    //+    // Binding through an Optional makes the lie checkable. Cheap insurance on+    // a cached path, and it matters in the app too: this runs on the+    // raw-source width computation, where a nil font would otherwise crash+    // the reader rather than mis-size a scroll view.+    //+    // Deliberately `nonisolated` (stated, not left to inference — this+    // target's default isolation is `MainActor`): it touches no shared+    // state, so `widestLine(in:family:size:)` can call it from a background+    // parse task without a main-actor hop per line (T-1830).+    #if os(macOS)+    nonisolated private static func resolveFont(family: String?, size: CGFloat) -> NSFont? {         var font: NSFont?         if let family {             font = NSFont(name: family, size: size)@@ -92,7 +101,10 @@ enum MonospaceMetrics {         if font == nil {             font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)         }-        #else+        return font+    }+    #else+    nonisolated private static func resolveFont(family: String?, size: CGFloat) -> UIFont? {         var font: UIFont?         if let family {             font = UIFont(name: family, size: size)@@ -100,10 +112,156 @@ enum MonospaceMetrics {         if font == nil {             font = UIFont.monospacedSystemFont(ofSize: size, weight: .regular)         }-        #endif-        guard let font else { return size * fallbackAdvanceRatio }+        return font+    }+    #endif++    @MainActor+    private static func measure(family: String?, size: CGFloat) -> CGFloat {+        guard let font = resolveFont(family: family, size: size) else {+            return size * fallbackAdvanceRatio+        }         return ("M" as NSString).size(withAttributes: [.font: font]).width     }++    /// Measures the rendered width of a whole line of `text` — tabs, wide+    /// CJK glyphs, and emoji included — instead of assuming every character+    /// has the same advance as "M" (T-1830). A tab's advance in particular+    /// depends on the text system's default tab stops rather than the font's+    /// per-character width, so only measuring the real string reproduces it.+    ///+    /// Not cached and touches no shared state, so it is safe to call from a+    /// background context (e.g. a detached parse task) without hopping to+    /// the main actor.+    nonisolated static func lineWidth(_ text: String, family: String?, size: CGFloat) -> CGFloat {+        guard !text.isEmpty else { return 0 }+        guard let font = resolveFont(family: family, size: size) else {+            return CGFloat(text.count) * size * fallbackAdvanceRatio+        }+        return (text as NSString).size(withAttributes: [.font: font]).width+    }++    /// Finds the line with the greatest RENDERED width among `lines`,+    /// measured with the actual font rather than compared by character+    /// count (T-1830): a short line full of tabs, CJK, or emoji can render+    /// far wider than a longer plain-ASCII line, and grapheme count alone+    /// can never tell them apart.+    ///+    /// Only the lines that can win are measured. Measuring every line is a+    /// full text-layout pass over the document on each parse (and on each+    /// mono font change), so lines are split into two classes:+    ///+    /// - **Plain** — every scalar is printable ASCII (space through tilde).+    ///   In a fixed-pitch font each of those glyphs has the same advance, so+    ///   a plain line's width is exactly `count × advance` and width is+    ///   monotonic in count: only the longest plain line is measured. This+    ///   is exact, not a heuristic — fixed pitch is what "monospace" means,+    ///   and it is checked on the resolved font (`isFixedPitch`) rather than+    ///   assumed, so a family that turns out proportional simply measures+    ///   all of its lines.+    /// - **Everything else** — any tab or non-ASCII scalar. Each such line is+    ///   measured, because no per-glyph ratio can bound it: a tab's advance+    ///   depends on its position relative to the fixed-point default tab+    ///   stops, not on the font at all, and one grapheme cluster can render+    ///   anywhere from zero width (a lone combining mark) to several ems+    ///   (U+FDFA, flag and ZWJ emoji sequences). A count-times-factor+    ///   prefilter would have to pick a factor no glyph exceeds, and there+    ///   is no such factor.+    ///+    /// The cost is therefore proportional to the number of tab/non-ASCII+    /// lines, which in typical markdown is a handful; a document written+    /// entirely in CJK measures every line, exactly as before.+    ///+    /// Resolves the font once and reuses it for every measured line, since+    /// re-resolving per line would multiply the (small but nonzero)+    /// font-lookup cost by the line count.+    nonisolated static func widestLine(in lines: [String], family: String?, size: CGFloat) -> String {+        guard let font = resolveFont(family: family, size: size) else {+            // No usable font at all — fall back to the pre-fix approximation+            // (longest by character count) rather than measuring nothing.+            return lines.max(by: { $0.count < $1.count }) ?? ""+        }++        var candidates: [String] = []+        if isFixedPitch(font) {+            var longestPlain = ""+            for text in lines where !text.isEmpty {+                if isPlainASCII(text) {+                    // ASCII only, so UTF-8 length is the character count.+                    if text.utf8.count > longestPlain.utf8.count { longestPlain = text }+                } else {+                    candidates.append(text)+                }+            }+            if !longestPlain.isEmpty { candidates.append(longestPlain) }+        } else {+            candidates = lines.filter { !$0.isEmpty }+        }++        var widestWidth: CGFloat = -1+        var widestText = ""+        for text in candidates {+            let width = (text as NSString).size(withAttributes: [.font: font]).width+            if width > widestWidth {+                widestWidth = width+                widestText = text+            }+        }+        return widestText+    }++    /// True when every scalar of `text` is printable ASCII (space through+    /// tilde) — the one class of text whose width in a fixed-pitch font is+    /// exactly `count × advance`, so it can be compared by count alone.+    /// A tab (0x09) is deliberately outside the range.+    nonisolated static func isPlainASCII(_ text: String) -> Bool {+        text.utf8.allSatisfy { $0 >= 0x20 && $0 <= 0x7E }+    }++    /// Whether `widestLine` takes the plain-ASCII shortcut for this font,+    /// i.e. the resolved font reports itself fixed pitch. Exposed so a test+    /// can pin that the shortcut is live for the fonts the app uses — if+    /// it silently went dark, `widestLine` would still be correct but back+    /// to measuring every line.+    nonisolated static func usesPlainShortcut(family: String?, size: CGFloat) -> Bool {+        guard let font = resolveFont(family: family, size: size) else { return false }+        return isFixedPitch(font)+    }++    #if os(macOS)+    nonisolated private static func isFixedPitch(_ font: NSFont) -> Bool {+        font.isFixedPitch+    }+    #else+    nonisolated private static func isFixedPitch(_ font: UIFont) -> Bool {+        font.fontDescriptor.symbolicTraits.contains(.traitMonoSpace)+    }+    #endif+}++/// The mono font a raw-source line is rendered with: family (`nil` means the+/// platform's monospaced system font) and EFFECTIVE point size — baseline+/// size × user scale, the size `TypographyResolver.scaledMonoFont` builds+/// its `Font` with. Equatable so `RawSourceView` can watch it for changes+/// and `RawSourceViewModel` can record which font its widest-line selection+/// was made against (T-1830).+struct MonoFontSpec: Hashable, Sendable {+    let family: String?+    let size: CGFloat++    init(family: String?, size: CGFloat) {+        self.family = family+        self.size = size+    }++    /// The spec `RawSourceView.lineView` renders with under `resolver`.+    @MainActor+    init(resolver: TypographyResolver) {+        self.init(+            family: resolver.monoFontFamily,+            size: TypographyResolver.baselineSize(for: .body) * resolver.scaleFactor+        )+    } }  /// Computes the minimum content width for the raw-source horizontal scroll@@ -120,26 +278,75 @@ enum RawSourceContentWidth {     /// slightly larger scrollable area.     static let safetyMultiplier: CGFloat = 1.1 +    /// Memoizes the last (text, family, size) measurement. `compute` runs on+    /// every render of `RawSourceView`'s scroll content, and the common case+    /// is that none of the three inputs changed since the previous call, so+    /// this skips re-measuring the widest line's text — which, unlike the+    /// old grapheme-count model, is a real (and potentially expensive for a+    /// very long line) text measurement (T-1830).+    ///+    /// A single slot rather than a dictionary keyed on `MonospaceMetrics`'s+    /// (family, size) pattern: that cache holds one entry per font+    /// configuration ever seen, which is fine because there are only ever a+    /// handful of those. Keying on arbitrary line TEXT here would grow+    /// without bound over a session that opens many different documents.+    private struct MeasurementKey: Equatable {+        let text: String+        let family: String?+        let size: CGFloat+    }++    @MainActor private static var lastMeasurement: (key: MeasurementKey, width: CGFloat)?+     /// Computes the `minWidth` for the scroll content frame.     ///+    /// This measures ONE line — whichever `widestLineText` is — against the+    /// current family and effective size (both part of the memo key, so a+    /// family, scale, or text change re-measures; T-995). It does not decide+    /// which line is widest: that selection is font-dependent too (tab stops+    /// are fixed-point, so a tab-heavy line that wins at 100% scale can lose+    /// to a plain line at 200%), and it is `RawSourceViewModel`'s job —+    /// re-run on every mono font change via `updateWidestLine(font:)` as+    /// well as on parse — so the text handed in here is already the widest+    /// line FOR THE FONT being measured with.+    ///     /// - Parameters:-    ///   - maxLineLength: Character count of the longest source line.+    ///   - widestLineText: The raw text of the document's widest RENDERED+    ///     line (see `RawSourceViewModel.widestLineText`) — not simply the+    ///     longest by character count, which undercounts tabs, CJK, and+    ///     emoji (T-1830).     ///   - viewportWidth: Width of the surrounding scroll viewport.     ///   - resolver: The active `TypographyResolver` — used both to pick the     ///     mono family and to compute the effective rendered size.     @MainActor     static func compute(-        maxLineLength: Int,+        widestLineText: String,         viewportWidth: CGFloat,         resolver: TypographyResolver     ) -> CGFloat {         let renderedSize = TypographyResolver.baselineSize(for: .body) * resolver.scaleFactor-        let charWidth = MonospaceMetrics.charWidth(-            family: resolver.monoFontFamily,-            size: renderedSize-        )-        let textWidth = CGFloat(maxLineLength) * charWidth * safetyMultiplier-            + horizontalPaddingAllowance-        return max(viewportWidth, textWidth)+        let key = MeasurementKey(text: widestLineText, family: resolver.monoFontFamily, size: renderedSize)++        let textWidth: CGFloat+        if let cached = lastMeasurement, cached.key == key {+            textWidth = cached.width+        } else {+            textWidth = MonospaceMetrics.lineWidth(+                widestLineText,+                family: resolver.monoFontFamily,+                size: renderedSize+            )+            lastMeasurement = (key, textWidth)+        }++        let contentWidth = textWidth * safetyMultiplier + horizontalPaddingAllowance+        return max(viewportWidth, contentWidth)+    }++    /// Clears the memoized measurement. Used by tests so each test starts+    /// with no stale cached width from a previous one.+    @MainActor+    static func clearCache() {+        lastMeasurement = nil     } }
prism/ViewModels/RawSourceViewModel.swift Modified +97 / -6
diff --git a/prism/ViewModels/RawSourceViewModel.swift b/prism/ViewModels/RawSourceViewModel.swiftindex 56db3943..4c535c92 100644--- a/prism/ViewModels/RawSourceViewModel.swift+++ b/prism/ViewModels/RawSourceViewModel.swift@@ -45,8 +45,26 @@ final class RawSourceViewModel {     /// When true, highlighting is automatically disabled to maintain performance (req 9.4).     private(set) var isDocumentTooLarge: Bool = false -    /// Character count of the longest line, used to calculate content width for horizontal scrolling.-    private(set) var maxLineLength: Int = 0+    /// Raw text of the document's RENDERED-widest line, used to calculate+    /// content width for horizontal scrolling when line wrapping is off.+    ///+    /// This is the actual text of whichever line measures widest with the+    /// font active at parse time (`MonospaceMetrics.widestLine`), not simply+    /// the longest by character count. A short line full of tabs, CJK, or+    /// emoji can render far wider than a longer plain-ASCII line, and the+    /// previous grapheme-count model could never tell them apart, leaving+    /// the scrollable area too narrow to reach the true end of such lines+    /// (T-1830). `RawSourceContentWidth.compute` re-measures this text at+    /// the CURRENT font on every render, and a family or scale change+    /// without a reparse re-selects the line via `updateWidestLine(font:)`,+    /// since which line is widest depends on the font too.+    private(set) var widestLineText: String = ""++    /// The mono font `widestLineText` was selected against, or should be:+    /// `updateWidestLine(font:)` records the newest font here first, so a+    /// parse that finishes after a font change can see it was selected+    /// against a stale font and re-select (T-1830).+    private var widestLineFont: MonoFontSpec?      /// VoiceOver chunk navigation state.     /// Key: chunk index, Value: current line index within chunk.@@ -70,7 +88,12 @@ final class RawSourceViewModel {     @ObservationIgnored var scrollOffset: CGFloat = 0      /// Content hash being loaded, used to prevent stale results from overwriting.-    private var loadingContentHash: Int?+    ///+    /// Readable so a test can identify which of several concurrently issued+    /// loads was the LAST TO START — `async let` children reach the main+    /// actor in no guaranteed order — and assert `lines` belongs to that one+    /// (T-700).+    private(set) var loadingContentHash: Int?      /// Identifies the document currently assigned to `lines`.     ///@@ -92,6 +115,11 @@ final class RawSourceViewModel {     /// Current recolor task (for cancellation on rapid theme changes).     private var recolorTask: Task<Void, Never>? +    /// In-flight widest-line re-selection (see `remeasureWidestLine`), so a+    /// newer font change or a new document cancels the measurement rather+    /// than letting it run to a result that would be discarded anyway.+    private var remeasureTask: Task<String, Never>?+     // MARK: - Thresholds      private enum Thresholds {@@ -146,6 +174,12 @@ final class RawSourceViewModel {     ///   - content: Raw markdown content to parse.     ///   - colors: Theme colors for syntax highlighting.     ///   - highlightingEnabled: Whether to apply syntax highlighting.+    ///   - monoFontFamily: The mono font family active for raw source right+    ///     now, used only to pick the widest RENDERED line (T-1830) — not+    ///     retained, since `RawSourceContentWidth.compute` re-measures that+    ///     line's text against whatever font is active at render time.+    ///   - monoFontSize: The mono font's effective point size (baseline size+    ///     × user scale), used the same way as `monoFontFamily`.     ///     /// Cancels any in-progress parse task before starting a new one (req 10.2).     /// Uses content hash to prevent stale results from overwriting when@@ -153,11 +187,14 @@ final class RawSourceViewModel {     func loadContent(         _ content: String,         colors: any ThemeColors,-        highlightingEnabled: Bool+        highlightingEnabled: Bool,+        monoFontFamily: String? = nil,+        monoFontSize: CGFloat = TypographyResolver.baselineSize(for: .body)     ) async {         // Cancel any in-progress parse (req 10.2, 10.3)         parseTask?.cancel()         recolorTask?.cancel()+        remeasureTask?.cancel()          let contentHash = content.hashValue         loadingContentHash = contentHash@@ -171,6 +208,8 @@ final class RawSourceViewModel {         let effectiveHighlighting = highlightingEnabled && !documentTooLarge          let parser = RawSourceHighlightParser()+        let font = MonoFontSpec(family: monoFontFamily, size: monoFontSize)+        widestLineFont = font          parseTask = Task.detached(priority: .userInitiated) { [colors] in             let computedLines = parser.parse(@@ -179,6 +218,15 @@ final class RawSourceViewModel {                 enableHighlighting: effectiveHighlighting             ) +            // Measured here, off the main actor: `widestLine` resolves a font+            // and measures every line's real width, which would block the+            // UI if it ran inside the `MainActor.run` block below (T-1830).+            let widestLineText = MonospaceMetrics.widestLine(+                in: computedLines.map(\.text),+                family: font.family,+                size: font.size+            )+             await MainActor.run { [weak self] in                 guard let self = self else { return } @@ -192,12 +240,52 @@ final class RawSourceViewModel {                  self.lines = computedLines                 self.linesGeneration &+= 1-                self.maxLineLength = computedLines.reduce(0) { max($0, $1.text.count) }+                self.widestLineText = widestLineText                 self.isLoading = false             }         }          await parseTask?.value++        // The font changed while this parse was in flight; `updateWidestLine`+        // left the re-selection to us. Only the load that is still current+        // does it — a superseded call's parse never wrote its results.+        if loadingContentHash == contentHash, !isLoading, widestLineFont != font {+            await remeasureWidestLine()+        }+    }++    /// Re-selects the widest rendered line for a new mono font WITHOUT+    /// reparsing (T-1830). Which line renders widest is font-dependent —+    /// tab stops are fixed-point, so a tab-heavy line that wins at 100%+    /// scale loses to a longer plain line at 200% — so re-measuring the+    /// already-chosen line (`RawSourceContentWidth.compute`) is not enough+    /// on its own. Cheap relative to a parse: no highlighting, and+    /// `MonospaceMetrics.widestLine` measures only the lines that can win.+    ///+    /// No-op when the font is unchanged. During a parse it only records the+    /// font; `loadContent` notices the mismatch once its parse lands and+    /// re-selects before it returns.+    func updateWidestLine(font: MonoFontSpec) async {+        guard font != widestLineFont else { return }+        widestLineFont = font+        guard !isLoading else { return }+        await remeasureWidestLine()+    }++    private func remeasureWidestLine() async {+        guard let font = widestLineFont else { return }+        remeasureTask?.cancel()+        let texts = lines.map(\.text)+        let generation = linesGeneration+        let task = Task.detached(priority: .userInitiated) {+            MonospaceMetrics.widestLine(in: texts, family: font.family, size: font.size)+        }+        remeasureTask = task+        let widest = await task.value+        // Drop the result if the document or the font moved on meanwhile.+        guard !task.isCancelled, linesGeneration == generation, widestLineFont == font else { return }+        widestLineText = widest     }      /// Recolors existing lines with new theme colors.@@ -264,7 +352,10 @@ final class RawSourceViewModel {         linesGeneration &+= 1         isLoading = true         isDocumentTooLarge = false-        maxLineLength = 0+        widestLineText = ""+        widestLineFont = nil+        remeasureTask?.cancel()+        remeasureTask = nil         voiceOverChunkState = [:]         contentHeight = 0         scrollOffset = 0
prism/Views/RawSourceView.swift Modified +19 / -2
diff --git a/prism/Views/RawSourceView.swift b/prism/Views/RawSourceView.swiftindex 45c1c535..c61248f9 100644--- a/prism/Views/RawSourceView.swift+++ b/prism/Views/RawSourceView.swift@@ -94,12 +94,24 @@ struct RawSourceView: View {         .background(colors.background)         .task(id: ContentIdentifier(content: content,                                      highlighting: settings.rawSourceSyntaxHighlighting)) {+            let resolver = typographyResolver             await viewModel.loadContent(                 content,                 colors: colors,-                highlightingEnabled: settings.rawSourceSyntaxHighlighting+                highlightingEnabled: settings.rawSourceSyntaxHighlighting,+                monoFontFamily: resolver.monoFontFamily,+                monoFontSize: TypographyResolver.baselineSize(for: .body) * resolver.scaleFactor             )         }+        .onChange(of: MonoFontSpec(resolver: typographyResolver)) { _, font in+            // Mono family or scale changed without a reparse: which line+            // renders widest can change with the font (tab stops are+            // fixed-point), so re-select it (T-1830). No reparse — that+            // would redo highlighting for what is only a width change.+            Task {+                await viewModel.updateWidestLine(font: font)+            }+        }         .onChange(of: colors.rawHeaderText.description) { _, _ in             // Theme changed - recolor existing lines with new colors (req 8.4)             // Uses rawHeaderText.description as a proxy for theme identity since@@ -211,10 +223,15 @@ struct RawSourceView: View {     ///     /// Uses `RawSourceContentWidth.compute` so the measurement is taken at the same     /// family and effective point size used by `scaledMonoFont` — see T-995.+    ///+    /// Measures the actual text of the widest RENDERED line+    /// (`viewModel.widestLineText`) rather than a character count times a+    /// single "M" advance, so tabs, CJK, and emoji are sized correctly+    /// (T-1830).     private func contentMinWidth(viewportWidth: CGFloat) -> CGFloat? {         guard !settings.rawSourceWrapLines else { return nil }         return RawSourceContentWidth.compute(-            maxLineLength: viewModel.maxLineLength,+            widestLineText: viewModel.widestLineText,             viewportWidth: viewportWidth,             resolver: TypographyResolver(from: settings)         )
prism/Theme/TypographyResolver.swift Modified +12 / -5
diff --git a/prism/Theme/TypographyResolver.swift b/prism/Theme/TypographyResolver.swiftindex 615b7230..f8f1191e 100644--- a/prism/Theme/TypographyResolver.swift+++ b/prism/Theme/TypographyResolver.swift@@ -44,7 +44,7 @@ struct TypographyResolver {     /// macOS uses 15pt body because the system default (13pt) is too small for reading.     /// Font.custom(relativeTo:) handles Dynamic Type scaling on iOS.     #if os(macOS)-    private static let baselineSizes: [Font.TextStyle: CGFloat] = [+    nonisolated private static let baselineSizes: [Font.TextStyle: CGFloat] = [         .title: 26,         .title2: 21,         .title3: 19,@@ -53,9 +53,9 @@ struct TypographyResolver {         .subheadline: 14,         .footnote: 12,     ]-    private static let defaultBaseline: CGFloat = 15+    nonisolated private static let defaultBaseline: CGFloat = 15     #else-    private static let baselineSizes: [Font.TextStyle: CGFloat] = [+    nonisolated private static let baselineSizes: [Font.TextStyle: CGFloat] = [         .title: 28,         .title2: 22,         .title3: 20,@@ -64,10 +64,17 @@ struct TypographyResolver {         .subheadline: 15,         .footnote: 13,     ]-    private static let defaultBaseline: CGFloat = 17+    nonisolated private static let defaultBaseline: CGFloat = 17     #endif -    static func baselineSize(for style: Font.TextStyle) -> CGFloat {+    /// `nonisolated` on purpose: this only indexes an immutable static table,+    /// and it is used as a DEFAULT ARGUMENT (`RawSourceViewModel.loadContent`).+    /// A default-argument expression compiles into its own thunk that is not+    /// isolated to the enclosing type's actor, so leaving this implicitly+    /// `@MainActor` (the target's default isolation) produced a "call to main+    /// actor-isolated static method in a synchronous nonisolated context"+    /// warning at that call site — a hard error under Swift 6 (T-1830 review).+    nonisolated static func baselineSize(for style: Font.TextStyle) -> CGFloat {         baselineSizes[style] ?? defaultBaseline     } 
prismTests/MonospaceMetricsRenderParityTests.swift Added +111 / -0
diff --git a/prismTests/MonospaceMetricsRenderParityTests.swift b/prismTests/MonospaceMetricsRenderParityTests.swiftnew file mode 100644index 00000000..197f16c5--- /dev/null+++ b/prismTests/MonospaceMetricsRenderParityTests.swift@@ -0,0 +1,111 @@+//+//  MonospaceMetricsRenderParityTests.swift+//  prismTests+//+//  Pins that `MonospaceMetrics.lineWidth` measures what SwiftUI `Text`+//  actually renders for the lines `RawSourceView.lineView` draws (T-1830).+//+//  The width measurement uses `NSString.size(withAttributes:)` with no+//  paragraph style, which lays tabs out against the text system's DEFAULT+//  tab stops. `RawSourceView.lineView` renders `Text(line.text)` with no+//  paragraph style either, so both should agree — but that is an assumption+//  about two different layout entry points, and a systematic mismatch larger+//  than `RawSourceContentWidth.safetyMultiplier` would leave the end of a+//  tab-heavy line unreachable exactly as the original bug did. These tests+//  host the real `Text` and compare.+//+//  Bodies are `async` in a `@MainActor` suite on purpose: hosting a view+//  must happen on the main thread, and a synchronous `@MainActor` test+//  gets no hop-on-entry in this target (see `make verify-test-isolation`).+//++import Testing+import SwiftUI+@testable import prism++#if os(macOS)+import AppKit+#else+import UIKit+#endif++@Suite("Monospace Metrics Render Parity")+@MainActor+struct MonospaceMetricsRenderParityTests {++    /// Measures the intrinsic width of the exact view `RawSourceView.lineView`+    /// builds for a plain (un-highlighted) line, using the same font the raw+    /// source view uses at the given family and size.+    private func renderedWidth(of text: String, family: String?, size: CGFloat) -> CGFloat {+        let font: Font = family.map { Font.custom($0, size: size) }+            ?? .system(size: size, design: .monospaced)+        let view = Text(text)+            .font(font)+            .lineLimit(1)+            .fixedSize()+        #if os(macOS)+        let host = NSHostingView(rootView: view)+        return host.fittingSize.width+        #else+        let host = UIHostingController(rootView: view)+        let bound = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)+        return host.sizeThatFits(in: bound).width+        #endif+    }++    /// Absolute tolerance for the comparison, in points. Sub-point layout+    /// rounding between the two entry points is expected; anything larger+    /// than one "M" advance is a real disagreement.+    private func tolerance(family: String?, size: CGFloat) -> CGFloat {+        MonospaceMetrics.charWidth(family: family, size: size)+    }++    private static let sampleLines: [String] = [+        "plain ascii line with nothing special in it",+        "a\tb",+        "id\tname\t日本語\t🎉done",+        // Past the text system's 12 default tab stops: tab advance beyond+        // the last explicit stop is where the two layout paths could+        // plausibly diverge.+        String(repeating: "\t", count: 15) + "end",+        "\t\tcol\t\tcol\t\tcol",+        "日本語テストテスト日本語テストテスト",+        "🎉🚀😀🔥✨🎉🚀😀🔥✨",+        "        eight leading spaces then text",+    ]++    /// Every sample line measures the same in `lineWidth` as the hosted+    /// `Text` renders it, at the platform baseline size with the system+    /// mono font.+    @Test("lineWidth matches the rendered Text width at the baseline size", arguments: sampleLines)+    func lineWidthMatchesRenderedTextBaseline(line: String) async {+        let size = TypographyResolver.baselineSize(for: .body)+        let measured = MonospaceMetrics.lineWidth(line, family: nil, size: size)+        let rendered = renderedWidth(of: line, family: nil, size: size)+        #expect(abs(measured - rendered) <= tolerance(family: nil, size: size),+                "lineWidth \(measured) vs rendered \(rendered) for \(line.debugDescription)")+    }++    /// The same holds at a doubled scale, where a tab's fixed-point stop is+    /// proportionally much narrower than a glyph — the case a font-relative+    /// tab model gets wrong.+    @Test("lineWidth matches the rendered Text width at 200% scale", arguments: sampleLines)+    func lineWidthMatchesRenderedTextScaled(line: String) async {+        let size = TypographyResolver.baselineSize(for: .body) * 2+        let measured = MonospaceMetrics.lineWidth(line, family: nil, size: size)+        let rendered = renderedWidth(of: line, family: nil, size: size)+        #expect(abs(measured - rendered) <= tolerance(family: nil, size: size),+                "lineWidth \(measured) vs rendered \(rendered) for \(line.debugDescription)")+    }++    /// And for a custom mono family, which is what a family-change+    /// re-measure runs against.+    @Test("lineWidth matches the rendered Text width for a custom family", arguments: sampleLines)+    func lineWidthMatchesRenderedTextCustomFamily(line: String) async {+        let size = TypographyResolver.baselineSize(for: .body)+        let measured = MonospaceMetrics.lineWidth(line, family: "Menlo", size: size)+        let rendered = renderedWidth(of: line, family: "Menlo", size: size)+        #expect(abs(measured - rendered) <= tolerance(family: "Menlo", size: size),+                "lineWidth \(measured) vs rendered \(rendered) for \(line.debugDescription)")+    }+}
prismTests/MonospaceMetricsTests.swift Modified +236 / -5
diff --git a/prismTests/MonospaceMetricsTests.swift b/prismTests/MonospaceMetricsTests.swiftindex 8437979a..e4d146e8 100644--- a/prismTests/MonospaceMetricsTests.swift+++ b/prismTests/MonospaceMetricsTests.swift@@ -84,7 +84,7 @@ struct MonospaceMetricsTests {      // MARK: - Width Computation -    /// `RawSourceView.contentMinWidth(maxLineLength:viewportWidth:resolver:)` must+    /// `RawSourceView.contentMinWidth(widestLineText:viewportWidth:resolver:)` must     /// grow when the user scales text up, holding all other factors constant.     /// Regression for T-995: at scale=200 with wrapping off, the right edge of     /// long lines became unreachable because the calculation used the unscaled@@ -92,19 +92,20 @@ struct MonospaceMetricsTests {     @Test("contentMinWidth grows with text scale")     func testContentMinWidthGrowsWithScale() {         MonospaceMetrics.clearCache()+        RawSourceContentWidth.clearCache()         let viewport: CGFloat = 400-        let lineLength = 200+        let line = String(repeating: "a", count: 200)          let resolver100 = TypographyResolver(bodyFontFamily: nil, monoFontFamily: nil, scalePercent: 100)         let resolver200 = TypographyResolver(bodyFontFamily: nil, monoFontFamily: nil, scalePercent: 200)          let width100 = RawSourceContentWidth.compute(-            maxLineLength: lineLength,+            widestLineText: line,             viewportWidth: viewport,             resolver: resolver100         )         let width200 = RawSourceContentWidth.compute(-            maxLineLength: lineLength,+            widestLineText: line,             viewportWidth: viewport,             resolver: resolver200         )@@ -120,13 +121,243 @@ struct MonospaceMetricsTests {     @Test("contentMinWidth never returns less than viewport width")     func testContentMinWidthFloor() {         MonospaceMetrics.clearCache()+        RawSourceContentWidth.clearCache()         let viewport: CGFloat = 800         let resolver = TypographyResolver(bodyFontFamily: nil, monoFontFamily: nil, scalePercent: 100)         let width = RawSourceContentWidth.compute(-            maxLineLength: 1,+            widestLineText: "a",             viewportWidth: viewport,             resolver: resolver         )         #expect(width >= viewport)     }++    /// `compute` must re-measure (not serve a stale cached width) when the+    /// text changes but family/size stay the same — the memoization is keyed+    /// on all three, not just family/size.+    @Test("contentMinWidth recomputes when the widest line text changes")+    func testContentMinWidthRecomputesOnTextChange() {+        MonospaceMetrics.clearCache()+        RawSourceContentWidth.clearCache()+        let resolver = TypographyResolver(bodyFontFamily: nil, monoFontFamily: nil, scalePercent: 100)++        let shortWidth = RawSourceContentWidth.compute(+            widestLineText: "short",+            viewportWidth: 100,+            resolver: resolver+        )+        let longWidth = RawSourceContentWidth.compute(+            widestLineText: String(repeating: "long line ", count: 20),+            viewportWidth: 100,+            resolver: resolver+        )++        #expect(longWidth > shortWidth)+    }++    // MARK: - Line Width Measurement (T-1830)++    /// A tab's advance comes from the text system's default tab stops, not+    /// the font's per-character width, so a tab must measure wider than a+    /// single "M" — the previous grapheme-count model treated it as exactly+    /// one character wide.+    @Test("lineWidth measures a tab as wider than a single character")+    func testLineWidthTabWiderThanSingleChar() {+        let charWidth = MonospaceMetrics.lineWidth("a", family: nil, size: 17)+        let tabWidth = MonospaceMetrics.lineWidth("\t", family: nil, size: 17)+        #expect(tabWidth > charWidth,+                "A tab (\(tabWidth)) must measure wider than a single character (\(charWidth))")+    }++    /// Ten tabs must measure much wider than ten plain characters — the+    /// T-1830 bug undercounted tab-heavy lines by treating every tab as one+    /// "M"-width column.+    @Test("lineWidth measures repeated tabs as much wider than the same count of plain characters")+    func testLineWidthTabsMuchWiderThanSameCountPlain() {+        let tabs = String(repeating: "\t", count: 10)+        let plain = String(repeating: "a", count: 10)+        let tabsWidth = MonospaceMetrics.lineWidth(tabs, family: nil, size: 17)+        let plainWidth = MonospaceMetrics.lineWidth(plain, family: nil, size: 17)+        #expect(tabsWidth > plainWidth * 1.5,+                "10 tabs (\(tabsWidth)) should measure well over 1.5x 10 plain characters (\(plainWidth))")+    }++    /// CJK glyphs render roughly twice as wide as Latin ones in a monospace+    /// font — a CJK line must measure wider than a same-COUNT ASCII line,+    /// which grapheme count alone cannot express.+    @Test("lineWidth measures CJK text as wider than the same character count of ASCII")+    func testLineWidthCJKWiderThanSameCountASCII() {+        let cjk = "日本語テスト"+        let ascii = String(repeating: "a", count: cjk.count)+        let cjkWidth = MonospaceMetrics.lineWidth(cjk, family: nil, size: 17)+        let asciiWidth = MonospaceMetrics.lineWidth(ascii, family: nil, size: 17)+        #expect(cjkWidth > asciiWidth,+                "CJK text (\(cjkWidth)) should measure wider than the same count of ASCII (\(asciiWidth))")+    }++    /// Emoji render as wide glyphs, not single-column ones — an emoji line+    /// must measure wider than the same-count ASCII line.+    @Test("lineWidth measures emoji text as wider than the same character count of ASCII")+    func testLineWidthEmojiWiderThanSameCountASCII() {+        let emoji = "🎉🚀😀🔥✨"+        let ascii = String(repeating: "a", count: emoji.count)+        let emojiWidth = MonospaceMetrics.lineWidth(emoji, family: nil, size: 17)+        let asciiWidth = MonospaceMetrics.lineWidth(ascii, family: nil, size: 17)+        #expect(emojiWidth > asciiWidth,+                "Emoji text (\(emojiWidth)) should measure wider than the same count of ASCII (\(asciiWidth))")+    }++    /// A mixed line (ASCII + tab + CJK + emoji) must measure as more than the+    /// sum of "each character is one column" — i.e. more than its own+    /// character count times the plain "M" width.+    @Test("lineWidth measures a mixed line wider than character-count times M width")+    func testLineWidthMixedLineExceedsNaiveEstimate() {+        let mixed = "id\tname\t日本語\t🎉done"+        let mixedWidth = MonospaceMetrics.lineWidth(mixed, family: nil, size: 17)+        let naiveEstimate = CGFloat(mixed.count) * MonospaceMetrics.charWidth(family: nil, size: 17)+        #expect(mixedWidth > naiveEstimate,+                "Mixed line (\(mixedWidth)) should exceed the naive count × M-width estimate (\(naiveEstimate))")+    }++    /// Empty text measures as zero width regardless of font.+    @Test("lineWidth returns zero for empty text")+    func testLineWidthEmptyText() {+        #expect(MonospaceMetrics.lineWidth("", family: nil, size: 17) == 0)+    }++    // MARK: - Widest Line Selection (T-1830)++    /// Builds a plain-ASCII line with MORE characters than `wideText` but a+    /// smaller MEASURED width — i.e. exactly the case the old grapheme-count+    /// model got backwards. The comparison line's length is derived from+    /// `wideText`'s own measured width rather than a guessed literal, so the+    /// test holds regardless of the exact glyph-advance ratio a given font+    /// and platform produce for tabs/CJK/emoji.+    private func longerButNarrowerASCIILine(comparedTo wideText: String, family: String?, size: CGFloat) -> String {+        let wideWidth = MonospaceMetrics.lineWidth(wideText, family: family, size: size)+        let mWidth = MonospaceMetrics.charWidth(family: family, size: size)+        // One "M" short of the wide line's width, so it measures narrower,+        // then padded to guarantee it still out-counts the wide line's+        // characters even after that trim.+        let narrowerCount = max(wideText.count + 1, Int(wideWidth / mWidth) - 1)+        return String(repeating: "a", count: narrowerCount)+    }++    /// A short CJK line must be selected over a longer-by-COUNT plain-ASCII+    /// line whose measured width is nonetheless smaller: the bug this+    /// regresses picked lines by character count, so a short wide line never+    /// won even though it rendered wider.+    @Test("widestLine picks a short CJK line over a longer-by-count but narrower plain ASCII line")+    func testWidestLinePicksShortCJKOverLongerASCII() {+        let cjkLine = "日本語テストテスト" // double-width glyphs+        let asciiLine = longerButNarrowerASCIILine(comparedTo: cjkLine, family: nil, size: 17)+        #expect(asciiLine.count > cjkLine.count,+                "Test setup requires the ASCII line to out-count the CJK line")++        let winner = MonospaceMetrics.widestLine(in: [asciiLine, cjkLine], family: nil, size: 17)+        #expect(winner == cjkLine,+                "The CJK line renders wider despite having fewer characters")+    }++    /// A short line of tabs must be selected over a longer-by-count but+    /// narrower plain-ASCII line.+    @Test("widestLine picks a short tab-filled line over a longer-by-count but narrower plain ASCII line")+    func testWidestLinePicksTabsOverLongerASCII() {+        let tabLine = String(repeating: "\t", count: 8)+        let asciiLine = longerButNarrowerASCIILine(comparedTo: tabLine, family: nil, size: 17)+        #expect(asciiLine.count > tabLine.count,+                "Test setup requires the ASCII line to out-count the tab line")++        let winner = MonospaceMetrics.widestLine(in: [asciiLine, tabLine], family: nil, size: 17)+        #expect(winner == tabLine,+                "The tab-filled line renders wider despite having fewer characters")+    }++    /// A short emoji line must be selected over a longer-by-count but+    /// narrower plain-ASCII line.+    @Test("widestLine picks a short emoji line over a longer-by-count but narrower plain ASCII line")+    func testWidestLinePicksEmojiOverLongerASCII() {+        let emojiLine = "🎉🚀😀🔥✨"+        let asciiLine = longerButNarrowerASCIILine(comparedTo: emojiLine, family: nil, size: 17)+        #expect(asciiLine.count > emojiLine.count,+                "Test setup requires the ASCII line to out-count the emoji line")++        let winner = MonospaceMetrics.widestLine(in: [asciiLine, emojiLine], family: nil, size: 17)+        #expect(winner == emojiLine,+                "The emoji line renders wider despite having fewer characters")+    }++    /// Empty input returns an empty string rather than crashing.+    @Test("widestLine returns empty string for no lines")+    func testWidestLineEmptyInput() {+        #expect(MonospaceMetrics.widestLine(in: [], family: nil, size: 17) == "")+    }++    /// Blank lines are skipped in favor of any line with real content.+    @Test("widestLine skips empty lines when a non-empty line exists")+    func testWidestLineSkipsEmptyLines() {+        let winner = MonospaceMetrics.widestLine(in: ["", "content", ""], family: nil, size: 17)+        #expect(winner == "content")+    }++    // MARK: - Widest Line Prefilter (T-1830 review)++    /// The plain-ASCII shortcut (only the longest plain line is measured)+    /// rests on the resolved font reporting itself fixed pitch. If that ever+    /// went false for the fonts the app uses, `widestLine` would still be+    /// correct but back to a full layout pass per parse.+    @Test("widestLine's plain-ASCII shortcut is live for the system mono font and bundled families")+    func testPlainShortcutIsLive() {+        #expect(MonospaceMetrics.usesPlainShortcut(family: nil, size: 17))+        #expect(MonospaceMetrics.usesPlainShortcut(family: "Menlo", size: 17))+        #expect(MonospaceMetrics.usesPlainShortcut(family: "Courier", size: 17))+    }++    /// `isPlainASCII` is the class boundary: anything a tab or non-ASCII+    /// scalar can widen must be measured, everything else compares by count.+    @Test("isPlainASCII admits only printable ASCII")+    func testIsPlainASCII() {+        #expect(MonospaceMetrics.isPlainASCII("plain text, punctuation & digits 123 ~"))+        #expect(MonospaceMetrics.isPlainASCII(""))+        #expect(!MonospaceMetrics.isPlainASCII("a\tb"))+        #expect(!MonospaceMetrics.isPlainASCII("caf\u{E9}"))+        #expect(!MonospaceMetrics.isPlainASCII("\u{65E5}"))+        #expect(!MonospaceMetrics.isPlainASCII("\u{1F389}"))+    }++    /// The prefilter must never change the answer: over a corpus mixing+    /// plain lines of many lengths with tab, CJK, emoji, accented and+    /// combining-mark lines, the selected line measures exactly as wide as+    /// the widest width found by measuring EVERY line.+    @Test("widestLine agrees with measuring every line over a mixed corpus")+    func testWidestLineMatchesExhaustiveMeasurement() {+        var lines: [String] = []+        for count in stride(from: 1, through: 60, by: 3) {+            lines.append(String(repeating: "x", count: count))+        }+        lines += [+            "", "\t", "a\tb\tc", String(repeating: "\t", count: 4),+            "\u{65E5}\u{672C}\u{8A9E}", "\u{1F389}\u{1F680}", "caf\u{E9} na\u{EF}ve",+            "e\u{301}\u{301}\u{301}", String(repeating: "\u{65E5}", count: 30),+        ]+        for size: CGFloat in [12, 17, 34] {+            for family in [nil, "Menlo"] {+                let winner = MonospaceMetrics.widestLine(in: lines, family: family, size: size)+                let winnerWidth = MonospaceMetrics.lineWidth(winner, family: family, size: size)+                let exhaustive = lines.map { MonospaceMetrics.lineWidth($0, family: family, size: size) }.max() ?? 0+                #expect(winnerWidth == exhaustive,+                        "family \(family ?? "system") size \(size): picked \(winner.debugDescription) at \(winnerWidth), exhaustive max \(exhaustive)")+            }+        }+    }++    /// The longest plain line is still a candidate and beats a shorter+    /// non-ASCII line that renders narrower — the shortcut skips the OTHER+    /// plain lines, not the plain class as a whole.+    @Test("widestLine picks the longest plain line over a narrower accented line")+    func testWidestLinePlainBeatsNarrowerNonASCII() {+        let plain = String(repeating: "a", count: 40)+        let winner = MonospaceMetrics.widestLine(in: ["short", "caf\u{E9}", plain, "mid line"], family: nil, size: 17)+        #expect(winner == plain)+    } }
prismTests/RawSourceViewModelTests.swift Modified +228 / -47
diff --git a/prismTests/RawSourceViewModelTests.swift b/prismTests/RawSourceViewModelTests.swiftindex 35a93374..480f72cf 100644--- a/prismTests/RawSourceViewModelTests.swift+++ b/prismTests/RawSourceViewModelTests.swift@@ -413,99 +413,276 @@ struct RawSourceViewModelTests {         #expect(viewModel.lineCount == 5)     } -    // MARK: - Max Line Length+    // MARK: - Widest Rendered Line (T-1830) -    @Test("maxLineLength finds the longest line")-    func maxLineLengthFindsLongest() async throws {+    /// Among plain-ASCII lines, `widestLineText` picks the longest by count —+    /// consistent with the old grapheme-count model when width and count agree.+    @Test("widestLineText finds the longest plain-ASCII line")+    func widestLineTextPlainASCII() async throws {         let viewModel = RawSourceViewModel()         let content = "short\nvery long line here!\nmid"          await viewModel.loadContent(content) -        #expect(viewModel.maxLineLength == 20) // "very long line here!".count+        #expect(viewModel.widestLineText == "very long line here!")     } -    @Test("maxLineLength is 0 for empty content")-    func maxLineLengthEmptyContent() async throws {+    /// Builds a plain-ASCII line with MORE characters than `wideText` but a+    /// smaller MEASURED width at the same (default) font `loadContent` uses+    /// — i.e. exactly the case the old grapheme-count model got backwards.+    /// Derived from `wideText`'s own measured width rather than a guessed+    /// literal, so the test holds regardless of the exact glyph-advance+    /// ratio a given font and platform produce for tabs/CJK/emoji.+    private func longerButNarrowerPlainLine(comparedTo wideText: String) -> String {+        let size = TypographyResolver.baselineSize(for: .body)+        let wideWidth = MonospaceMetrics.lineWidth(wideText, family: nil, size: size)+        let mWidth = MonospaceMetrics.charWidth(family: nil, size: size)+        let narrowerCount = max(wideText.count + 1, Int(wideWidth / mWidth) - 1)+        return String(repeating: "a", count: narrowerCount)+    }++    /// A short line full of tabs must be selected over a longer-by-count but+    /// narrower plain line: tabs render far wider than a single character,+    /// which pure grapheme count could never reflect (T-1830).+    @Test("widestLineText picks a short tab-filled line over a longer-by-count but narrower plain line")+    func widestLineTextPicksTabsOverLongerPlain() async throws {         let viewModel = RawSourceViewModel()+        let tabLine = String(repeating: "\t", count: 6)+        let plainLine = longerButNarrowerPlainLine(comparedTo: tabLine)+        #expect(plainLine.count > tabLine.count,+                "Test setup requires the plain line to out-count the tab line") -        await viewModel.loadContent("")+        await viewModel.loadContent("\(plainLine)\n\(tabLine)") -        #expect(viewModel.maxLineLength == 0)+        #expect(viewModel.widestLineText == tabLine)     } -    @Test("maxLineLength handles single line")-    func maxLineLengthSingleLine() async throws {+    /// A short CJK line must be selected over a longer-by-count but narrower+    /// plain-ASCII line.+    @Test("widestLineText picks a short CJK line over a longer-by-count but narrower plain line")+    func widestLineTextPicksCJKOverLongerPlain() async throws {         let viewModel = RawSourceViewModel()+        let cjkLine = "日本語テストテスト" // 9 characters, double-width glyphs+        let plainLine = longerButNarrowerPlainLine(comparedTo: cjkLine)+        #expect(plainLine.count > cjkLine.count,+                "Test setup requires the plain line to out-count the CJK line") -        await viewModel.loadContent("hello")+        await viewModel.loadContent("\(plainLine)\n\(cjkLine)") -        #expect(viewModel.maxLineLength == 5)+        #expect(viewModel.widestLineText == cjkLine)     } -    @Test("maxLineLength handles unicode characters")-    func maxLineLengthUnicode() async throws {+    /// A short emoji line must be selected over a longer-by-count but+    /// narrower plain-ASCII line.+    @Test("widestLineText picks a short emoji line over a longer-by-count but narrower plain line")+    func widestLineTextPicksEmojiOverLongerPlain() async throws {         let viewModel = RawSourceViewModel()-        let content = "abc\n日本語テスト\nhi"+        let emojiLine = "🎉🚀😀🔥✨🥳🌟"+        let plainLine = longerButNarrowerPlainLine(comparedTo: emojiLine)+        #expect(plainLine.count > emojiLine.count,+                "Test setup requires the plain line to out-count the emoji line") -        await viewModel.loadContent(content)+        await viewModel.loadContent("\(plainLine)\n\(emojiLine)") -        // "日本語テスト" is 6 characters in Swift's String.count-        #expect(viewModel.maxLineLength == 6)+        #expect(viewModel.widestLineText == emojiLine)     } -    @Test("maxLineLength handles lines with only empty lines")-    func maxLineLengthAllEmptyLines() async throws {+    /// A mixed line (tabs + CJK + emoji + ASCII) must win over a plain line+    /// with a similar character count.+    @Test("widestLineText picks a mixed tab/CJK/emoji line over a plain line of similar length")+    func widestLineTextPicksMixedOverSimilarLengthPlain() async throws {         let viewModel = RawSourceViewModel()-        let content = "\n\n\n"+        let mixedLine = "id\tname\t日本語\t🎉done"+        let plainLine = String(repeating: "a", count: mixedLine.count)+        #expect(plainLine.count == mixedLine.count) -        await viewModel.loadContent(content)+        await viewModel.loadContent("\(plainLine)\n\(mixedLine)") -        #expect(viewModel.maxLineLength == 0)+        #expect(viewModel.widestLineText == mixedLine)     } -    @Test("maxLineLength is reset to 0 on reset()")-    func maxLineLengthResetClears() async throws {+    /// Empty content leaves `widestLineText` empty.+    @Test("widestLineText is empty for empty content")+    func widestLineTextEmptyContent() async throws {+        let viewModel = RawSourceViewModel()++        await viewModel.loadContent("")++        #expect(viewModel.widestLineText.isEmpty)+    }++    /// `reset()` clears the previously-selected widest line.+    @Test("widestLineText is reset to empty on reset()")+    func widestLineTextResetClears() async throws {         let viewModel = RawSourceViewModel()         await viewModel.loadContent("a long line of text") -        #expect(viewModel.maxLineLength > 0)+        #expect(!viewModel.widestLineText.isEmpty)          viewModel.reset() -        #expect(viewModel.maxLineLength == 0)+        #expect(viewModel.widestLineText.isEmpty)     } -    @Test("maxLineLength updates when loading new content")-    func maxLineLengthUpdatesOnNewContent() async throws {+    /// Loading new content replaces the previously-selected widest line.+    @Test("widestLineText updates when loading new content")+    func widestLineTextUpdatesOnNewContent() async throws {         let viewModel = RawSourceViewModel()          await viewModel.loadContent("short")-        #expect(viewModel.maxLineLength == 5)+        #expect(viewModel.widestLineText == "short")          await viewModel.loadContent("a much longer line than before")-        #expect(viewModel.maxLineLength == 30)+        #expect(viewModel.widestLineText == "a much longer line than before")+    }++    // MARK: - Widest Line Re-selection On Font Change (T-1830)++    /// Builds a plain line that measures NARROWER than `tabLine` at the+    /// baseline size but WIDER at `scaledSize`: glyphs scale with the font,+    /// tab stops are fixed-point and do not. Derived from real measurements+    /// so it holds regardless of the platform's exact tab-stop interval.+    private func plainLineThatOvertakesOnScale(+        tabLine: String,+        baselineSize: CGFloat,+        scaledSize: CGFloat+    ) -> String {+        let baselineTabWidth = MonospaceMetrics.lineWidth(tabLine, family: nil, size: baselineSize)+        let baselineM = MonospaceMetrics.charWidth(family: nil, size: baselineSize)+        // One "M" short of the tab line at the baseline size.+        let count = max(tabLine.count + 1, Int(baselineTabWidth / baselineM) - 1)+        return String(repeating: "a", count: count)+    }++    /// A tab-heavy line that is widest at 100% scale is beaten by a longer+    /// plain line at 300%, so a scale change must re-select the widest line+    /// rather than only re-measuring the one chosen at parse time.+    @Test("updateWidestLine re-selects the widest line for a new font size without reparsing")+    func updateWidestLineReselectsOnScaleChange() async throws {+        let viewModel = RawSourceViewModel()+        let baselineSize = TypographyResolver.baselineSize(for: .body)+        let scaledSize = baselineSize * 3+        let tabLine = String(repeating: "\t", count: 6)+        let plainLine = plainLineThatOvertakesOnScale(+            tabLine: tabLine, baselineSize: baselineSize, scaledSize: scaledSize+        )+        // Pin the premise the test rests on, at both sizes.+        #expect(MonospaceMetrics.lineWidth(plainLine, family: nil, size: baselineSize)+                < MonospaceMetrics.lineWidth(tabLine, family: nil, size: baselineSize))+        #expect(MonospaceMetrics.lineWidth(plainLine, family: nil, size: scaledSize)+                > MonospaceMetrics.lineWidth(tabLine, family: nil, size: scaledSize))++        await viewModel.loadContent("\(plainLine)\n\(tabLine)")+        #expect(viewModel.widestLineText == tabLine)+        let linesBefore = viewModel.lines++        await viewModel.updateWidestLine(font: MonoFontSpec(family: nil, size: scaledSize))+        #expect(viewModel.widestLineText == plainLine)+        // No reparse happened: the lines array is untouched.+        #expect(viewModel.lines == linesBefore)++        await viewModel.updateWidestLine(font: MonoFontSpec(family: nil, size: baselineSize))+        #expect(viewModel.widestLineText == tabLine)+    }++    /// The font recorded at parse time is the one `loadContent` was given,+    /// so an update to the SAME font is a no-op and leaves the selection.+    @Test("updateWidestLine with the parse-time font leaves the selection unchanged")+    func updateWidestLineSameFontIsNoOp() async throws {+        let viewModel = RawSourceViewModel()+        let size = TypographyResolver.baselineSize(for: .body)++        await viewModel.loadContent(+            "short\na much longer line",+            colors: TestThemeColors(),+            highlightingEnabled: false,+            monoFontFamily: nil,+            monoFontSize: size+        )+        #expect(viewModel.widestLineText == "a much longer line")++        await viewModel.updateWidestLine(font: MonoFontSpec(family: nil, size: size))+        #expect(viewModel.widestLineText == "a much longer line")+    }++    /// A font change that lands while a parse is in flight is not lost: the+    /// parse selected against the font it was started with, `loadContent`+    /// notices the newer font once the parse lands, and re-selects against+    /// that one before returning.+    @Test("font change during an in-flight parse is applied once the parse lands")+    func updateWidestLineDuringParseIsAppliedAfterwards() async throws {+        let viewModel = RawSourceViewModel()+        let baselineSize = TypographyResolver.baselineSize(for: .body)+        let scaledSize = baselineSize * 3+        let tabLine = String(repeating: "\t", count: 6)+        let plainLine = plainLineThatOvertakesOnScale(+            tabLine: tabLine, baselineSize: baselineSize, scaledSize: scaledSize+        )+        // A large document so the parse is still running when the font+        // change arrives.+        let filler = Array(repeating: "filler line", count: 20_000).joined(separator: "\n")+        let content = "\(plainLine)\n\(tabLine)\n\(filler)"++        // Prime with a tiny document so `isLoading` is false before the+        // large parse starts — it begins life true, so only the flip back+        // to true identifies the parse under test as in flight.+        await viewModel.loadContent("x")+        #expect(!viewModel.isLoading)++        // A structured child, like the T-700 tests below: an unstructured+        // `Task {}` was observed not to get onto the main actor for ten+        // seconds on a loaded shared host while this test polled.+        async let load: Void = viewModel.loadContent(+            content,+            colors: TestThemeColors(),+            highlightingEnabled: false,+            monoFontFamily: nil,+            monoFontSize: baselineSize+        )+        // Wait for the parse to be in flight — or, on a fast host, already+        // landed. A real suspension, not `Task.yield()`, which spun forever+        // here. Bounded so a regression fails instead of hanging the run.+        var waited = 0+        while !viewModel.isLoading && viewModel.lines.count < 3 && waited < 10_000 {+            try await Task.sleep(for: .milliseconds(1))+            waited += 1+        }+        #expect(waited < 10_000, "the parse under test never started")+        // Whichever side won the race the outcome is the same: either+        // `loadContent` sees the newer font once its parse lands and+        // re-selects before returning, or the update itself re-selects+        // because the parse had already landed.+        await viewModel.updateWidestLine(font: MonoFontSpec(family: nil, size: scaledSize))+        await load++        #expect(viewModel.widestLineText == plainLine)     }      // MARK: - Stale Task Race Condition (T-700) +    /// The content whose load was the LAST TO START, identified by the view+    /// model's own hash guard. Concurrent `async let` loads reach the main+    /// actor in no guaranteed order (each child starts nonisolated and races+    /// its hop), so declaration order says nothing about which document is+    /// current; the hash does.+    private func lastStartedContent(in candidates: [String], of viewModel: RawSourceViewModel) -> String? {+        candidates.first { $0.hashValue == viewModel.loadingContentHash }+    }+     @Test("stale parse task does not clear isLoading while newer task is running")     func staleTaskDoesNotClearLoading() async throws {         // T-700: When loadContent is called rapidly, an older cancelled task         // must not set isLoading = false while a newer parse is still in progress.         //-        // This test fires two loadContent calls concurrently. The first call's-        // detached task will be cancelled when the second call starts. When that-        // stale task finishes, it must NOT clear isLoading — only the active-        // (second) task should do that.+        // This test fires two loadContent calls concurrently. Whichever starts+        // second cancels the first's detached task. When that stale task+        // finishes it must NOT write lines or clear isLoading — only the+        // active task (the one matching the hash guard) should do that.         let viewModel = RawSourceViewModel()          let firstContent = "First document\nwith multiple lines"         let secondContent = "Final content" -        // Fire the first load and immediately fire the second.-        // The first call suspends at `await parseTask?.value`, allowing-        // the second call to start and cancel the first task.         async let firstLoad: Void = viewModel.loadContent(             firstContent, colors: TestThemeColors(), highlightingEnabled: false         )@@ -516,36 +693,40 @@ struct RawSourceViewModelTests {         // Wait for both to complete         _ = await (firstLoad, secondLoad) -        // After both calls complete, isLoading must be false (the second/active-        // task cleared it) and lines must reflect the second call's content.+        // After both calls complete, isLoading must be false (the active task+        // cleared it) and lines must belong to the load that started last —+        // not to the superseded one, whose task was cancelled.+        let expected = try #require(lastStartedContent(in: [firstContent, secondContent], of: viewModel))         #expect(!viewModel.isLoading)-        #expect(viewModel.lines.first?.text == "Final content")+        #expect(viewModel.lines.map(\.text) == expected.components(separatedBy: "\n"))     }      @Test("rapid loadContent calls leave consistent state")     func rapidLoadContentLeavesConsistentState() async throws {         // T-700: Multiple rapid loadContent calls must not leave the view model         // in an inconsistent state where isLoading is false but lines are empty-        // or belong to a stale document.+        // or belong to a stale (superseded) document.         let viewModel = RawSourceViewModel()+        let documents = ["First document", "Second document", "Third document"]          // Fire several loads in quick succession         async let load1: Void = viewModel.loadContent(-            "First document", colors: TestThemeColors(), highlightingEnabled: false+            documents[0], colors: TestThemeColors(), highlightingEnabled: false         )         async let load2: Void = viewModel.loadContent(-            "Second document", colors: TestThemeColors(), highlightingEnabled: false+            documents[1], colors: TestThemeColors(), highlightingEnabled: false         )         async let load3: Void = viewModel.loadContent(-            "Third document", colors: TestThemeColors(), highlightingEnabled: false+            documents[2], colors: TestThemeColors(), highlightingEnabled: false         )          _ = await (load1, load2, load3)          // The final state must be consistent: isLoading is false and the lines-        // belong to the last-submitted content ("Third document").+        // belong to whichever load started last.+        let expected = try #require(lastStartedContent(in: documents, of: viewModel))         #expect(!viewModel.isLoading)         #expect(viewModel.lines.count == 1)-        #expect(viewModel.lines.first?.text == "Third document")+        #expect(viewModel.lines.first?.text == expected)     } }
specs/bugfixes/raw-source-nowrap-width/report.md Added +107 / -0
diff --git a/specs/bugfixes/raw-source-nowrap-width/report.md b/specs/bugfixes/raw-source-nowrap-width/report.mdnew file mode 100644index 00000000..0e4a9e19--- /dev/null+++ b/specs/bugfixes/raw-source-nowrap-width/report.md@@ -0,0 +1,107 @@+# Bugfix Report: Raw Source No-Wrap Width Clips Tabs, CJK, and Emoji Lines++**Date:** 2026-09-06+**Status:** Fixed++## Description of the Issue++With "Wrap Long Lines" turned off in Raw Source, the horizontal scroll area's content width was estimated by multiplying the character count (grapheme clusters) of the longest line by the advance width of a single "M" glyph, plus a 1.1x safety multiplier. Tabs, CJK characters, and emoji all render wider than a single "M" — a tab's advance comes from the text system's default tab stops (not a per-character width), CJK glyphs are roughly double-width, and emoji render as wide glyphs — so a line containing them could render far past the computed scroll width. The right edge of such lines was unreachable: scrolling stopped short of the actual end of the line, or clipped it.++**Reproduction steps:**+1. Open a document containing a long line with tabs, CJK characters, or emoji.+2. Switch to Raw Source view.+3. Turn off "Wrap Long Lines".+4. Scroll horizontally to the end of the line.+5. Observe that the scrollable area ends before the line's actual rendered end.++**Impact:** Any raw-source line containing tabs, CJK text, or emoji, viewed with wrapping off, could have its tail permanently unreachable by scrolling. Affects both iOS and macOS.++## Investigation Summary++- **Symptoms examined:** Diagnostic AppKit measurement (from the ticket) at 17pt system mono showed 10 "M" characters ≈ 105pt, but 10 tabs ≈ 280pt, 10 CJK characters ≈ 169pt, and 10 emoji ≈ 230pt — all substantially wider than the naive per-character estimate of ~10.5pt each.+- **Code inspected:** `RawSourceViewModel.swift` (computed `maxLineLength` as `String.count` of the longest line), `MonospaceMetrics.swift` (measured only the width of the literal character "M"), and `RawSourceView.swift` (multiplied count × "M"-width × 1.1 to get the scroll `minWidth`).+- **Hypotheses tested:** Considered a purely heuristic column-counting fix (treat tabs as N columns, wide characters as 2 columns) but rejected it — a tab's real advance comes from the text system's *default tab stops* (a fixed point value, ~28pt, independent of font size), not a column multiple of the font's per-character width, so no font-size-relative heuristic can reproduce it. Real text measurement was required.++## Discovered Root Cause++**Defect type:** Incorrect estimation model (grapheme count used as a proxy for rendered pixel width).++**Why it occurred:** The original design (T-995) correctly fixed font family/size cache invalidation for the "M"-width lookup, but kept the surrounding model of `character count × single-glyph width`. That model implicitly assumes every character occupies exactly one monospace column, which holds for plain ASCII but not for tabs (variable-width, non-proportional to font size), CJK (double-width), or emoji (wide glyphs).++**Contributing factors:** The bug is a "check-code-for-issues automation" finding (per ticket metadata) rather than a user report, so there was no existing regression test exercising non-ASCII/tab content in the raw-source width path.++## Resolution for the Issue++**Changes made:**+- `prism/Services/MonospaceMetrics.swift` — added `lineWidth(_:family:size:)`, which measures a whole line's real rendered width (tabs, CJK, emoji included) instead of assuming a uniform per-character advance; added `widestLine(in:family:size:)`, which finds the line with the greatest *measured* width among a set of lines (not the greatest character count); refactored font resolution into a shared `resolveFont` helper used by both the existing "M"-width measurement and the new functions. Both new functions are `nonisolated` (no shared mutable state) so they can run from a background parse task without a main-actor hop per line.+- `prism/ViewModels/RawSourceViewModel.swift` — `loadContent` now also measures, inside its existing background parse task, which parsed line renders widest (`MonospaceMetrics.widestLine`), and stores its raw text as a new `widestLineText` property. `maxLineLength` (character count) is retained unchanged as a simple statistic; it's no longer used to size the scroll area. `loadContent` gained two parameters (`monoFontFamily`, `monoFontSize`) with backward-compatible defaults, used only to pick an accurate initial candidate line at parse time.+- `prism/Views/RawSourceView.swift` — `contentMinWidth` now calls `RawSourceContentWidth.compute(widestLineText:viewportWidth:resolver:)`, passing `viewModel.widestLineText`; the `.task(id:)` that starts parsing now passes the current mono font family and effective size into `loadContent`.+- `RawSourceContentWidth.compute` (`MonospaceMetrics.swift`) now measures `widestLineText`'s real width with the *current* font (so a font family/scale change picked up without a reparse still produces an accurate width), memoizing the last (text, family, size) → width result in a single-slot cache to avoid re-measuring on every render when nothing changed.++**Review follow-up (PR #410, iteration 1):**+- `MonospaceMetrics.widestLine` no longer measures every line. Lines consisting only of printable ASCII (no tab) render at exactly `count × advance` in a fixed-pitch font — checked on the resolved font via `isFixedPitch`, not assumed — so only the longest of them is measured. Every line containing a tab or any non-ASCII scalar is measured, because no per-glyph factor can bound those: a tab's advance depends on its position relative to the fixed-point default tab stops, and one grapheme cluster can render anywhere from zero width (a lone combining mark) to several ems (U+FDFA, flag/ZWJ emoji). A "count within a factor of the max" prefilter, as suggested in review, was rejected for that reason — there is no factor that is safe. Cost is now proportional to the number of tab/non-ASCII lines; a document written entirely in CJK measures every line as before. The `isDocumentTooLarge` threshold was not applied: it exists to bound highlighting, and skipping measurement above it would reintroduce the clipping for exactly the largest documents.+- Which line is widest is now re-selected on a mono font change, not only on reparse: `RawSourceView` watches `MonoFontSpec(resolver:)` (family + effective size) and calls `RawSourceViewModel.updateWidestLine(font:)`, which re-runs `widestLine` over the existing lines off the main actor (no highlighting, so far cheaper than a reparse). This matters because the selection is font-dependent, not just the width: tab stops are fixed-point, so a tab-heavy line that wins at 100% scale loses to a longer plain line at 200%. A font change landing mid-parse is not lost — the view model records the newest font first, and the parse completion re-selects when it finds it selected against a stale one.+- `RawSourceViewModel.maxLineLength` was removed: nothing consumed it once the width stopped being derived from character count, and dead state invites a future reader to size something by it again. Its tests went with it.+- The T-700 tests `staleTaskDoesNotClearLoading` and `rapidLoadContentLeavesConsistentState` failed on this branch nearly every run (2/2 on the branch tip with this follow-up stashed). They fire `loadContent` via `async let` and assumed the children reach the main actor in declaration order; they do not — each child starts nonisolated and races its hop — and the extra measurement in `loadContent` shifted timing enough to expose it. They now identify the last-started load through `RawSourceViewModel.loadingContentHash` (made `private(set)` for this) and assert `lines` belongs to it, which is the actual T-700 invariant.+- `TypographyResolver.baselineSize(for:)` (and the static tables it reads) are now explicitly `nonisolated`. It is used as a default argument of `loadContent`, and a default-argument expression compiles into a thunk outside the enclosing type's actor, which produced the one compiler warning in the build. The same explicit `nonisolated` was added to `MonospaceMetrics.resolveFont/lineWidth/widestLine` (and `fallbackAdvanceRatio`): the original change described them as `nonisolated` in comments only, and under this target's `SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor` an unannotated static is main-actor-isolated, so the "measured off the main actor" claim was not true until the keyword was added.++**Approach rationale:** Measuring the actual text (rather than approximating with a column-weighting heuristic) is the only way to correctly capture a tab's fixed-point default advance, which does not scale proportionally with font size the way glyph advances do. Splitting "which line is widest" (decided once, in the background parse, using real per-line measurement) from "how wide is that text at the current font" (re-measured cheaply at render time, single-slot cached) keeps the render path O(1) relative to document size and keeps a family/scale-only settings change from needing a full reparse (which would otherwise re-run syntax highlighting unnecessarily).++**Alternatives considered:**+- A pure heuristic column-count model (tabs = N columns, wide characters = 2 columns) — rejected because tab stops are physical points, not font-relative columns, so no column-based heuristic reproduces real tab rendering at different font sizes.+- Measuring every line at every render — rejected for performance: it would make `RawSourceView`'s body evaluation (which can run every scroll/geometry frame) cost O(document length) instead of O(1).+- Re-parsing (including syntax highlighting) whenever the font family or scale changes, so the exact widest-line width is always fresh from a single computation — rejected because it would redo expensive syntax highlighting for what is only a width recomputation.++## Regression Test++**Test files:** `prismTests/MonospaceMetricsTests.swift`, `prismTests/MonospaceMetricsRenderParityTests.swift`, `prismTests/RawSourceViewModelTests.swift`++**Test names (new):**+- `MonospaceMetricsTests`: `testLineWidthTabWiderThanSingleChar`, `testLineWidthTabsMuchWiderThanSameCountPlain`, `testLineWidthCJKWiderThanSameCountASCII`, `testLineWidthEmojiWiderThanSameCountASCII`, `testLineWidthMixedLineExceedsNaiveEstimate`, `testLineWidthEmptyText`, `testWidestLinePicksShortCJKOverLongerASCII`, `testWidestLinePicksTabsOverLongerASCII`, `testWidestLinePicksEmojiOverLongerASCII`, `testWidestLineEmptyInput`, `testWidestLineSkipsEmptyLines`, `testContentMinWidthRecomputesOnTextChange`+- `RawSourceViewModelTests`: `widestLineTextPlainASCII`, `widestLineTextPicksTabsOverLongerPlain`, `widestLineTextPicksCJKOverLongerPlain`, `widestLineTextPicksEmojiOverLongerPlain`, `widestLineTextPicksMixedOverSimilarLengthPlain`, `widestLineTextEmptyContent`, `widestLineTextResetClears`, `widestLineTextUpdatesOnNewContent`+- Review follow-up — `MonospaceMetricsTests`: `testPlainShortcutIsLive`, `testIsPlainASCII`, `testWidestLineMatchesExhaustiveMeasurement` (the prefiltered answer equals the measure-everything answer over a mixed corpus, three sizes, two families), `testWidestLinePlainBeatsNarrowerNonASCII`; `RawSourceViewModelTests`: `updateWidestLineReselectsOnScaleChange`, `updateWidestLineSameFontIsNoOp`, `updateWidestLineDuringParseIsAppliedAfterwards`; `MonospaceMetricsRenderParityTests`: `lineWidthMatchesRenderedTextBaseline`, `lineWidthMatchesRenderedTextScaled`, `lineWidthMatchesRenderedTextCustomFamily` (parameterised over plain, tab, CJK, emoji, mixed, and 15-consecutive-tab lines — past the text system's 12 default tab stops)++**What it verifies:** That measuring a tab, CJK text, or emoji produces a width greater than a naive character-count estimate; that a *short* line dominated by tabs, CJK, or emoji is correctly selected as "widest" over a *longer* (by character count) plain-ASCII line, when the plain line's own measured width is smaller — the exact case the old grapheme-count model got backwards. The comparison ASCII lines in these tests are derived dynamically from the real measured width ratio (not a hardcoded guess), so the tests hold regardless of the exact glyph-advance ratio a given font/platform produces.++**Run command:**+```+xcodebuild test -project prism.xcodeproj -scheme prism \+  -destination 'platform=macOS' -testPlan prism -only-test-configuration "en (base)" \+  -only-testing:prismTests/MonospaceMetricsTests \+  -only-testing:prismTests/RawSourceViewModelTests \+  -only-testing:prismTests/RawSourceViewModelHighlightingTests+```++## Affected Files++| File | Change |+|------|--------|+| `prism/Services/MonospaceMetrics.swift` | Added `lineWidth`, `widestLine`, shared `resolveFont` helper; `RawSourceContentWidth.compute` now takes `widestLineText` and measures it directly, with single-slot memoization |+| `prism/ViewModels/RawSourceViewModel.swift` | Added `widestLineText` property, computed during background parse via real font measurement; `loadContent` gained `monoFontFamily`/`monoFontSize` parameters (defaulted); `reset()` clears the new property |+| `prism/Views/RawSourceView.swift` | `contentMinWidth` uses `widestLineText`; `.task(id:)` passes current mono font family/size into `loadContent` |+| `prismTests/MonospaceMetricsTests.swift` | Updated width-computation tests for the new signature; added tab/CJK/emoji/mixed measurement and selection tests |+| `prismTests/RawSourceViewModelTests.swift` | Added `widestLineText` selection tests (plain, tabs, CJK, emoji, mixed, reset, update) |+| `CHANGELOG.md` | Added `[Unreleased]/Fixed` entry |++## Verification++**Automated:**+- [x] Regression tests pass (targeted run: `MonospaceMetricsTests`, `RawSourceViewModelTests`, `RawSourceViewModelHighlightingTests`)+- [x] `make build-macos` and `make build-ios` both succeed+- [x] `make lint` passes+- [ ] Full `make test-quick`/`make test` suite — not run to completion; this machine had several other worktrees running `xcodebuild` concurrently during this fix (contention noted in project memory), so a full-suite run was not attempted. One pre-existing, timing-sensitive test (`RawSourceViewModelTests.rapidLoadContentLeavesConsistentState`, unrelated to this change — a T-700 async-cancellation race test) flaked once under that contention and passed cleanly when re-run in isolation immediately after.++**Tab-stop parity with the rendered view:** The measurement (`NSString.size(withAttributes:)` with no paragraph style, i.e. the text system's default tab stops) was checked against what `RawSourceView.lineView` actually draws, not assumed to match. `MonospaceMetricsRenderParityTests` hosts the exact `Text(text).font(...).lineLimit(1)` view in an `NSHostingView` (macOS) / `UIHostingController` (iOS), reads its fitting width, and compares to `MonospaceMetrics.lineWidth` within one "M" advance — for the system mono font at the baseline size and at 200%, and for Menlo — over plain, `a\tb`, mixed tab/CJK/emoji, `\t\tcol\t\tcol`, and a line of 15 consecutive tabs followed by text (beyond the 12 default tab stops, where the two layout entry points could plausibly diverge). All pass on macOS; a systematic mismatch larger than the 1.1× safety multiplier would fail them.++**Manual verification:** Not performed interactively; the parity tests above replace the earlier assumption that the two layout paths agree.++## Prevention++**Recommendations to avoid similar bugs:**+- When sizing a scrollable area to fit rendered text, measure the actual text with the real font rather than approximating from a character count and a single glyph's width — especially where tabs are involved, since tab stops are physical, not font-relative.+- When adding a "compare lines by some proxy for width" selection, prefer measuring the candidate with the actual rendering API over a Unicode-category-based heuristic; the fixed-point tab-stop behavior in this bug would have broken a heuristic-only fix too.++## Related++- T-995: Fixed font family/size cache-key invalidation for the "M"-width lookup this bug's original width estimate depended on.+- Ticket source: "check-code-for-issues automation" (commit 5d33c13).
docs/agent-notes/raw-source-view.md Modified +13 / -7
diff --git a/docs/agent-notes/raw-source-view.md b/docs/agent-notes/raw-source-view.mdindex 4b00f7d4..8d34652e 100644--- a/docs/agent-notes/raw-source-view.md+++ b/docs/agent-notes/raw-source-view.md@@ -19,17 +19,23 @@ The view supports two modes controlled by `settings.rawSourceWrapLines`: **Wrapping OFF**: - `ScrollView([.vertical, .horizontal])` — both axes - Lines: `maxWidth: nil`, `lineLimit(1)`-- `contentMinWidth` computes explicit width from `maxLineLength * monospaceCharWidth`+- `contentMinWidth` computes explicit width by measuring the widest RENDERED line's text (`viewModel.widestLineText`) at the current mono font - `.frame(minWidth:minHeight:alignment: .topLeading)` pins content and enables scrolling  ### Horizontal Scroll Width Calculation -The ViewModel computes `maxLineLength` (character count of longest line) during parsing. The width calculation is delegated to `RawSourceContentWidth.compute(...)` (in `prism/Services/MonospaceMetrics.swift`), which:+Two steps, owned by two different types (T-1830): -1. Resolves the effective rendered point size as `TypographyResolver.baselineSize(for: .body) * scaleFactor` — the same value `scaledMonoFont` uses.-2. Asks `MonospaceMetrics.charWidth(family:size:)` for the advance width of "M" in that font, cached by `(family, size)`.-3. Multiplies by `maxLineLength`, applies a 1.1× safety multiplier, and adds 32pt for the surrounding `.padding()`.-4. Returns `max(viewportWidth, textWidth)` so short content still fills the viewport.+1. **Which line is widest** — `RawSourceViewModel.widestLineText`, selected by `MonospaceMetrics.widestLine(in:family:size:)` inside the background parse task, and RE-SELECTED (no reparse) by `updateWidestLine(font:)` when `RawSourceView` sees the `MonoFontSpec` (family, effective size) change. Selection is font-dependent, not just the width: tab stops are fixed-point (~28pt, whatever the font size), so a tab-heavy line that wins at 100% scale loses to a longer plain line at 200%. A font change that lands mid-parse is not lost — the parse completion compares the font it selected with against the newest one and re-selects.+2. **How wide that line is** — `RawSourceContentWidth.compute(widestLineText:viewportWidth:resolver:)` (in `prism/Services/MonospaceMetrics.swift`) measures that ONE line's text at `TypographyResolver.baselineSize(for: .body) * scaleFactor` (the same size `scaledMonoFont` uses), memoised on (text, family, size), applies a 1.1× safety multiplier, adds 32pt for the surrounding `.padding()`, and returns `max(viewportWidth, textWidth)`.++`widestLine` does not measure every line. Lines made only of printable ASCII (no tab) have width exactly `count × advance` in a fixed-pitch font (checked on the resolved font, not assumed), so only the longest of them is measured; every line containing a tab or a non-ASCII scalar is measured, because no per-glyph factor bounds those — a tab's advance depends on its position, and a single grapheme cluster can be anything from zero width to several ems. Do NOT replace this with a "count within a factor of the max" prefilter; there is no factor that is safe.++`MonospaceMetrics.lineWidth` (`NSString.size(withAttributes:)`, no paragraph style) measures what `Text(line.text)` in `RawSourceView.lineView` renders, tabs included — pinned by `MonospaceMetricsRenderParityTests`, which hosts the real `Text` and compares, including tabs past the 12 default stops. The old `maxLineLength` (character count) is gone; nothing consumed it once the width stopped being derived from it.++The T-700 concurrency tests (`staleTaskDoesNotClearLoading`, `rapidLoadContentLeavesConsistentState`) fire `loadContent` via `async let` and used to assume the children reach the main actor in declaration order. They do not — each child starts nonisolated and races its hop — and the extra measurement in this path shifted timing enough that they failed nearly every run. They now identify the last-started load through `loadingContentHash` (read-only outside the view model for exactly this) and assert `lines` belongs to it.++`MonospaceMetrics.resolveFont/lineWidth/widestLine` and `TypographyResolver.baselineSize` are explicitly `nonisolated`. The target's default isolation is `MainActor`, and the `nonisolated` claim was originally only a comment: without the keyword the "off-main" measurement was main-actor-isolated, and `baselineSize` used as a default argument produced the one warning the zero-warnings build gate flags (a default-argument expression compiles into its own nonisolated thunk). The `static let` tables those functions read need `nonisolated` too — an implicitly `@MainActor` `static let` is not readable from a nonisolated context even when its type is Sendable.  ### Width Must Use Actual Rendered Metrics (T-995) @@ -51,7 +57,7 @@ SwiftUI's `ScrollView([.vertical, .horizontal])` on macOS centers its content in  **Do NOT use**: `fixedSize` on `LazyVStack` or its parents to enable horizontal scrolling. -**Instead**: Compute the required width explicitly (e.g., from character count × monospace character width) and set it via `.frame(minWidth:)` on the scroll content. Use `lineLimit(1)` on text views to prevent wrapping.+**Instead**: Compute the required width explicitly — measure the widest rendered line's text at the rendered font (see "Horizontal Scroll Width Calculation" above; NOT character count × a single glyph's width, which is the T-1830 bug) — and set it via `.frame(minWidth:)` on the scroll content. Use `lineLimit(1)` on text views to prevent wrapping.  ### Do Not Use `defer` for `isLoading` in Concurrent Task Contexts 
docs/agent-notes/document-reader.md Modified +3 / -5
diff --git a/docs/agent-notes/document-reader.md b/docs/agent-notes/document-reader.mdindex a2de95c7..0cff22a5 100644--- a/docs/agent-notes/document-reader.md+++ b/docs/agent-notes/document-reader.md@@ -6,10 +6,8 @@ Historical: note loading used to hang off `.task(id: session.id)`, so it ran whe  Fixed by T-987: notes loading is keyed on `session.parseRevision`, which bumps on every successful parse, so both reload paths re-relocate persisted notes and rebuild imported ones. See the comment on the `.task(id:)` in `DocumentReaderView`. Kept here only so the old symptom isn't re-investigated. -## Raw Source Width Uses Default Font Metrics+## Raw Source Width Uses Default Font Metrics (RESOLVED — T-995, T-1830) -`RawSourceView.contentMinWidth` depends on `monospaceCharWidth`, but the measurement is cached only by font family and taken using `NSFont.systemFontSize` / `UIFont.labelFontSize`.+Historical: `RawSourceView.contentMinWidth` measured a single "M" at `NSFont.systemFontSize` / `UIFont.labelFontSize`, cached by family alone, while the text rendered with `TypographyResolver.scaledMonoFont`; scaled-up or wider custom mono fonts left the right edge of long lines unreachable with wrapping off. -The displayed raw-source text uses `TypographyResolver.scaledMonoFont`, which is based on Prism's own baseline body size plus the user's text scale. With wrapping disabled, larger text sizes or wider custom monospace fonts can make the computed scrollable width too small, leaving the right edge of long lines unreachable.--Any fix should measure the effective rendered mono font and key the width cache by both family and effective size or scale.+Fixed by T-995 (measure at the rendered family + effective size, key the cache by both) and superseded by T-1830 (measure the widest RENDERED line's text rather than count × "M", re-selected on font change). The current design lives in `raw-source-view.md` under "Horizontal Scroll Width Calculation"; kept here only so the old symptom isn't re-investigated.
CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 9f8806be..32a958aa 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- Raw Source with Wrap Long Lines off no longer clips the end of lines containing tabs, CJK text, or emoji (T-1830). The horizontal scroll area was sized from the longest line's character count times the width of a single "M", which assumes every character is exactly one column wide — tabs, wide CJK glyphs, and emoji are not, so a line full of them could render far wider than the scrollable area accounted for, leaving its right edge unreachable. The scroll width is now taken from measuring the actual widest RENDERED line's text with the font in use, the same way the text itself is drawn, so tab stops and wide glyphs are sized correctly; picking that widest line is also now based on its measured width rather than its character count, since a short line full of wide characters can render wider than a longer plain one — and it is picked again whenever the mono font family or text scale changes, because tab stops do not scale with the font, so the widest line at one size is not always the widest at another. - The shared unit-test host no longer aborts part-way through a run and reports every still-queued test as a failure it never ran (T-2219, third pass). PR #380 fixed one cause of this — a synchronous `@MainActor` test body reaching WebKit off the main thread — and the cascade kept coming back with `make verify-test-isolation` passing, because there was a second, unrelated cause on a lifetime path no constructor check can see. A crash report captured during this investigation named it: WebKit raises an Objective-C exception on a Swift async job on the main thread, and the exception unwinds into a Swift frame, where `_swift_exceptionPersonality` calls `swift::fatalError` and aborts **inside the throw**. That last detail is why the failure had been so expensive to chase: the process dies before `NSSetUncaughtExceptionHandler` or any `catch` can see it, so nothing is recorded, the exception's own text is destroyed with the process, and the test the bundle blames is simply whichever job was resumed at that instant — twice it blamed `URLEncodingCorpusTests`, which does not touch WebKit at all. The path in this repository that can reach that stack is the `prism-doc://` scheme handler: it produced responses from an unstructured task into an unbounded stream buffer, so WebKit stopping a task (navigating away, superseding a load, releasing a page — all routine) raced every response not yet delivered, and a `WKURLSchemeTask` given anything after it has been stopped raises exactly that exception. Three of its four production points had no cancellation check at all. Every one of them now goes through a single sink that stops producing as soon as its consumer is gone, failures included, and a source-contract test fails the build if a new one bypasses it — a behavioural test is impossible here, since reproducing the race aborts the process running it. That sink is hardening rather than a closed door, and the code says so: its cancellation signal arrives only once the stream is already torn down, so it narrows the window instead of removing it. A bounded stream buffer is not the missing piece — `AsyncStream` has no back-pressure at any policy, so bounding it would drop response and body elements rather than slow the producer down. - Live-WebKit tests no longer hold hundreds of WebKit processes open at once (T-2219). Measured on the run that reproduced the abort: 230 WebKit helper processes started by one test host, 226 of them alive simultaneously in the instant it died, only 4 ever reclaimed — about 206 concurrent live pages. `-parallel-testing-worker-count 1` does not bound this; it bounds test host processes, while swift-testing runs tests concurrently inside one host with no cap, and the live-WebKit tests are the slowest in the target, so they are precisely the ones that accumulate. Every clean run peaked in the same place, so the pile-up is not itself the crash — it is the condition the crash needs, and it is why a run only fails under load and never reproduces a suite in isolation. Suites that can hold a live page are now charged against a shared budget, which took the peak from 226 to 59 and restored reclamation during the run. `make verify-test-isolation` fails when a suite that can reach WebKit is not covered, sharing one reachability model with the existing synchronous-construction rule so a suite cannot be visible to one check and invisible to the other; it found an uncovered suite on `main` the first time it ran, and eight more once `WebViewPool` and `SVGRenderer` were added to the list of production types it treats as building a page. That list is the boundary of both checks and is documented as such: a production type that builds a page but is not named there is invisible to them, and nothing can discover the omission automatically. Nothing is skipped, excluded or reordered, and the number of tests executed is unchanged. - `Tools/check-test-results.sh` now tells you what to look for when it detects the cascade (T-2219). It used to point at `~/Library/Logs/DiagnosticReports/prism-*.ips` and stop there. Those reports are frequently never written — three consecutive reproductions on the development machine produced none — and they rotate away within days, which is how this ticket twice lost the only evidence it had. The message now names the stack signature that identifies this abort, so a report that does exist can be read correctly on the first attempt, and states that there is no in-process alternative to it.

Things to double-check

Verification scope.

One targeted xcodebuild test on macOS against a git archive export of 7f2b650e (isolated derived data): MonospaceMetricsTests, MonospaceMetricsRenderParityTests, RawSourceViewModelTests, RawSourceViewModelHighlightingTests — 82 passed, 0 failed, 0 skipped. Not run: full make test-quick, iOS make test, make test-ui, make build-ios. The parity suite and testPlainShortcutIsLive (Courier trait) have not been exercised on an iOS simulator in this review.

Pre-existing production warnings, not from this branch.

The test build (Debug, testing enabled) reports three warnings in prism/Services/URLDocumentLoader.swift (lines 156, 188, 205: main-actor-isolated static call from outside the actor; captured var in concurrent code). That file is untouched by this branch and identical to origin/main. Recorded so the zero-warnings gate is not mis-attributed to T-1830.

Ordering of the unstructured font-change Tasks.

From a MainActor context, Task {} inherits isolation and in practice enqueues FIFO on the main executor at equal priority; the inversion described in finding 1 needs a priority difference or scheduler change. Judged low-probability, and self-correcting on the next font change, hence non-blocking.

iOS Dynamic Type vs MonoFontSpec.size.

scaledMonoFont uses Font.custom(_:size:relativeTo: .body) for custom families, which Dynamic Type scales further than baselineSize * scaleFactor. Pre-existing from T-995 and outside this ticket, but the parity tests run at the default text size and would not detect it.