From 1f55e710baa522797975692344b13704b0159486 Mon Sep 17 00:00:00 2001 From: Adam Jesionkiewicz Date: Tue, 6 Oct 2026 21:43:31 +0200 Subject: [PATCH 1/4] fix: improve native editing latency and fit expanded tables Signed-off-by: Adam Jesionkiewicz --- Sources/PreviewMD/MarkdownSourceEditor.swift | 63 +++++- Sources/PreviewMD/Models.swift | 19 +- .../PreviewMD/Resources/Renderer/renderer.css | 4 + .../PreviewMD/Resources/Renderer/renderer.js | 13 +- Sources/PreviewMD/WorkspaceView.swift | 7 +- .../EditingPerformanceProfileTests.swift | 189 ++++++++++++++++++ Tests/PreviewMDTests/EditorWebViewTests.swift | 4 +- .../MarkdownSourceEditorTests.swift | 49 +++++ Tests/PreviewMDTests/RendererAuditTests.swift | 121 +++++++++++ .../SourceHighlightingRegressionTests.swift | 52 +++++ docs/EDITING_PERFORMANCE.md | 119 +++++++++++ docs/REGRESSION_AUDIT.md | 13 +- docs/performance/issue-24-after.json | 122 +++++++++++ docs/performance/issue-24-before.json | 122 +++++++++++ 14 files changed, 871 insertions(+), 26 deletions(-) create mode 100644 Tests/PreviewMDTests/EditingPerformanceProfileTests.swift create mode 100644 docs/EDITING_PERFORMANCE.md create mode 100644 docs/performance/issue-24-after.json create mode 100644 docs/performance/issue-24-before.json diff --git a/Sources/PreviewMD/MarkdownSourceEditor.swift b/Sources/PreviewMD/MarkdownSourceEditor.swift index 3639354..1d798fc 100644 --- a/Sources/PreviewMD/MarkdownSourceEditor.swift +++ b/Sources/PreviewMD/MarkdownSourceEditor.swift @@ -215,6 +215,7 @@ struct MarkdownSourceEditor: NSViewRepresentable { var documentID: UUID? = nil var splitSynchronizer: SplitEditorSynchronizer? = nil var isSplitSynchronizationEnabled = false + var isVisible = true func makeCoordinator() -> Coordinator { Coordinator( @@ -235,6 +236,7 @@ struct MarkdownSourceEditor: NSViewRepresentable { let textView = MarkdownSourceTextView() textView.delegate = context.coordinator textView.isRichText = false + textView.isEditable = isVisible textView.importsGraphics = false textView.allowsUndo = false textView.isAutomaticQuoteSubstitutionEnabled = false @@ -252,7 +254,7 @@ struct MarkdownSourceEditor: NSViewRepresentable { height: CGFloat.greatestFiniteMagnitude ) textView.backgroundColor = .clear - textView.string = text + textView.string = isVisible ? text : "" scrollView.documentView = textView let lineNumberRuler = MarkdownLineNumberRulerView( @@ -272,7 +274,7 @@ struct MarkdownSourceEditor: NSViewRepresentable { splitSynchronizer: splitSynchronizer, isEnabled: isSplitSynchronizationEnabled ) - context.coordinator.highlight() + if isVisible { context.coordinator.highlight() } return container } @@ -286,14 +288,31 @@ struct MarkdownSourceEditor: NSViewRepresentable { splitSynchronizer: splitSynchronizer, isEnabled: isSplitSynchronizationEnabled ) + // Keep the view identity and selection while Preview is visible, but + // defer text storage, layout and coloring until the source is revealed. + // The binding always supplies the latest document on that update. guard let textView = context.coordinator.textView else { return } + textView.isEditable = isVisible + guard isVisible else { + if textView.window?.firstResponder === textView { + textView.window?.makeFirstResponder(nil) + } + return + } let contentChanged = textView.string != text let appearanceChanged = context.coordinator.lastAppearanceName != textView.effectiveAppearance.name if contentChanged { let selection = textView.selectedRange() - textView.string = text context.coordinator.withoutPublishingSelection { + if let storage = textView.textStorage, + let ranges = MarkdownSourceHighlighting.changedTextRanges(from: textView.string, to: text) { + // Preserve unaffected text attributes and layout when a + // rich edit is echoed into the visible source pane. + storage.replaceCharacters(in: ranges.previous, with: (text as NSString).substring(with: ranges.current)) + } else { + textView.string = text + } textView.setSelectedRange( NSRange( location: min(selection.location, (text as NSString).length), @@ -303,7 +322,7 @@ struct MarkdownSourceEditor: NSViewRepresentable { } } if contentChanged || appearanceChanged { - context.coordinator.highlight(forceFull: true) + context.coordinator.highlight(forceFull: appearanceChanged || textView.textStorage == nil) context.coordinator.lineNumberRuler?.reload() } if contentChanged, @@ -357,7 +376,7 @@ struct MarkdownSourceEditor: NSViewRepresentable { } func textDidChange(_ notification: Notification) { - guard let textView else { return } + guard let textView, textView.isEditable else { return } text.wrappedValue = textView.string lineNumberRuler?.reload() scheduleHighlight() @@ -686,10 +705,9 @@ struct MarkdownSourceEditor: NSViewRepresentable { } enum MarkdownSourceHighlighting { - /// AppKit shifts existing attributes as characters are edited. Recolor only - /// changed logical lines; edits to fence boundaries can affect the rest of - /// the document, so those deliberately retain the full pass. - static func changedLineRange(from previous: String, to current: String) -> NSRange? { + /// Matching UTF-16 ranges, widened at surrogate boundaries so the inserted + /// substring cannot lose half of an emoji during NSString-to-String bridging. + static func changedTextRanges(from previous: String, to current: String) -> (previous: NSRange, current: NSRange)? { let old = previous as NSString let new = current as NSString var start = 0 @@ -705,8 +723,31 @@ enum MarkdownSourceHighlighting { oldEnd -= 1 newEnd -= 1 } - let oldLines = old.lineRange(for: NSRange(location: start, length: oldEnd - start)) - let newLines = new.lineRange(for: NSRange(location: start, length: newEnd - start)) + if splitsSurrogate(in: old, at: start) || splitsSurrogate(in: new, at: start) { + start -= 1 + } + if splitsSurrogate(in: old, at: oldEnd) || splitsSurrogate(in: new, at: newEnd) { + oldEnd += 1 + newEnd += 1 + } + return (NSRange(location: start, length: oldEnd - start), NSRange(location: start, length: newEnd - start)) + } + + private static func splitsSurrogate(in source: NSString, at offset: Int) -> Bool { + guard offset > 0, offset < source.length else { return false } + return (0xD800...0xDBFF).contains(source.character(at: offset - 1)) + && (0xDC00...0xDFFF).contains(source.character(at: offset)) + } + + /// AppKit shifts existing attributes as characters are edited. Recolor only + /// changed logical lines; edits to fence boundaries can affect the rest of + /// the document, so those deliberately retain the full pass. + static func changedLineRange(from previous: String, to current: String) -> NSRange? { + guard let ranges = changedTextRanges(from: previous, to: current) else { return nil } + let old = previous as NSString + let new = current as NSString + let oldLines = old.lineRange(for: ranges.previous) + let newLines = new.lineRange(for: ranges.current) let oldChangedLines = old.substring(with: oldLines) let newChangedLines = new.substring(with: newLines) if oldChangedLines.contains("```") || oldChangedLines.contains("~~~") diff --git a/Sources/PreviewMD/Models.swift b/Sources/PreviewMD/Models.swift index b7dc574..0fd6f6a 100644 --- a/Sources/PreviewMD/Models.swift +++ b/Sources/PreviewMD/Models.swift @@ -303,16 +303,27 @@ enum WorkspaceFileSort: String, CaseIterable, Identifiable { /// repeatedly without rescanning the full document during an editing update. @propertyWrapper struct MarkdownText: Equatable { + private var text: String var wrappedValue: String { - didSet { updateStatistics() } + get { text } + set { + text = newValue + // AppKit and the WebKit bridge can supply NSString-backed text. + // Materialize UTF-8 once instead of paying foreign-string character + // iteration costs on every statistics pass and subsequent read. + text.makeContiguousUTF8() + updateStatistics() + } } private(set) var wordCount: Int private(set) var characterCount: Int init(wrappedValue: String) { - self.wrappedValue = wrappedValue - wordCount = wrappedValue.split(whereSeparator: \.isWhitespace).count - characterCount = wrappedValue.count + var text = wrappedValue + text.makeContiguousUTF8() + self.text = text + wordCount = text.split(whereSeparator: \.isWhitespace).count + characterCount = text.count } private mutating func updateStatistics() { diff --git a/Sources/PreviewMD/Resources/Renderer/renderer.css b/Sources/PreviewMD/Resources/Renderer/renderer.css index 0136965..c401ab6 100644 --- a/Sources/PreviewMD/Resources/Renderer/renderer.css +++ b/Sources/PreviewMD/Resources/Renderer/renderer.css @@ -623,6 +623,7 @@ code { .table-scroll.is-wide { max-width: none; + margin-top: calc(1.55em + 8px); } .table-scroll.is-wide .table-sizer { @@ -632,6 +633,9 @@ code { .table-scroll.is-wide > .table-expand, .table-scroll.is-expanded > .table-expand { + /* A wide surface has no room beside the table. Keep its action above the + header instead of obscuring the final column or a scrolled header. */ + top: -32px; right: 8px; } diff --git a/Sources/PreviewMD/Resources/Renderer/renderer.js b/Sources/PreviewMD/Resources/Renderer/renderer.js index d5ff1fa..f87dcc9 100644 --- a/Sources/PreviewMD/Resources/Renderer/renderer.js +++ b/Sources/PreviewMD/Resources/Renderer/renderer.js @@ -551,15 +551,20 @@ const expanded = wrapper.classList.contains("is-expanded"); const contentWidth = articleContentWidth(); const baseTableWidth = columnCount * readableColumnWidth; - const tableWidth = columnCount * - (expanded ? Math.max(220, readableColumnWidth) : readableColumnWidth); const shouldUseWideSurface = expanded || baseTableWidth > contentWidth + 1; + const surface = shouldUseWideSurface ? wideTableSurface() : null; + const surfaceWidth = surface ? Math.max(contentWidth, surface.width) : contentWidth; + const availableWidth = surface ? Math.max(0, surfaceWidth - surface.leadingGutter) : contentWidth; + // Use the free surface before introducing scroll, but keep the collapsed + // minimum so narrow windows never squeeze columns below their readable size. + const tableWidth = expanded + ? Math.max(baseTableWidth, Math.min(availableWidth, columnCount * Math.max(220, readableColumnWidth))) + : baseTableWidth; if (sizer) sizer.style.minWidth = tableWidth + "px"; wrapper.classList.toggle("is-wide", shouldUseWideSurface); if (shouldUseWideSurface) { - const surface = wideTableSurface(); - wrapper.style.width = Math.max(contentWidth, surface.width) + "px"; + wrapper.style.width = surfaceWidth + "px"; wrapper.style.marginLeft = -surface.leadingGutter + "px"; wrapper.style.setProperty( "--table-leading-gutter", diff --git a/Sources/PreviewMD/WorkspaceView.swift b/Sources/PreviewMD/WorkspaceView.swift index cbfb119..104d538 100644 --- a/Sources/PreviewMD/WorkspaceView.swift +++ b/Sources/PreviewMD/WorkspaceView.swift @@ -1860,7 +1860,8 @@ private struct DocumentWorkspace: View { SourceEditor( documentID: document.id, splitSynchronizer: splitSynchronizer, - isSplitSynchronizationEnabled: isSplit + isSplitSynchronizationEnabled: isSplit, + isVisible: visibleSourceWidth > 0 ) .frame(width: visibleSourceWidth) .opacity(visibleSourceWidth > 0 ? 1 : 0) @@ -2153,6 +2154,7 @@ private struct SourceEditor: View { let documentID: UUID let splitSynchronizer: SplitEditorSynchronizer let isSplitSynchronizationEnabled: Bool + let isVisible: Bool var body: some View { ZStack { @@ -2161,7 +2163,8 @@ private struct SourceEditor: View { text: state.bindingForCurrentContent(), documentID: documentID, splitSynchronizer: splitSynchronizer, - isSplitSynchronizationEnabled: isSplitSynchronizationEnabled + isSplitSynchronizationEnabled: isSplitSynchronizationEnabled, + isVisible: isVisible ) } } diff --git a/Tests/PreviewMDTests/EditingPerformanceProfileTests.swift b/Tests/PreviewMDTests/EditingPerformanceProfileTests.swift new file mode 100644 index 0000000..a89bac9 --- /dev/null +++ b/Tests/PreviewMDTests/EditingPerformanceProfileTests.swift @@ -0,0 +1,189 @@ +import AppKit +import Foundation +import SwiftUI +import WebKit +import XCTest +@testable import PreviewMD + +/// Opt-in profiling of the real workspace, with isolated preferences and a +/// disposable in-memory edit. Timing results are evidence, not CI thresholds. +@MainActor +final class EditingPerformanceProfileTests: XCTestCase { + func testProfileNativeWorkspaceEditing() async throws { + let environment = ProcessInfo.processInfo.environment + guard let samplePath = environment["PREVIEWMD_PROFILE_SAMPLE"], + let outputPath = environment["PREVIEWMD_PROFILE_OUTPUT"] else { + throw XCTSkip("Set PREVIEWMD_PROFILE_SAMPLE and PREVIEWMD_PROFILE_OUTPUT to profile editing") + } + let sampleURL = URL(fileURLWithPath: samplePath) + let markdown = try String(contentsOf: sampleURL, encoding: .utf8) + var text = MarkdownText(wrappedValue: markdown) + let paragraph = (markdown as NSString).range(of: "\n\n") + let offset = paragraph.location == NSNotFound ? 0 : NSMaxRange(paragraph) + var statisticsUpdate: [Double] = [] + for _ in 0..<16 { + let replacement = (text.wrappedValue as NSString).replacingCharacters(in: NSRange(location: offset, length: 0), with: "x") + let start = CFAbsoluteTimeGetCurrent() + text.wrappedValue = replacement + statisticsUpdate.append((CFAbsoluteTimeGetCurrent() - start) * 1000) + } + _ = NSApplication.shared + NSApplication.shared.setActivationPolicy(.regular) + let suite = "PreviewMDEditingProfile.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let state = AppState(defaults: defaults) + state.liveReloadEnabled = false + state.open(url: sampleURL) + let documentID = try XCTUnwrap(state.currentDocument?.id) + var stateUpdate: [Double] = [] + var lineMapUpdate: [Double] = [] + for _ in 0..<16 { + let replacement = (state.currentDocument!.content as NSString).replacingCharacters(in: NSRange(location: offset, length: 0), with: "x") + var start = CFAbsoluteTimeGetCurrent() + state.updateContent(replacement, for: documentID, origin: .source) + stateUpdate.append((CFAbsoluteTimeGetCurrent() - start) * 1000) + start = CFAbsoluteTimeGetCurrent() + XCTAssertGreaterThan(MarkdownLineMap.lineStartOffsets(in: replacement).count, 1) + lineMapUpdate.append((CFAbsoluteTimeGetCurrent() - start) * 1000) + } + let host = NSHostingView(rootView: WorkspaceView().environmentObject(state)) + let window = NSWindow( + contentRect: NSRect(x: 100, y: 100, width: 1200, height: 760), + styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false + ) + window.isReleasedWhenClosed = false + window.title = "PreviewMD editing profile — synthetic document" + window.contentView = host + window.makeKeyAndOrderFront(nil) + NSApplication.shared.activate(ignoringOtherApps: true) + window.makeFirstResponder(nil) + defer { window.close() } + var results: [[String: Any]] = [] + for (name, mode, rich) in [ + ("source", DisplayMode.source, false), + ("preview", DisplayMode.preview, true), + ("split-source", DisplayMode.split, false), + ("split-preview", DisplayMode.split, true) + ] { + state.updateContent(markdown, for: documentID, origin: .source) + state.displayMode = mode + host.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(500)) + let webView = try XCTUnwrap(descendant(of: WKWebView.self, in: host)) + if rich { + XCTAssertTrue(window.makeFirstResponder(webView), "Rich typing requires the native web view to have focus") + let flushed: String? = await withCheckedContinuation { continuation in + state.rendererController.flushMarkdown(for: documentID) { continuation.resume(returning: $0) } + } + XCTAssertNotNil(flushed, "Visible renderer must be ready before profiling") + } + _ = try await webView.evaluateJavaScript(""" + window.profileRenderCount = 0; + if (!window.profileOriginalRender) { + window.profileOriginalRender = window.previewmdRender; + window.previewmdRender = function(options) { + window.profileRenderCount++; + return window.profileOriginalRender(options); + }; + } + true; + """) + let textView = try XCTUnwrap(descendant(of: MarkdownSourceTextView.self, in: host)) + if !rich { + window.makeFirstResponder(textView) + let paragraph = (markdown as NSString).range(of: "\n\n") + let offset = paragraph.location == NSNotFound ? 0 : NSMaxRange(paragraph) + textView.setSelectedRange(NSRange(location: offset, length: 0)) + } + var synchronous: [Double] = [] + var committed: [Double] = [] + var stateCommitted: [Double] = [] + var nativeReadingStatistics: [Double] = [] + if !rich { + for _ in 0..<16 { + let start = CFAbsoluteTimeGetCurrent() + let counts = MarkdownText(wrappedValue: textView.string) + XCTAssertGreaterThan(counts.wordCount, 0) + nativeReadingStatistics.append((CFAbsoluteTimeGetCurrent() - start) * 1000) + } + } + for _ in 0..<16 { + let revision = try XCTUnwrap(state.currentDocument?.contentRevision) + let start = CFAbsoluteTimeGetCurrent() + if rich { + _ = try await webView.callAsyncJavaScript(""" + const paragraph = document.querySelector('#preview-document > p'); + if (!paragraph) throw new Error('No editable paragraph in the sample'); + const range = document.createRange(); + range.selectNodeContents(paragraph); + range.collapse(false); + const selection = window.getSelection(); + selection.removeAllRanges(); selection.addRange(range); + paragraph.focus(); + document.execCommand('insertText', false, 'x'); + return true; + """, contentWorld: .page) + } else { + textView.insertText("x", replacementRange: textView.selectedRange()) + } + synchronous.append((CFAbsoluteTimeGetCurrent() - start) * 1000) + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while state.currentDocument?.contentRevision == revision { + guard ContinuousClock.now < deadline else { + XCTFail("Edit did not reach document state in \(name)") + return + } + try await Task.sleep(for: .milliseconds(1)) + } + stateCommitted.append((CFAbsoluteTimeGetCurrent() - start) * 1000) + await withCheckedContinuation { (continuation: CheckedContinuation) in + DispatchQueue.main.async { + host.layoutSubtreeIfNeeded() + window.displayIfNeeded() + continuation.resume() + } + } + committed.append((CFAbsoluteTimeGetCurrent() - start) * 1000) + // Allow deferred highlighting and split preview updates to run + // between inputs. Do not await web animation frames: an + // occluded or zero-width web view can suspend those callbacks. + try await Task.sleep(for: .milliseconds(60)) + } + let renderCount = try await webView.evaluateJavaScript("window.profileRenderCount") + XCTAssertGreaterThan(state.currentDocument?.contentRevision ?? 0, 0) + results.append([ + "mode": name, "samples": committed.count, + "insertion": statistics(synchronous), + "documentCommit": statistics(stateCommitted), + "documentCommitAndNativeDisplay": statistics(committed), + "readingStatisticsFromNativeText": nativeReadingStatistics.isEmpty ? [:] : statistics(nativeReadingStatistics), + "fullPreviewRenders": try XCTUnwrap(renderCount as? Int) + ]) + } + let report: [String: Any] = [ + "sampleUTF16Units": markdown.utf16.count, + "sampleUTF8Bytes": markdown.utf8.count, + "system": ProcessInfo.processInfo.operatingSystemVersionString, + "window": ["width": 1200, "height": 760], + "readingStatisticsUpdate": statistics(statisticsUpdate), + "stateUpdateWithoutViews": statistics(stateUpdate), + "lineMapUpdate": statistics(lineMapUpdate), + "measurement": "Native insertion or WebKit insertText through document-state revision and the next native display pass; excludes physical screen presentation. WebKit insertion includes IPC. Inputs have a 60 ms pause for deferred work, excluded from timings.", + "results": results + ] + let data = try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: URL(fileURLWithPath: outputPath), options: .atomic) + print("Editing profile written to \(outputPath)") + } + + private func statistics(_ values: [Double]) -> [String: Double] { + let sorted = values.sorted() + return ["medianMs": sorted[sorted.count / 2], "p95Ms": sorted[Int(ceil(Double(sorted.count) * 0.95)) - 1], "maxMs": sorted.last!] + } + + private func descendant(of type: T.Type, in view: NSView) -> T? { + if let match = view as? T { return match } + return view.subviews.lazy.compactMap { self.descendant(of: type, in: $0) }.first + } +} diff --git a/Tests/PreviewMDTests/EditorWebViewTests.swift b/Tests/PreviewMDTests/EditorWebViewTests.swift index 8529140..f915e44 100644 --- a/Tests/PreviewMDTests/EditorWebViewTests.swift +++ b/Tests/PreviewMDTests/EditorWebViewTests.swift @@ -1738,8 +1738,8 @@ final class EditorWebViewTests: XCTestCase, WKNavigationDelegate { ) XCTAssertEqual(expandedWrapperWidth, initialWrapperWidth, accuracy: 0.5) XCTAssertEqual(expandedWrapperLeft, initialWrapperLeft, accuracy: 0.5) - XCTAssertGreaterThan(expandedTableWidth, tableWidth) - XCTAssertEqual(expanded["minimumWidth"] as? String, "1320px") + XCTAssertGreaterThanOrEqual(expandedTableWidth, tableWidth) + XCTAssertEqual(expanded["minimumWidth"] as? String, "864px") XCTAssertEqual(expanded["expanded"] as? String, "true") XCTAssertEqual(expanded["label"] as? String, "Collapse table") XCTAssertEqual(expanded["otherExpanded"] as? String, "false") diff --git a/Tests/PreviewMDTests/MarkdownSourceEditorTests.swift b/Tests/PreviewMDTests/MarkdownSourceEditorTests.swift index 5cc7730..4903c32 100644 --- a/Tests/PreviewMDTests/MarkdownSourceEditorTests.swift +++ b/Tests/PreviewMDTests/MarkdownSourceEditorTests.swift @@ -5,6 +5,55 @@ import XCTest @testable import PreviewMD final class MarkdownSourceEditorTests: XCTestCase { + func testReadingStatisticsPreserveCocoaTextUnicodeAndWhitespace() { + let samples = ["", "zażółć\t世界\r\n👩🏽‍💻", "e\u{301} café\u{00a0}test", "🇵🇱\n\nword", "a\r\nb\r c\u{2028}d"] + var text = MarkdownText(wrappedValue: NSMutableString(string: samples[1]) as String) + for sample in samples { + let cocoa = NSMutableString(string: sample) + text.wrappedValue = cocoa as String + cocoa.append(" must not mutate the document") + XCTAssertEqual(text.wrappedValue, sample) + XCTAssertEqual(text.wordCount, sample.split(whereSeparator: \.isWhitespace).count) + XCTAssertEqual(text.characterCount, sample.count) + } + } + + @MainActor + func testHiddenSourceDefersTextAndRestoresLatestContentInTheSameView() async throws { + _ = NSApplication.shared + let host = NSHostingView(rootView: MarkdownSourceEditor(text: .constant("# Initial"), isVisible: false)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + host.layoutSubtreeIfNeeded() + defer { window.close() } + let textView = try XCTUnwrap(host.descendant(of: MarkdownSourceTextView.self)) + XCTAssertEqual(textView.string, "", "Preview-only launch must avoid native text layout and highlighting") + XCTAssertFalse(textView.isEditable) + host.rootView = MarkdownSourceEditor(text: .constant("# Latest\n\nBody"), isVisible: true) + host.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(50)) + XCTAssertTrue(host.descendant(of: MarkdownSourceTextView.self) === textView) + XCTAssertEqual(textView.string, "# Latest\n\nBody") + XCTAssertTrue(textView.isEditable) + XCTAssertNotNil(textView.textStorage?.attribute(.foregroundColor, at: 0, effectiveRange: nil)) + textView.setSelectedRange(NSRange(location: 4, length: 0)) + window.makeFirstResponder(textView) + host.rootView = MarkdownSourceEditor(text: .constant("Changed while hidden"), isVisible: false) + host.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(50)) + XCTAssertEqual(textView.string, "# Latest\n\nBody") + XCTAssertFalse(textView.isEditable) + XCTAssertFalse(window.firstResponder === textView, "Hidden stale source must not receive keyboard input") + host.rootView = MarkdownSourceEditor(text: .constant("Final edit"), isVisible: true) + host.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(50)) + XCTAssertTrue(host.descendant(of: MarkdownSourceTextView.self) === textView) + XCTAssertEqual(textView.string, "Final edit") + XCTAssertEqual(textView.selectedRange(), NSRange(location: 4, length: 0)) + } + func testLineMapIncludesEmptyAndTrailingLines() { XCTAssertEqual(MarkdownLineMap.lineStartOffsets(in: ""), [0]) XCTAssertEqual(MarkdownLineMap.lineStartOffsets(in: "one"), [0]) diff --git a/Tests/PreviewMDTests/RendererAuditTests.swift b/Tests/PreviewMDTests/RendererAuditTests.swift index ca88e6a..5436198 100644 --- a/Tests/PreviewMDTests/RendererAuditTests.swift +++ b/Tests/PreviewMDTests/RendererAuditTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import PDFKit import WebKit @@ -202,6 +203,126 @@ final class RendererAuditTests: XCTestCase, WKNavigationDelegate { } } + func testExpandedTableUsesAvailableSpaceWithoutNearFitScrolling() async throws { + let webView = try await makeEditor(""" + | Area | Status | Owner | Progress | + | :--- | :---: | :--- | ---: | + | Native macOS shell | ✅ Ready | Design | 100% | + | Markdown reading and editing | ✅ Ready | Platform | 100% | + | Diagrams, math, and code | ✅ Ready | Content | 100% | + | Agent edit review | ✅ Ready | Automation | 100% | + | Quick Look and PDF export | ✅ Ready | Files | 100% | + """) + let window = NSWindow(contentRect: NSRect(x: 100, y: 100, width: 980, height: 700), + styleMask: [.titled, .resizable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = webView + NSApplication.shared.setActivationPolicy(.regular) + window.makeKeyAndOrderFront(nil) + NSApplication.shared.activate(ignoringOtherApps: true) + window.makeFirstResponder(nil) + defer { window.close() } + var sweep: [[String: Any]] = [] + var expanded = false + for (windowWidth, readingWidth, expected) in [ + (980, 902, "near-fit"), (1800, 560, "roomy"), + (520, 480, "narrow"), (980, 902, "near-fit") + ] { + window.setContentSize(NSSize(width: CGFloat(windowWidth), height: 700)) + webView.setFrameSize(NSSize(width: CGFloat(windowWidth), height: 700)) + webView.layoutSubtreeIfNeeded() + window.displayIfNeeded() + try await Task.sleep(for: .milliseconds(100)) + XCTAssertEqual(webView.bounds.width, CGFloat(windowWidth)) + let result = try await webView.callAsyncJavaScript( + """ + window.previewmdSetLayout(readingWidth, false, 0, false); + await new Promise(resolve => setTimeout(resolve, 300)); + // Headless WebKit can suspend animation frames. Finish the + // width transition and use the editor's public layout hook. + const article = document.getElementById('preview-document'); + article.getAnimations().forEach(animation => animation.finish()); + window.previewmdRefreshTableLayout(); + const wrapper = document.querySelector('.table-scroll'); + if (!expanded) wrapper.querySelector('.table-expand').click(); + const viewport = wrapper.querySelector('.table-viewport'); + const sizer = wrapper.querySelector('.table-sizer'); + const gutter = parseFloat(wrapper.style.getPropertyValue('--table-leading-gutter')); + const shell = document.getElementById('preview-shell'); + const shellStyle = getComputedStyle(shell); + const expectedArticleWidth = Math.min(readingWidth, shell.clientWidth - parseFloat(shellStyle.paddingLeft) - parseFloat(shellStyle.paddingRight)); + return { + windowWidth: window.innerWidth, + articleWidth: article.getBoundingClientRect().width, expectedArticleWidth, + available: wrapper.getBoundingClientRect().width - gutter, + minimum: parseFloat(sizer.style.minWidth), + smallestColumn: Math.min(...Array.from(wrapper.querySelectorAll('th')).map(cell => cell.getBoundingClientRect().width)), + buttonBottom: wrapper.querySelector('.table-expand').getBoundingClientRect().bottom, + headerTop: wrapper.querySelector('th').getBoundingClientRect().top, + client: viewport.clientWidth, scroll: viewport.scrollWidth, + right: sizer.getBoundingClientRect().right, + viewportRight: viewport.getBoundingClientRect().right, + expanded: wrapper.querySelector('.table-expand').getAttribute('aria-expanded') + }; + """, arguments: ["readingWidth": readingWidth, "expanded": expanded], contentWorld: .page + ) + expanded = true + let metrics = try XCTUnwrap(result as? [String: Any]) + sweep.append(metrics) + XCTAssertEqual(metrics["windowWidth"] as? Int, windowWidth) + XCTAssertEqual(metrics["articleWidth"] as? Double ?? .nan, metrics["expectedArticleWidth"] as? Double ?? .nan, accuracy: 0.1) + let available = try XCTUnwrap(metrics["available"] as? Double) + let minimum = try XCTUnwrap(metrics["minimum"] as? Double) + let client = try XCTUnwrap(metrics["client"] as? Int) + let scroll = try XCTUnwrap(metrics["scroll"] as? Int) + XCTAssertEqual(metrics["expanded"] as? String, "true") + XCTAssertGreaterThanOrEqual(minimum, 576) + XCTAssertGreaterThanOrEqual(metrics["smallestColumn"] as? Double ?? 0, 144) + XCTAssertLessThanOrEqual(metrics["buttonBottom"] as? Double ?? .infinity, metrics["headerTop"] as? Double ?? -.infinity, + "The expansion action must not cover headers at any width") + switch expected { + case "near-fit": + XCTAssertGreaterThan(available, 576) + XCTAssertLessThan(available, 880) + XCTAssertEqual(client, scroll, "Near-fit table must fit: \(metrics)") + XCTAssertEqual(minimum, available, accuracy: 0.1) + XCTAssertEqual(metrics["right"] as? Double ?? .nan, metrics["viewportRight"] as? Double ?? .nan, accuracy: 1) + case "roomy": + XCTAssertGreaterThan(available, 880) + XCTAssertEqual(minimum, 880) + XCTAssertEqual(client, scroll) + default: + XCTAssertLessThan(available, 576) + XCTAssertEqual(minimum, 576) + XCTAssertGreaterThan(scroll, client, "Unreadably small surfaces must still scroll") + } + if let path = ProcessInfo.processInfo.environment["PREVIEWMD_TABLE_SNAPSHOTS"] { + let directory = URL(fileURLWithPath: path, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let configuration = WKSnapshotConfiguration() + configuration.rect = NSRect(x: 0, y: 0, width: CGFloat(windowWidth), height: 400) + let snapshot = try await webView.takeSnapshot(configuration: configuration) + let bitmap = try XCTUnwrap(NSBitmapImageRep(data: try XCTUnwrap(snapshot.tiffRepresentation))) + try XCTUnwrap(bitmap.representation(using: .png, properties: [:])).write(to: directory.appendingPathComponent("table-\(expected)-\(sweep.count).png")) + try JSONSerialization.data(withJSONObject: sweep, options: [.prettyPrinted, .sortedKeys]).write(to: directory.appendingPathComponent("geometry.json")) + } + } + let collapsed = try await webView.callAsyncJavaScript( + """ + const wrapper = document.querySelector('.table-scroll'); + wrapper.querySelector('.table-expand').click(); + const viewport = wrapper.querySelector('.table-viewport'); + return { expanded: wrapper.querySelector('.table-expand').getAttribute('aria-expanded'), + minimum: wrapper.querySelector('.table-sizer').style.minWidth, + client: viewport.clientWidth, scroll: viewport.scrollWidth }; + """, contentWorld: .page + ) + let metrics = try XCTUnwrap(collapsed as? [String: Any]) + XCTAssertEqual(metrics["expanded"] as? String, "false") + XCTAssertEqual(metrics["minimum"] as? String, "576px") + XCTAssertEqual(metrics["client"] as? Int, metrics["scroll"] as? Int) + } + func testHiddenSourceIsRenderedBeforePDFPreparationAndRestoredAfterward() async throws { let webView = try await makeEditor("Old source") let coordinator = outputCoordinator(in: webView, visible: false) diff --git a/Tests/PreviewMDTests/SourceHighlightingRegressionTests.swift b/Tests/PreviewMDTests/SourceHighlightingRegressionTests.swift index 09c9874..2cefb1a 100644 --- a/Tests/PreviewMDTests/SourceHighlightingRegressionTests.swift +++ b/Tests/PreviewMDTests/SourceHighlightingRegressionTests.swift @@ -6,6 +6,54 @@ import XCTest @MainActor final class SourceHighlightingRegressionTests: XCTestCase { + func testIncrementalSourceReplacementPreservesUnicodeAndLineBoundaries() throws { + for (previous, current) in [ + ("before 🙂 after", "before 🙃 after"), + ("before 👩🏽‍💻 after", "before 👩‍💻 after"), + ("e\u{301}", "e"), ("e", "e\u{301}"), + ("🇵🇱🇫🇷", "🇵🇱🇩🇪"), ("a\r\nb", "a\nb"), + ("a\nb", "ab"), ("", "🙂"), ("🙂", ""), + ("\u{10000}a", "\u{10400}a"), ("same", "same") + ] { + let storage = NSTextStorage(string: previous) + if let ranges = MarkdownSourceHighlighting.changedTextRanges(from: previous, to: current) { + storage.replaceCharacters(in: ranges.previous, with: (current as NSString).substring(with: ranges.current)) + } + XCTAssertEqual(storage.string, current) + } + } + + func testRichEchoChangesOnlyAffectedSourceTextAndKeepsOtherColors() async throws { + _ = NSApplication.shared + let source = "# Heading\n\nPlain line\n\n" + String(repeating: "Unchanged line\n", count: 200) + let host = NSHostingView(rootView: MarkdownSourceEditor(text: .constant(source))) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + host.layoutSubtreeIfNeeded() + defer { window.close() } + let textView = try XCTUnwrap(findTextView(in: host)) + let storage = try XCTUnwrap(textView.textStorage) + let headingColor = storage.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor + let observer = AttributeEditObserver() + storage.delegate = observer + let changed = source.replacingOccurrences(of: "Plain line", with: "Plain linex") + host.rootView = MarkdownSourceEditor(text: .constant(changed)) + host.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(50)) + XCTAssertEqual(textView.string, changed) + XCTAssertFalse(observer.characterRanges.isEmpty) + XCTAssertTrue(observer.characterRanges.allSatisfy { $0.length < 100 }) + XCTAssertTrue(observer.attributeRanges.allSatisfy { $0.length < 100 }) + XCTAssertEqual(storage.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor, headingColor) + } + + private func findTextView(in view: NSView) -> NSTextView? { + if let textView = view as? NSTextView { return textView } + return view.subviews.lazy.compactMap { self.findTextView(in: $0) }.first + } + func testSingleLineEditDoesNotRestyleTheWholeDocument() throws { _ = NSApplication.shared let source = "# Heading\n\n" + (0..<2_000).map { "Plain line \($0)" }.joined(separator: "\n") @@ -58,6 +106,7 @@ final class SourceHighlightingRegressionTests: XCTestCase { private final class AttributeEditObserver: NSObject, NSTextStorageDelegate { var attributeRanges: [NSRange] = [] + var characterRanges: [NSRange] = [] func textStorage( _ textStorage: NSTextStorage, @@ -68,6 +117,9 @@ private final class AttributeEditObserver: NSObject, NSTextStorageDelegate { if editedMask.contains(.editedAttributes) { attributeRanges.append(editedRange) } + if editedMask.contains(.editedCharacters) { + characterRanges.append(editedRange) + } } } #endif diff --git a/docs/EDITING_PERFORMANCE.md b/docs/EDITING_PERFORMANCE.md new file mode 100644 index 0000000..5ee2cc3 --- /dev/null +++ b/docs/EDITING_PERFORMANCE.md @@ -0,0 +1,119 @@ +# Native editing and table follow-up + +## Scope and causes + +This follow-up addresses [#24](https://github.com/ashtree74/PreviewMD/issues/24) +and [#27](https://github.com/ashtree74/PreviewMD/issues/27), based on main +`9b0cae993bfac779689ac9904bbe7727b580007d` (PreviewMD 1.8). The Windows port +remains independent. + +The original public synthetic attachment is 126,669 UTF-8 bytes and UTF-16 +units, SHA-256 +`5dcf69360c09ff74635569eb1fd3551245c14c930be173deb064929f0cf51430`. +It is not copied into the repository. + +Native profiling exposed costs that the earlier component harness missed: + +- Reading statistics took about 25 ms on `NSTextView.string`, compared with + about 5 ms on a native Swift string containing the same text. Materializing + contiguous UTF-8 once in document storage removes repeated foreign-string + iteration costs while preserving Swift's word and grapheme counting semantics. +- Rich edits updated and recolored the entire native source editor, including + when its column was hidden. Hidden source work now waits until the column is + revealed. The view and selection survive; a hidden editor resigns focus and + cannot publish stale keyboard input. +- Visible rich-to-source updates now replace only the changed UTF-16 range, + retaining attributes outside it and using incremental highlighting. Ranges + preserve surrogate pairs. Fence edits and appearance changes keep full + highlighting passes. + +## Before and after + +Measurements were made on October 6, 2026, on the same arm64 Mac running +macOS 27 (26A428), Apple Swift 6.4, release optimization and SDK 27, with the +real `WorkspaceView` hosted in a 1200 × 760 native window. The baseline is a +clean archive of main using exactly the same profiling test as the patch. +Preferences use a disposable suite; edits stay in memory and never save the +sample. Both editing surfaces explicitly receive native focus before input. + +Each mode receives 16 single-character edits with a 60 ms pause between inputs +for deferred work. The pause is excluded. Medians are milliseconds: + +| Mode | Input operation, before → after | Document revision, before → after | Revision and forced native display, before → after | +| --- | --- | --- | --- | +| Markdown | 26.87 → 8.42 | 26.88 → 8.43 | 65.68 → 51.86 | +| Preview | 1.71 → 1.70 | 74.12 → 50.28 | 75.30 → 52.62 | +| Split, Markdown input | 27.43 → 8.96 | 27.44 → 8.97 | 74.60 → 60.03 | +| Split, Preview input | 1.71 → 1.92 | 74.13 → 65.25 | 75.83 → 67.35 | + +The native input operation includes synchronous AppKit callbacks. Rich input +includes WebKit IPC; the DOM insertion is already fast and is not the principal +gain. Reading statistics directly from native text dropped from roughly 25 ms +to 6.7–6.9 ms, including conversion. Source-only edits performed zero full +preview renders; split source edits performed 16 required visible renders; +rich edits performed zero echo renders. + +These are automated native workflow timings, not physical key-to-pixel latency. +The final column includes the harness's explicit SwiftUI layout and AppKit +display pass. Bridge callbacks, scheduling and native painting contribute to +the other timings; cold maximums and p95 are in the raw reports. With 16 samples, +nearest-rank p95 equals the maximum. No timing threshold is enforced in CI. +The issue remains open for the reporter's retest after a public release. + +Raw reports: [before](performance/issue-24-before.json) and +[after](performance/issue-24-after.json). + +## Reproducing the measurement + +Download the [original public sample](https://github.com/user-attachments/files/31117537/large-sample.md) +to a temporary location and verify its checksum. Do not substitute a smaller +fixture. On the Swift Build toolchain used for these measurements: + +```bash +previewmd_sdk=$(xcrun --sdk macosx --show-sdk-path) +previewmd_sdk_version=$(plutil -extract Version raw -o - "$previewmd_sdk/SDKSettings.json") +PREVIEWMD_PROFILE_SAMPLE=/private/tmp/previewmd-issue24-large-sample.md \ +PREVIEWMD_PROFILE_OUTPUT=/private/tmp/previewmd-editing-profile.json \ +swift test -c release --sdk "$previewmd_sdk" \ + -Xlinker -platform_version -Xlinker macos \ + -Xlinker 14.0 -Xlinker "$previewmd_sdk_version" \ + --filter EditingPerformanceProfileTests +``` + +Run baseline and patch sequentially without competing native tests or builds. +Without both environment variables, the profiling test explicitly skips. + +## Expanded tables + +The width policy follows jedrzejsieracki's #27 proposal: clamp the expanded +minimum to available width, never below the collapsed readable minimum. Roomy +surfaces retain the 220 px per-column target; narrow surfaces still scroll. +The existing leading-gutter correction remains intact. The expansion action +sits above wide tables, away from their headers, with keyboard focus styling +preserved. + +`RendererAuditTests.testExpandedTableUsesAvailableSpaceWithoutNearFitScrolling` +sweeps 980/1800/520/980 pt windows and 902/560/480/902 pt reading widths using +the showcase's four-column table. It checks the final article width, minimum +and actual column widths, scroll extent, right edge, action/header separation +and collapse. The six-column regression retains independent expansion and +unchanged Markdown serialization. + +For deterministic policy checks, the test finishes CSS width transitions and +calls the editor's existing public relayout hook: headless WebKit can suspend +animation frames. This does not substitute for the existing transition test. +Set `PREVIEWMD_TABLE_SNAPSHOTS` to a temporary directory to export PNGs and +geometry for visual inspection. The near-fit and roomy tables fit without +scroll; narrow columns remain at least 144 px and scroll as needed. + +## Validation + +The full local suite passed: 174 XCTest and 5 Swift Testing tests, with the +opt-in profiler explicitly skipped in the normal run. The profiler passed +separately on baseline and patch. The final renderer checks passed after the +header-action adjustment. Nine Python site tests and eight Node signup tests +also passed. The complete ad-hoc bundle and embedded Quick Look extension +passed strict signature verification, contain both arm64 and x86_64, and have +matching current renderer resources. The build script verified macOS 14 +deployment and SDK 27 metadata in all four slices. These changes do not create +a new public release; the signed and notarized 1.8 artifacts remain intact. diff --git a/docs/REGRESSION_AUDIT.md b/docs/REGRESSION_AUDIT.md index 05f80ef..ff9a8d2 100644 --- a/docs/REGRESSION_AUDIT.md +++ b/docs/REGRESSION_AUDIT.md @@ -25,7 +25,11 @@ covered by a focused regression rather than a snapshot of the implementation. blocks; split position work runs only when needed. Source-only edits defer hidden DOM rebuilding, and disk fingerprints/diffs run away from the main actor. Export and visible-editor flushes synchronize pending rendering before - using the WebView. + using the WebView. Follow-up native workspace profiling found expensive + statistics on AppKit-backed strings and full source-pane updates during rich + editing. Normalize document storage to contiguous UTF-8, defer hidden source + work, and patch only changed source characters and their highlighting when + visible. See [the reproducible native measurements](EDITING_PERFORMANCE.md). - [PR #25](https://github.com/ashtree74/PreviewMD/pull/25) identifies the reading width transition race. Observe actual width changes and coalesce relayout on animation frames, including the final transition width. @@ -33,8 +37,11 @@ covered by a focused regression rather than a snapshot of the implementation. gutter overflow. Account for that gutter in the wide table sizer's width. The report and CSS correction are credited to **jedrzejsieracki**. - [#27](https://github.com/ashtree74/PreviewMD/issues/27) proposes a different - expanded-table width policy. It remains a product decision; these fixes - preserve the existing expansion policy. + expanded-table width policy. Expanded tables now use available surface width + up to the 220 px column target while preserving the 144 px collapsed minimum. + Width sweeps cover near-fit, roomy and narrow surfaces, and the action sits + above wide tables so it cannot obscure their headers. The proposal is credited + to **jedrzejsieracki**. - [#22](https://github.com/ashtree74/PreviewMD/issues/22) and [PR #28](https://github.com/ashtree74/PreviewMD/pull/28) concern the Windows port and are independent of the macOS regressions. diff --git a/docs/performance/issue-24-after.json b/docs/performance/issue-24-after.json new file mode 100644 index 0000000..db16736 --- /dev/null +++ b/docs/performance/issue-24-after.json @@ -0,0 +1,122 @@ +{ + "baseRevision": "9b0cae993bfac779689ac9904bbe7727b580007d", + "lineMapUpdate": { + "maxMs": 0.4750490188598633, + "medianMs": 0.46002864837646484, + "p95Ms": 0.4750490188598633 + }, + "measurement": "Native insertion or WebKit insertText through document-state revision and the next native display pass; excludes physical screen presentation. WebKit insertion includes IPC. Inputs have a 60 ms pause for deferred work, excluded from timings.", + "phase": "table-fit-and-editing-latency patch", + "readingStatisticsUpdate": { + "maxMs": 5.253076553344727, + "medianMs": 5.026102066040039, + "p95Ms": 5.253076553344727 + }, + "results": [ + { + "documentCommit": { + "maxMs": 8.579015731811523, + "medianMs": 8.427977561950684, + "p95Ms": 8.579015731811523 + }, + "documentCommitAndNativeDisplay": { + "maxMs": 55.650949478149414, + "medianMs": 51.864027976989746, + "p95Ms": 55.650949478149414 + }, + "fullPreviewRenders": 0, + "insertion": { + "maxMs": 8.572936058044434, + "medianMs": 8.420944213867188, + "p95Ms": 8.572936058044434 + }, + "mode": "source", + "readingStatisticsFromNativeText": { + "maxMs": 7.217049598693848, + "medianMs": 6.713986396789551, + "p95Ms": 7.217049598693848 + }, + "samples": 16 + }, + { + "documentCommit": { + "maxMs": 60.7379674911499, + "medianMs": 50.27902126312256, + "p95Ms": 60.7379674911499 + }, + "documentCommitAndNativeDisplay": { + "maxMs": 105.87799549102783, + "medianMs": 52.62398719787598, + "p95Ms": 105.87799549102783 + }, + "fullPreviewRenders": 0, + "insertion": { + "maxMs": 53.256988525390625, + "medianMs": 1.6980171203613281, + "p95Ms": 53.256988525390625 + }, + "mode": "preview", + "readingStatisticsFromNativeText": {}, + "samples": 16 + }, + { + "documentCommit": { + "maxMs": 9.19198989868164, + "medianMs": 8.967041969299316, + "p95Ms": 9.19198989868164 + }, + "documentCommitAndNativeDisplay": { + "maxMs": 65.28699398040771, + "medianMs": 60.034990310668945, + "p95Ms": 65.28699398040771 + }, + "fullPreviewRenders": 16, + "insertion": { + "maxMs": 9.184002876281738, + "medianMs": 8.959054946899414, + "p95Ms": 9.184002876281738 + }, + "mode": "split-source", + "readingStatisticsFromNativeText": { + "maxMs": 7.133007049560547, + "medianMs": 6.892085075378418, + "p95Ms": 7.133007049560547 + }, + "samples": 16 + }, + { + "documentCommit": { + "maxMs": 66.65503978729248, + "medianMs": 65.25492668151855, + "p95Ms": 66.65503978729248 + }, + "documentCommitAndNativeDisplay": { + "maxMs": 85.96611022949219, + "medianMs": 67.34597682952881, + "p95Ms": 85.96611022949219 + }, + "fullPreviewRenders": 0, + "insertion": { + "maxMs": 9.494900703430176, + "medianMs": 1.9159317016601562, + "p95Ms": 9.494900703430176 + }, + "mode": "split-preview", + "readingStatisticsFromNativeText": {}, + "samples": 16 + } + ], + "sampleSHA256": "5dcf69360c09ff74635569eb1fd3551245c14c930be173deb064929f0cf51430", + "sampleUTF16Units": 126669, + "sampleUTF8Bytes": 126669, + "stateUpdateWithoutViews": { + "maxMs": 4.9610137939453125, + "medianMs": 4.822969436645508, + "p95Ms": 4.9610137939453125 + }, + "system": "Version 27.0 (Build 26A428)", + "window": { + "height": 760, + "width": 1200 + } +} diff --git a/docs/performance/issue-24-before.json b/docs/performance/issue-24-before.json new file mode 100644 index 0000000..8282d77 --- /dev/null +++ b/docs/performance/issue-24-before.json @@ -0,0 +1,122 @@ +{ + "baseRevision": "9b0cae993bfac779689ac9904bbe7727b580007d", + "lineMapUpdate": { + "maxMs": 0.4800558090209961, + "medianMs": 0.4620552062988281, + "p95Ms": 0.4800558090209961 + }, + "measurement": "Native insertion or WebKit insertText through document-state revision and the next native display pass; excludes physical screen presentation. WebKit insertion includes IPC. Inputs have a 60 ms pause for deferred work, excluded from timings.", + "phase": "main", + "readingStatisticsUpdate": { + "maxMs": 5.2269697189331055, + "medianMs": 4.8400163650512695, + "p95Ms": 5.2269697189331055 + }, + "results": [ + { + "documentCommit": { + "maxMs": 28.130054473876953, + "medianMs": 26.88002586364746, + "p95Ms": 28.130054473876953 + }, + "documentCommitAndNativeDisplay": { + "maxMs": 81.51602745056152, + "medianMs": 65.67597389221191, + "p95Ms": 81.51602745056152 + }, + "fullPreviewRenders": 0, + "insertion": { + "maxMs": 28.120994567871094, + "medianMs": 26.874065399169922, + "p95Ms": 28.120994567871094 + }, + "mode": "source", + "readingStatisticsFromNativeText": { + "maxMs": 25.655031204223633, + "medianMs": 25.117993354797363, + "p95Ms": 25.655031204223633 + }, + "samples": 16 + }, + { + "documentCommit": { + "maxMs": 76.15196704864502, + "medianMs": 74.11801815032959, + "p95Ms": 76.15196704864502 + }, + "documentCommitAndNativeDisplay": { + "maxMs": 90.04902839660645, + "medianMs": 75.30200481414795, + "p95Ms": 90.04902839660645 + }, + "fullPreviewRenders": 0, + "insertion": { + "maxMs": 8.566975593566895, + "medianMs": 1.7139911651611328, + "p95Ms": 8.566975593566895 + }, + "mode": "preview", + "readingStatisticsFromNativeText": {}, + "samples": 16 + }, + { + "documentCommit": { + "maxMs": 28.506040573120117, + "medianMs": 27.438998222351074, + "p95Ms": 28.506040573120117 + }, + "documentCommitAndNativeDisplay": { + "maxMs": 79.18298244476318, + "medianMs": 74.5999813079834, + "p95Ms": 79.18298244476318 + }, + "fullPreviewRenders": 16, + "insertion": { + "maxMs": 28.501033782958984, + "medianMs": 27.433037757873535, + "p95Ms": 28.501033782958984 + }, + "mode": "split-source", + "readingStatisticsFromNativeText": { + "maxMs": 25.5889892578125, + "medianMs": 24.873018264770508, + "p95Ms": 25.5889892578125 + }, + "samples": 16 + }, + { + "documentCommit": { + "maxMs": 75.09100437164307, + "medianMs": 74.12803173065186, + "p95Ms": 75.09100437164307 + }, + "documentCommitAndNativeDisplay": { + "maxMs": 88.98603916168213, + "medianMs": 75.83105564117432, + "p95Ms": 88.98603916168213 + }, + "fullPreviewRenders": 0, + "insertion": { + "maxMs": 9.679913520812988, + "medianMs": 1.7130374908447266, + "p95Ms": 9.679913520812988 + }, + "mode": "split-preview", + "readingStatisticsFromNativeText": {}, + "samples": 16 + } + ], + "sampleSHA256": "5dcf69360c09ff74635569eb1fd3551245c14c930be173deb064929f0cf51430", + "sampleUTF16Units": 126669, + "sampleUTF8Bytes": 126669, + "stateUpdateWithoutViews": { + "maxMs": 4.896044731140137, + "medianMs": 4.719018936157227, + "p95Ms": 4.896044731140137 + }, + "system": "Version 27.0 (Build 26A428)", + "window": { + "height": 760, + "width": 1200 + } +} From 1331687666c63e9e68ddc13fcdac690066287dc0 Mon Sep 17 00:00:00 2001 From: Adam Jesionkiewicz Date: Tue, 6 Oct 2026 21:56:40 +0200 Subject: [PATCH 2/4] fix: account for classic scrollbar space in table layout Signed-off-by: Adam Jesionkiewicz --- .../PreviewMD/Resources/Renderer/renderer.js | 8 ++++++-- Tests/PreviewMDTests/RendererAuditTests.swift | 19 ++++++++++++++----- docs/EDITING_PERFORMANCE.md | 9 +++++++-- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/Sources/PreviewMD/Resources/Renderer/renderer.js b/Sources/PreviewMD/Resources/Renderer/renderer.js index f87dcc9..5b1d90b 100644 --- a/Sources/PreviewMD/Resources/Renderer/renderer.js +++ b/Sources/PreviewMD/Resources/Renderer/renderer.js @@ -550,11 +550,15 @@ const readableColumnWidth = Math.max(144, computedCellWidth || 0); const expanded = wrapper.classList.contains("is-expanded"); const contentWidth = articleContentWidth(); + const viewport = wrapper.querySelector(".table-viewport"); + // Classic scrollbars reserve inline space even without vertical overflow + // because the viewport uses scrollbar-gutter: stable. Overlay bars do not. + const scrollbarGutter = viewport ? Math.max(0, viewport.offsetWidth - viewport.clientWidth) : 0; const baseTableWidth = columnCount * readableColumnWidth; - const shouldUseWideSurface = expanded || baseTableWidth > contentWidth + 1; + const shouldUseWideSurface = expanded || baseTableWidth > contentWidth - scrollbarGutter + 1; const surface = shouldUseWideSurface ? wideTableSurface() : null; const surfaceWidth = surface ? Math.max(contentWidth, surface.width) : contentWidth; - const availableWidth = surface ? Math.max(0, surfaceWidth - surface.leadingGutter) : contentWidth; + const availableWidth = surface ? Math.max(0, surfaceWidth - surface.leadingGutter - scrollbarGutter) : contentWidth - scrollbarGutter; // Use the free surface before introducing scroll, but keep the collapsed // minimum so narrow windows never squeeze columns below their readable size. const tableWidth = expanded diff --git a/Tests/PreviewMDTests/RendererAuditTests.swift b/Tests/PreviewMDTests/RendererAuditTests.swift index 5436198..881c930 100644 --- a/Tests/PreviewMDTests/RendererAuditTests.swift +++ b/Tests/PreviewMDTests/RendererAuditTests.swift @@ -226,7 +226,8 @@ final class RendererAuditTests: XCTestCase, WKNavigationDelegate { var expanded = false for (windowWidth, readingWidth, expected) in [ (980, 902, "near-fit"), (1800, 560, "roomy"), - (520, 480, "narrow"), (980, 902, "near-fit") + (520, 480, "narrow"), (980, 902, "near-fit"), + (980, 902, "classic") ] { window.setContentSize(NSSize(width: CGFloat(windowWidth), height: 700)) webView.setFrameSize(NSSize(width: CGFloat(windowWidth), height: 700)) @@ -237,6 +238,11 @@ final class RendererAuditTests: XCTestCase, WKNavigationDelegate { let result = try await webView.callAsyncJavaScript( """ window.previewmdSetLayout(readingWidth, false, 0, false); + if (forceScrollbarGutter) { + // Reserve the same inline space as a classic scrollbar + // without changing the Mac's global scrollbar preference. + document.querySelector('.table-viewport').style.borderRight = '17px solid transparent'; + } await new Promise(resolve => setTimeout(resolve, 300)); // Headless WebKit can suspend animation frames. Finish the // width transition and use the editor's public layout hook. @@ -248,23 +254,25 @@ final class RendererAuditTests: XCTestCase, WKNavigationDelegate { const viewport = wrapper.querySelector('.table-viewport'); const sizer = wrapper.querySelector('.table-sizer'); const gutter = parseFloat(wrapper.style.getPropertyValue('--table-leading-gutter')); + const scrollbarGutter = viewport.offsetWidth - viewport.clientWidth; const shell = document.getElementById('preview-shell'); const shellStyle = getComputedStyle(shell); const expectedArticleWidth = Math.min(readingWidth, shell.clientWidth - parseFloat(shellStyle.paddingLeft) - parseFloat(shellStyle.paddingRight)); return { windowWidth: window.innerWidth, articleWidth: article.getBoundingClientRect().width, expectedArticleWidth, - available: wrapper.getBoundingClientRect().width - gutter, + available: wrapper.getBoundingClientRect().width - gutter - scrollbarGutter, + scrollbarGutter, minimum: parseFloat(sizer.style.minWidth), smallestColumn: Math.min(...Array.from(wrapper.querySelectorAll('th')).map(cell => cell.getBoundingClientRect().width)), buttonBottom: wrapper.querySelector('.table-expand').getBoundingClientRect().bottom, headerTop: wrapper.querySelector('th').getBoundingClientRect().top, client: viewport.clientWidth, scroll: viewport.scrollWidth, right: sizer.getBoundingClientRect().right, - viewportRight: viewport.getBoundingClientRect().right, + viewportRight: viewport.getBoundingClientRect().right - scrollbarGutter, expanded: wrapper.querySelector('.table-expand').getAttribute('aria-expanded') }; - """, arguments: ["readingWidth": readingWidth, "expanded": expanded], contentWorld: .page + """, arguments: ["readingWidth": readingWidth, "expanded": expanded, "forceScrollbarGutter": expected == "classic"], contentWorld: .page ) expanded = true let metrics = try XCTUnwrap(result as? [String: Any]) @@ -281,7 +289,8 @@ final class RendererAuditTests: XCTestCase, WKNavigationDelegate { XCTAssertLessThanOrEqual(metrics["buttonBottom"] as? Double ?? .infinity, metrics["headerTop"] as? Double ?? -.infinity, "The expansion action must not cover headers at any width") switch expected { - case "near-fit": + case "near-fit", "classic": + if expected == "classic" { XCTAssertGreaterThan(metrics["scrollbarGutter"] as? Int ?? 0, 0) } XCTAssertGreaterThan(available, 576) XCTAssertLessThan(available, 880) XCTAssertEqual(client, scroll, "Near-fit table must fit: \(metrics)") diff --git a/docs/EDITING_PERFORMANCE.md b/docs/EDITING_PERFORMANCE.md index 5ee2cc3..186f2d7 100644 --- a/docs/EDITING_PERFORMANCE.md +++ b/docs/EDITING_PERFORMANCE.md @@ -88,7 +88,10 @@ Without both environment variables, the profiling test explicitly skips. The width policy follows jedrzejsieracki's #27 proposal: clamp the expanded minimum to available width, never below the collapsed readable minimum. Roomy surfaces retain the 220 px per-column target; narrow surfaces still scroll. -The existing leading-gutter correction remains intact. The expansion action +The existing leading-gutter correction remains intact. Available width also +excludes space reserved by classic scrollbars; otherwise their stable gutter +creates a 17 px overflow that is absent with overlay scrollbars. The expansion +action sits above wide tables, away from their headers, with keyboard focus styling preserved. @@ -96,7 +99,9 @@ preserved. sweeps 980/1800/520/980 pt windows and 902/560/480/902 pt reading widths using the showcase's four-column table. It checks the final article width, minimum and actual column widths, scroll extent, right edge, action/header separation -and collapse. The six-column regression retains independent expansion and +and collapse, plus a simulated classic-scrollbar inset without changing system +preferences. CI also exercises real classic scrollbars. The six-column +regression retains independent expansion and unchanged Markdown serialization. For deterministic policy checks, the test finishes CSS width transitions and From 0d07789f18ebeb2b29d7f8d30cacd0705937d030 Mon Sep 17 00:00:00 2001 From: Adam Jesionkiewicz Date: Tue, 6 Oct 2026 22:21:36 +0200 Subject: [PATCH 3/4] fix: preserve table content and source colors after edits Signed-off-by: Adam Jesionkiewicz --- Sources/PreviewMD/MarkdownSourceEditor.swift | 9 +- .../PreviewMD/Resources/Renderer/renderer.css | 5 +- Tests/PreviewMDTests/RendererAuditTests.swift | 88 +++++++++++++++ .../SourceHighlightingRegressionTests.swift | 43 ++++++++ docs/EDITING_PERFORMANCE.md | 42 +++++--- docs/performance/issue-24-after.json | 102 +++++++++--------- 6 files changed, 223 insertions(+), 66 deletions(-) diff --git a/Sources/PreviewMD/MarkdownSourceEditor.swift b/Sources/PreviewMD/MarkdownSourceEditor.swift index 1d798fc..bf6252d 100644 --- a/Sources/PreviewMD/MarkdownSourceEditor.swift +++ b/Sources/PreviewMD/MarkdownSourceEditor.swift @@ -747,7 +747,14 @@ enum MarkdownSourceHighlighting { let old = previous as NSString let new = current as NSString let oldLines = old.lineRange(for: ranges.previous) - let newLines = new.lineRange(for: ranges.current) + // A line split can move an unchanged suffix beyond the inserted range. + // Its old syntax attributes still need resetting in the new context. + let movedLineEnd = NSMaxRange(oldLines) + new.length - old.length + let affectedEnd = max(NSMaxRange(ranges.current), movedLineEnd) + let newLines = new.lineRange(for: NSRange( + location: oldLines.location, + length: affectedEnd - oldLines.location + )) let oldChangedLines = old.substring(with: oldLines) let newChangedLines = new.substring(with: newLines) if oldChangedLines.contains("```") || oldChangedLines.contains("~~~") diff --git a/Sources/PreviewMD/Resources/Renderer/renderer.css b/Sources/PreviewMD/Resources/Renderer/renderer.css index c401ab6..f2527d8 100644 --- a/Sources/PreviewMD/Resources/Renderer/renderer.css +++ b/Sources/PreviewMD/Resources/Renderer/renderer.css @@ -623,7 +623,7 @@ code { .table-scroll.is-wide { max-width: none; - margin-top: calc(1.55em + 8px); + margin-top: max(36px, calc(1.55em + 8px)); } .table-scroll.is-wide .table-sizer { @@ -653,6 +653,9 @@ code { } .table-sizer { + /* Let intrinsic table content grow the rounded surface instead of being + clipped by a width chosen for shorter headers. The viewport owns scroll. */ + display: table; width: 100%; overflow: hidden; border: 1px solid var(--line); diff --git a/Tests/PreviewMDTests/RendererAuditTests.swift b/Tests/PreviewMDTests/RendererAuditTests.swift index 881c930..7399cdb 100644 --- a/Tests/PreviewMDTests/RendererAuditTests.swift +++ b/Tests/PreviewMDTests/RendererAuditTests.swift @@ -332,6 +332,94 @@ final class RendererAuditTests: XCTestCase, WKNavigationDelegate { XCTAssertEqual(metrics["client"] as? Int, metrics["scroll"] as? Int) } + func testLongTableHeadersRemainReachableWhenTheSurfaceCannotFitTheirIntrinsicWidth() async throws { + let webView = try await makeEditor(""" + | WWWWWWWWWWWWWWWW | WWWWWWWWWWWWWWWW | WWWWWWWWWWWWWWWW | WWWWWWWWWWWWWWWW | + | - | - | - | - | + | one | two | three | four | + """) + for (windowWidth, classic) in [(980, false), (1800, false), (520, false), (980, true)] { + webView.setFrameSize(NSSize(width: CGFloat(windowWidth), height: 700)) + let result = try await webView.callAsyncJavaScript( + """ + window.previewmdSetLayout(902, false, 0, false); + const wrapper = document.querySelector('.table-scroll'); + const viewport = wrapper.querySelector('.table-viewport'); + viewport.style.borderRight = classic ? '17px solid transparent' : ''; + await new Promise(resolve => setTimeout(resolve, 300)); + document.getElementById('preview-document').getAnimations().forEach(animation => animation.finish()); + window.previewmdRefreshTableLayout(); + const originalMarkdown = window.previewmdFlushEditor(); + const states = []; + for (const expanded of [false, true, false]) { + const button = wrapper.querySelector('.table-expand'); + if ((button.getAttribute('aria-expanded') === 'true') !== expanded) button.click(); + viewport.scrollLeft = 100000; + const table = wrapper.querySelector('table'); + states.push({ + expanded, client: viewport.clientWidth, scroll: viewport.scrollWidth, + scrollLeft: viewport.scrollLeft, + tableRight: table.getBoundingClientRect().right, + sizerRight: wrapper.querySelector('.table-sizer').getBoundingClientRect().right, + viewportRight: viewport.getBoundingClientRect().right - (viewport.offsetWidth - viewport.clientWidth), + whitespace: getComputedStyle(table.querySelector('th')).whiteSpace + }); + } + return { states, unchanged: originalMarkdown === window.previewmdFlushEditor() }; + """, arguments: ["classic": classic], contentWorld: .page + ) + let output = try XCTUnwrap(result as? [String: Any]) + XCTAssertEqual(output["unchanged"] as? Bool, true) + for metrics in try XCTUnwrap(output["states"] as? [[String: Any]]) { + let tableRight = try XCTUnwrap(metrics["tableRight"] as? Double) + XCTAssertLessThanOrEqual(tableRight, try XCTUnwrap(metrics["sizerRight"] as? Double) + 1, + "The rounded sizer must contain every column: \(metrics)") + XCTAssertLessThanOrEqual(tableRight, try XCTUnwrap(metrics["viewportRight"] as? Double) + 1, + "Scrolling to the end must expose the last column: \(metrics)") + XCTAssertEqual(metrics["whitespace"] as? String, "nowrap") + let client = try XCTUnwrap(metrics["client"] as? Int) + let scroll = try XCTUnwrap(metrics["scroll"] as? Int) + if windowWidth == 1800, metrics["expanded"] as? Bool == true { + XCTAssertEqual(client, scroll) + } else if windowWidth != 1800 { + XCTAssertGreaterThan(scroll, client, "Intrinsic content that cannot fit must remain scrollable") + XCTAssertGreaterThan(metrics["scrollLeft"] as? Double ?? 0, 0) + } + } + } + } + + func testWideTableActionsStayClearOfThePreviousTableAtMinimumCustomTextSize() async throws { + let table = "| Area | Status | Owner | Progress |\n| - | - | - | - |\n| App | Ready | Design | Done |" + let markdown = table + "\n\n" + table + let webView = try await makeEditor(markdown) + webView.setFrameSize(NSSize(width: 980, height: 700)) + let result = try await webView.callAsyncJavaScript( + """ + await window.previewmdRender({ markdown, theme: 'light', readingStyle: 'custom', + customReadingPreset: {bodySize:13, lineHeight:1.25, bodyFont:'system', headingFont:'serif'}, + readingWidth:902, paperCanvas:false, topInset:0 }); + await new Promise(resolve => setTimeout(resolve, 300)); + document.getElementById('preview-document').getAnimations().forEach(animation => animation.finish()); + window.previewmdRefreshTableLayout(); + const wrappers = Array.from(document.querySelectorAll('.table-scroll')); + wrappers.forEach(wrapper => wrapper.querySelector('.table-expand').click()); + return { + count:wrappers.length, + fontSize:parseFloat(getComputedStyle(document.getElementById('preview-document')).fontSize), + previousBottom:wrappers[0].getBoundingClientRect().bottom, + buttonTop:wrappers[1].querySelector('.table-expand').getBoundingClientRect().top + }; + """, arguments: ["markdown": markdown], contentWorld: .page + ) + let metrics = try XCTUnwrap(result as? [String: Any]) + XCTAssertEqual(metrics["count"] as? Int, 2) + XCTAssertEqual(metrics["fontSize"] as? Double, 13) + XCTAssertGreaterThanOrEqual(try XCTUnwrap(metrics["buttonTop"] as? Double), + try XCTUnwrap(metrics["previousBottom"] as? Double) + 3.5, + "An expansion action needs clear space above its table at the smallest text size") + } + func testHiddenSourceIsRenderedBeforePDFPreparationAndRestoredAfterward() async throws { let webView = try await makeEditor("Old source") let coordinator = outputCoordinator(in: webView, visible: false) diff --git a/Tests/PreviewMDTests/SourceHighlightingRegressionTests.swift b/Tests/PreviewMDTests/SourceHighlightingRegressionTests.swift index 2cefb1a..95a6e60 100644 --- a/Tests/PreviewMDTests/SourceHighlightingRegressionTests.swift +++ b/Tests/PreviewMDTests/SourceHighlightingRegressionTests.swift @@ -54,6 +54,49 @@ final class SourceHighlightingRegressionTests: XCTestCase { return view.subviews.lazy.compactMap { self.findTextView(in: $0) }.first } + func testRichEchoRecolorsTheMovedSuffixWhenSplittingLogicalLines() async throws { + _ = NSApplication.shared + for (previous, current) in [ + ("# Heading\n\nTail", "# Head\n\ning\n\nTail"), + ("# Heading\r\n\r\nTail", "# Head\r\n\r\ning\r\n\r\nTail"), + ("1. item\n\nTail", "1\n\n. item\n\nTail"), + ("- [ ] task\n\nTail", "- [\n\n] task\n\nTail"), + ("[label](target)\n\nTail", "[la\n\nbel](target)\n\nTail") + ] { + let host = NSHostingView(rootView: MarkdownSourceEditor(text: .constant(previous))) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + host.layoutSubtreeIfNeeded() + defer { window.close() } + let textView = try XCTUnwrap(findTextView(in: host)) + host.rootView = MarkdownSourceEditor(text: .constant(current)) + host.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(50)) + XCTAssertEqual(textView.string, current) + + let expected = NSTextView() + expected.appearance = textView.effectiveAppearance + expected.string = current + let coordinator = MarkdownSourceEditor.Coordinator( + text: .constant(current), documentID: nil, + splitSynchronizer: nil, isSplitSynchronizationEnabled: false + ) + coordinator.textView = expected + coordinator.highlight(forceFull: true) + let actualStorage = try XCTUnwrap(textView.textStorage) + let expectedStorage = try XCTUnwrap(expected.textStorage) + for offset in 0.. Date: Tue, 6 Oct 2026 22:27:11 +0200 Subject: [PATCH 4/4] test: await source scroll publication reliably Signed-off-by: Adam Jesionkiewicz --- .../PreviewMDTests/MarkdownSourceEditorTests.swift | 13 +++++++++++-- docs/EDITING_PERFORMANCE.md | 5 +++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Tests/PreviewMDTests/MarkdownSourceEditorTests.swift b/Tests/PreviewMDTests/MarkdownSourceEditorTests.swift index 4903c32..e4d8390 100644 --- a/Tests/PreviewMDTests/MarkdownSourceEditorTests.swift +++ b/Tests/PreviewMDTests/MarkdownSourceEditorTests.swift @@ -260,7 +260,7 @@ final class MarkdownSourceEditorTests: XCTestCase { } @MainActor - func testSourceScrollPublicationUsesLogicalLineWhenLinesWrap() throws { + func testSourceScrollPublicationUsesLogicalLineWhenLinesWrap() async throws { _ = NSApplication.shared let source = (0..<80) .map { "Line \($0) " + String(repeating: "wrapped content ", count: 5) } @@ -282,7 +282,9 @@ final class MarkdownSourceEditorTests: XCTestCase { backing: .buffered, defer: false ) + window.isReleasedWhenClosed = false window.contentView = host + defer { window.close() } host.frame = window.contentView!.bounds host.layoutSubtreeIfNeeded() synchronizer.attachPreview(preview, documentID: documentID) @@ -293,6 +295,13 @@ final class MarkdownSourceEditorTests: XCTestCase { let textContainer = try XCTUnwrap(textView.textContainer) layoutManager.ensureLayout(for: textContainer) let targetLine = 30 + let published = expectation(description: "Source scroll position reaches the preview") + preview.onScrollPosition = { [weak preview] position in + if abs(position.sourceLine - Double(targetLine)) <= 1 { + preview?.onScrollPosition = nil + published.fulfill() + } + } let targetRange = (source as NSString).range(of: "Line \(targetLine) ") let glyphIndex = layoutManager.glyphIndexForCharacter( at: targetRange.location @@ -309,7 +318,7 @@ final class MarkdownSourceEditorTests: XCTestCase { ) ) scrollView.reflectScrolledClipView(scrollView.contentView) - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + await fulfillment(of: [published], timeout: 2) XCTAssertEqual( preview.scrollPositions.last?.sourceLine ?? .nan, diff --git a/docs/EDITING_PERFORMANCE.md b/docs/EDITING_PERFORMANCE.md index 751d4bb..697d916 100644 --- a/docs/EDITING_PERFORMANCE.md +++ b/docs/EDITING_PERFORMANCE.md @@ -129,6 +129,11 @@ then passed after the fixes. Both reviewers repeated their review of the resulting patch and found no unresolved findings in scope. This is independent agent review, not a GitHub approval submission. +CI also exposed an existing scroll-publication test that waited a fixed 50 ms +instead of observing completion. It now keeps its native window alive, awaits +the actual callback for the expected logical line and retains the same line +accuracy assertion. Missing or incorrect publication still fails the test. + The full local suite passed: 177 XCTest and 5 Swift Testing tests, with the opt-in profiler explicitly skipped in the normal run. The profiler passed separately on baseline and patch, and the after report was refreshed after