diff --git a/Sources/PreviewMD/MarkdownSourceEditor.swift b/Sources/PreviewMD/MarkdownSourceEditor.swift index 3639354..bf6252d 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,38 @@ 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) + // 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/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..f2527d8 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: max(36px, 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; } @@ -649,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/Sources/PreviewMD/Resources/Renderer/renderer.js b/Sources/PreviewMD/Resources/Renderer/renderer.js index d5ff1fa..5b1d90b 100644 --- a/Sources/PreviewMD/Resources/Renderer/renderer.js +++ b/Sources/PreviewMD/Resources/Renderer/renderer.js @@ -550,16 +550,25 @@ 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 tableWidth = columnCount * - (expanded ? Math.max(220, readableColumnWidth) : 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 - 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 + ? 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..e4d8390 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]) @@ -211,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) } @@ -233,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) @@ -244,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 @@ -260,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/Tests/PreviewMDTests/RendererAuditTests.swift b/Tests/PreviewMDTests/RendererAuditTests.swift index ca88e6a..7399cdb 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,223 @@ 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"), + (980, 902, "classic") + ] { + 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); + 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. + 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 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 - 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 - scrollbarGutter, + expanded: wrapper.querySelector('.table-expand').getAttribute('aria-expanded') + }; + """, arguments: ["readingWidth": readingWidth, "expanded": expanded, "forceScrollbarGutter": expected == "classic"], 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", "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)") + 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 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 09c9874..95a6e60 100644 --- a/Tests/PreviewMDTests/SourceHighlightingRegressionTests.swift +++ b/Tests/PreviewMDTests/SourceHighlightingRegressionTests.swift @@ -6,6 +6,97 @@ 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 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..