From 3fccfd3830f0e49b8755d9ddc39d58db53d1fc1f Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Thu, 22 Oct 2026 13:30:22 +0200 Subject: [PATCH 01/10] wip(tables): TS-T1 table as one NSTextView (NSTextTable), exact row height TableTextBuilder builds one attributed string per table (one paragraph per cell, reading order, NSTextTable blocks); TableTextView (a SelfSizingTextView) draws it; BlockHeightMeasurer measures the same string, so table rows are .exact. TableBlockView removed (print keeps PrintableTableView). Search keeps 1.12 behaviour through a bridge until TS-T3. Not shippable alone: tables are still atomic in the selection until TS-T2. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BhzVb6aPQs2FF1bHdkcYys --- QuickMD/QuickMD.xcodeproj/project.pbxproj | 18 +- QuickMD/QuickMD/BlockHeightMeasurer.swift | 89 ++--- QuickMD/QuickMD/MarkdownExport.swift | 28 ++ QuickMD/QuickMD/MarkdownRenderer.swift | 2 +- QuickMD/QuickMD/MarkdownView.swift | 37 +- QuickMD/QuickMD/TableTextBuilder.swift | 334 ++++++++++++++++++ QuickMD/QuickMD/Views/CodeBlockView.swift | 23 +- QuickMD/QuickMD/Views/TableBlockView.swift | 189 ---------- QuickMD/QuickMD/Views/TableTextView.swift | 219 ++++++++++++ QuickMD/QuickMD/Views/TextBlockView.swift | 40 ++- QuickMD/QuickMD/Views/VirtualBlockList.swift | 2 +- .../BlockHeightMeasurerTests.swift | 12 +- QuickMD/QuickMDTests/TableTextTests.swift | 325 +++++++++++++++++ 13 files changed, 1035 insertions(+), 283 deletions(-) create mode 100644 QuickMD/QuickMD/TableTextBuilder.swift delete mode 100644 QuickMD/QuickMD/Views/TableBlockView.swift create mode 100644 QuickMD/QuickMD/Views/TableTextView.swift create mode 100644 QuickMD/QuickMDTests/TableTextTests.swift diff --git a/QuickMD/QuickMD.xcodeproj/project.pbxproj b/QuickMD/QuickMD.xcodeproj/project.pbxproj index 42f17c0..c66d318 100644 --- a/QuickMD/QuickMD.xcodeproj/project.pbxproj +++ b/QuickMD/QuickMD.xcodeproj/project.pbxproj @@ -24,8 +24,11 @@ A1B2C3D401000009 /* MarkdownExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000C /* MarkdownExport.swift */; }; A1B2C3D40100000A /* MarkdownBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000D /* MarkdownBlock.swift */; }; A1B2C3D40100000B /* MarkdownBlockParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000E /* MarkdownBlockParser.swift */; }; - A1B2C3D40100000C /* TableBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000F /* TableBlockView.swift */; }; A1B2C3D40100000D /* CodeBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000011 /* CodeBlockView.swift */; }; + A1B2C3D4010001B0 /* TableTextBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001B0 /* TableTextBuilder.swift */; }; + A1B2C3D4010001B1 /* TableTextBuilder.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001B0 /* TableTextBuilder.swift */; }; + A1B2C3D4010001B2 /* TableTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001B1 /* TableTextView.swift */; }; + A1B2C3D4010001B3 /* TableTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001B2 /* TableTextTests.swift */; }; A1B2C3D40100000E /* ImageBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000012 /* ImageBlockView.swift */; }; A1B2C3D40100000F /* BlockquoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000013 /* BlockquoteView.swift */; }; A1B2C3D401000010 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000014 /* SearchBar.swift */; }; @@ -202,9 +205,11 @@ A1B2C3D40000000C /* MarkdownExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownExport.swift; sourceTree = ""; }; A1B2C3D40000000D /* MarkdownBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownBlock.swift; sourceTree = ""; }; A1B2C3D40000000E /* MarkdownBlockParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownBlockParser.swift; sourceTree = ""; }; - A1B2C3D40000000F /* TableBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableBlockView.swift; sourceTree = ""; }; A1B2C3D400000010 /* QuickMD.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QuickMD.app; sourceTree = BUILT_PRODUCTS_DIR; }; A1B2C3D400000011 /* CodeBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeBlockView.swift; sourceTree = ""; }; + A1B2C3D4000001B0 /* TableTextBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableTextBuilder.swift; sourceTree = ""; }; + A1B2C3D4000001B1 /* TableTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableTextView.swift; sourceTree = ""; }; + A1B2C3D4000001B2 /* TableTextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableTextTests.swift; sourceTree = ""; }; A1B2C3D400000012 /* ImageBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageBlockView.swift; sourceTree = ""; }; A1B2C3D400000013 /* BlockquoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockquoteView.swift; sourceTree = ""; }; A1B2C3D400000014 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; @@ -313,6 +318,7 @@ A1B2C3D400000082 /* ImageLoadingTests.swift */, A1B2C3D4000000E1 /* HTMLImageTests.swift */, A1B2C3D4000000F0 /* HeadingLayoutTests.swift */, + A1B2C3D4000001B2 /* TableTextTests.swift */, A1B2C3D4000000C1 /* DocumentSelectionTests.swift */, A1B2C3D400000111 /* ExternalEditorLineTests.swift */, A1B2C3D400000122 /* DocumentFileFormatTests.swift */, @@ -339,6 +345,7 @@ A1B2C3D40000000D /* MarkdownBlock.swift */, A1B2C3D40000000E /* MarkdownBlockParser.swift */, A1B2C3D400000069 /* BlockHeightMeasurer.swift */, + A1B2C3D4000001B0 /* TableTextBuilder.swift */, A1B2C3D40000006E /* SVGImageDecoder.swift */, A1B2C3D400000080 /* DataImageURI.swift */, A1B2C3D400000081 /* ImageLoader.swift */, @@ -382,7 +389,7 @@ A1B2C3D403000004 /* Views */ = { isa = PBXGroup; children = ( - A1B2C3D40000000F /* TableBlockView.swift */, + A1B2C3D4000001B1 /* TableTextView.swift */, A1B2C3D400000011 /* CodeBlockView.swift */, A1B2C3D400000012 /* ImageBlockView.swift */, A1B2C3D400000013 /* BlockquoteView.swift */, @@ -567,7 +574,6 @@ A1B2C3D401000006 /* MarkdownTheme.swift in Sources */, A1B2C3D40100000A /* MarkdownBlock.swift in Sources */, A1B2C3D40100000B /* MarkdownBlockParser.swift in Sources */, - A1B2C3D40100000C /* TableBlockView.swift in Sources */, A1B2C3D40100000D /* CodeBlockView.swift in Sources */, A1B2C3D40100000E /* ImageBlockView.swift in Sources */, A1B2C3D401000007 /* TipJarManager.swift in Sources */, @@ -629,6 +635,8 @@ A1B2C3D401000062 /* DocumentFonts.swift in Sources */, A1B2C3D401000063 /* FontPickerView.swift in Sources */, A1B2C3D401000069 /* BlockHeightMeasurer.swift in Sources */, + A1B2C3D4010001B0 /* TableTextBuilder.swift in Sources */, + A1B2C3D4010001B2 /* TableTextView.swift in Sources */, A1B2C3D40100006C /* VirtualBlockList.swift in Sources */, A1B2C3D40100006D /* SVGBlockView.swift in Sources */, A1B2C3D40100006E /* SVGImageDecoder.swift in Sources */, @@ -677,6 +685,7 @@ A1B2C3D401000068 /* ChromeHoverState.swift in Sources (Tests) */, A1B2C3D40100006A /* BlockHeightMeasurerTests.swift in Sources */, A1B2C3D40100006B /* BlockHeightMeasurer.swift in Sources (Tests) */, + A1B2C3D4010001B1 /* TableTextBuilder.swift in Sources (Tests) */, A1B2C3D40100006F /* SVGImageDecoder.swift in Sources (Tests) */, A1B2C3D401000081 /* DataImageURI.swift in Sources (Tests) */, A1B2C3D401000083 /* ImageLoader.swift in Sources (Tests) */, @@ -705,6 +714,7 @@ A1B2C3D401000181 /* SourceEditLanding.swift in Sources (Tests) */, A1B2C3D401000182 /* SourceEditLandingTests.swift in Sources */, A1B2C3D4010000F0 /* HeadingLayoutTests.swift in Sources */, + A1B2C3D4010001B3 /* TableTextTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/QuickMD/QuickMD/BlockHeightMeasurer.swift b/QuickMD/QuickMD/BlockHeightMeasurer.swift index bcfffb4..ccebe51 100644 --- a/QuickMD/QuickMD/BlockHeightMeasurer.swift +++ b/QuickMD/QuickMD/BlockHeightMeasurer.swift @@ -276,17 +276,26 @@ enum BlockLayout { // MARK: Tables + /// Since v1.13 a table is one NSTextView over an `NSTextTable` built by + /// `TableTextBuilder`; the builder (cell paddings, rules, font) and + /// `TableTextView` (clip, outline) read these, and the row is `.exact`. enum Table { static let cellHorizontalPadding: CGFloat = 12 static let headerCellVerticalPadding: CGFloat = 8 static let cellVerticalPadding: CGFloat = 6 - /// Vertical rule between columns. + /// Vertical rule between columns (a cell's trailing border). static let dividerWidth: CGFloat = 1 - /// Horizontal rule under the header and between rows. + /// Horizontal rule under the header and between rows (a cell's bottom + /// border). static let separatorHeight: CGFloat = 1 - /// The rounded outline around the whole table. + /// The rounded outline around the whole table, centred on its edge. static let borderWidth: CGFloat = 1 - static let cellFontSize: CGFloat = 13 + /// Every cell, header included, at this size × zoom, regular weight: + /// the size `MarkdownRenderer.renderInline` stamps on each run, i.e. + /// what 1.12 actually drew (its 13 pt / semibold header modifiers lost + /// to the runs' own font). The builder gives each cell's terminating + /// "\n" this font too, so an empty cell is one line of it. + static let cellFontSize: CGFloat = 14 static let cornerRadius: CGFloat = 4 } @@ -577,6 +586,10 @@ struct MeasuredBlocks: @unchecked Sendable { /// Keyed by `MarkdownBlock.id`, for the blocks whose text renders through /// `TextBlockView` (paragraphs/footnotes, quote bodies, alert bodies). let converted: [String: NSAttributedString] + /// Keyed by `MarkdownBlock.id`, for every `.table` block: the string and + /// cell layout the row was measured with, which `TableTextView` installs + /// as is (same D12 hand-off as `converted`). + let tables: [String: TableText] /// Rows whose height could not be computed because `measure` was called /// without the math engine (`math: nil` — the off-main call, see the file /// header): paragraphs containing inline `$…$` and `.mathBlock` rows. Their @@ -586,7 +599,7 @@ struct MeasuredBlocks: @unchecked Sendable { /// got a real engine. let mathPendingRows: [Int] - static let empty = MeasuredBlocks(table: .empty, converted: [:], mathPendingRows: []) + static let empty = MeasuredBlocks(table: .empty, converted: [:], tables: [:], mathPendingRows: []) } // MARK: - Measurer @@ -594,10 +607,10 @@ struct MeasuredBlocks: @unchecked Sendable { /// Computes a whole document's `BlockHeightTable` off the main thread. /// /// Exact for the NSTextView-backed kinds (`.text`, `.blockquote`, `.alert`, -/// `.codeBlock`): the same string, the same width, the same TextKit 1 settings. -/// An estimate for the kinds whose size only exists once a view is placed -/// (`.heading`, `.table`, `.mathBlock`, `.image`, `.mermaidDiagram`) — those rows -/// are `.reported` and get corrected once by the placed view. +/// `.codeBlock`, and since v1.13 `.table`): the same string, the same width, the +/// same TextKit 1 settings. An estimate for the kinds whose size only exists once +/// a view is placed (`.heading`, `.mathBlock`, `.image`, `.mermaidDiagram`) — +/// those rows are `.reported` and get corrected once by the placed view. enum BlockHeightMeasurer { /// Height of `ns` laid out at `width` by a standalone TextKit 1 stack. @@ -682,6 +695,7 @@ enum BlockHeightMeasurer { var heights: [CGFloat] = [] var kinds: [RowKind] = [] var converted: [String: NSAttributedString] = [:] + var tables: [String: TableText] = [:] var mathPendingRows: [Int] = [] heights.reserveCapacity(blocks.count) kinds.reserveCapacity(blocks.count) @@ -752,10 +766,14 @@ enum BlockHeightMeasurer { kinds.append(.reported) case .table(let headers, let rows, let alignments): - heights.append(tableEstimate(headers: headers, rows: rows, alignments: alignments, - renderer: renderer, theme: theme, fontScale: fontScale, - contentWidth: contentWidth)) - kinds.append(.reported) + // The string `TableTextView` installs (it is handed this very + // instance), at the width its frame gets: the whole column. + let table = TableTextBuilder.build(headers: headers, rows: rows, alignments: alignments, + theme: theme, fontScale: fontScale) + tables[block.id] = table + heights.append(TableTextBuilder.height(of: table.string, width: textWidth) + + 2 * BlockLayout.Document.tableOuterVerticalPadding) + kinds.append(.exact) case .mathBlock(let latex): if let math { @@ -800,6 +818,7 @@ enum BlockHeightMeasurer { return MeasuredBlocks( table: BlockHeightTable(heights: heights, kinds: kinds, contentWidth: contentWidth), converted: converted, + tables: tables, mathPendingRows: mathPendingRows) } @@ -842,7 +861,7 @@ enum BlockHeightMeasurer { reusing: [String: NSAttributedString] = [:]) -> MeasuredBlocks { guard !pending.isEmpty else { return MeasuredBlocks(table: measured.table, converted: measured.converted, - mathPendingRows: []) + tables: measured.tables, mathPendingRows: []) } #if DEBUG @@ -894,6 +913,7 @@ enum BlockHeightMeasurer { table: BlockHeightTable(heights: heights, kinds: measured.table.kinds, contentWidth: measured.table.contentWidth), converted: converted, + tables: measured.tables, mathPendingRows: []) } @@ -917,45 +937,4 @@ enum BlockHeightMeasurer { return max(exactHeight(text: ns, width: textWidth), BlockLayout.Heading.copyButtonHeight) } - /// `.table` — cells are SwiftUI `Text`, laid out in equal-width columns. - /// TextKit per cell is affordable because tables are rare and small. - private static func tableEstimate(headers: [String], rows: [[String]], - alignments: [TextAlignment], - renderer: MarkdownRenderer, - theme: MarkdownTheme, fontScale: CGFloat, - contentWidth: CGFloat) -> CGFloat { - let columnCount = max(1, max(headers.count, alignments.count, rows.map(\.count).max() ?? 0)) - let showsHeader = headers.contains { !$0.trimmingCharacters(in: .whitespaces).isEmpty } - let dividers = CGFloat(columnCount - 1) * BlockLayout.Table.dividerWidth - let columnWidth = (contentWidth - dividers) / CGFloat(columnCount) - let cellWidth = BlockLayout.clampedWidth(columnWidth - 2 * BlockLayout.Table.cellHorizontalPadding) - // Floor: an empty cell still occupies one line in SwiftUI, where an - // empty text container reports no used height. - let minCellHeight = BlockLayout.singleLineHeight( - for: theme.fonts.appKit(size: BlockLayout.Table.cellFontSize * fontScale)) - - func bandHeight(_ cells: [String], padding: CGFloat) -> CGFloat { - var tallest = minCellHeight - for index in 0.. Alignment { + guard index < alignments.count else { return .leading } + switch alignments[index] { + case .leading: return .leading + case .center: return .center + case .trailing: return .trailing + @unknown default: return .leading + } + } + + /// Get TextAlignment for multilineTextAlignment modifier + func textAlignmentFor(_ index: Int) -> TextAlignment { + guard index < alignments.count else { return .leading } + return alignments[index] + } +} + // MARK: - Printable Table View /// Table view optimized for PDF export and printing diff --git a/QuickMD/QuickMD/MarkdownRenderer.swift b/QuickMD/QuickMD/MarkdownRenderer.swift index da614fc..8cf0262 100644 --- a/QuickMD/QuickMD/MarkdownRenderer.swift +++ b/QuickMD/QuickMD/MarkdownRenderer.swift @@ -591,7 +591,7 @@ struct MarkdownRenderer: Sendable { // MARK: - Inline Formatting (refactored into smaller methods) - /// Public method for rendering inline formatting (used by TableBlockView) + /// Public method for rendering inline formatting (table cells: `TableTextBuilder`, `PrintableTableView`) func renderInline(_ text: String) -> AttributedString { renderInlineFormatting(text) } diff --git a/QuickMD/QuickMD/MarkdownView.swift b/QuickMD/QuickMD/MarkdownView.swift index b30ef3a..9028da7 100644 --- a/QuickMD/QuickMD/MarkdownView.swift +++ b/QuickMD/QuickMD/MarkdownView.swift @@ -976,11 +976,12 @@ struct MarkdownView: View { /// /// `NSViewRepresentable` is an environment boundary: nothing applied to the /// SwiftUI tree AROUND `VirtualBlockList` reaches the views inside its cells. - /// The link action matters — table cells render their links as SwiftUI - /// `Text` with a Foundation `.link` attribute, which SwiftUI opens - /// through `openURL`; without this they would bypass - /// `handleLinkActivation` (relative paths unresolved, `.md` files opened by - /// whatever app claims them, no confirmation for exotic schemes). + /// The link action matters — any SwiftUI `Text` with a Foundation `.link` + /// attribute inside a cell is opened by SwiftUI through `openURL`; without + /// this it would bypass `handleLinkActivation` (relative paths unresolved, + /// `.md` files opened by whatever app claims them, no confirmation for + /// exotic schemes). The NSTextView-backed rows (text, headings, tables + /// since v1.13) open links through their delegate's `onLink` instead. private func hostedBlockView(for block: MarkdownBlock) -> some View { blockView(for: block) .environment(\.openURL, OpenURLAction { url in @@ -1028,8 +1029,12 @@ struct MarkdownView: View { ) case .table(let headers, let rows, let alignments): - TableBlockView(headers: headers, rows: rows, alignments: alignments, theme: theme, - fontScale: scale, searchText: searchText, focusedOccurrence: focusedOcc) + // One NSTextView over the measurer's own `TableText` (v1.13 + // TS-D1/TS-D3) — an `.exact` row like the text kinds. + TableTextBlockView(headers: headers, rows: rows, alignments: alignments, theme: theme, + fontScale: scale, searchText: searchText, focusedOccurrence: focusedOcc, + preconverted: measured.tables[block.id], + onLink: { handleLinkActivation($0) }) .padding(.vertical, Metrics.tableOuterVerticalPadding) case .codeBlock(let code, let language): @@ -1133,8 +1138,10 @@ struct MarkdownView: View { // MARK: - Selectable strings (v1.11 S-D2) /// The string `block`'s text view displays — the string the document - /// selection's offsets index into — or nil for atomic rows (tables, - /// images, math, diagrams), which are selected whole. + /// selection's offsets index into — or nil for rows without one (images, + /// math, diagrams), which are selected whole. A table's is its + /// `TableTextBuilder` string (v1.13 TS-D4): cells in reading order, one + /// paragraph each. /// /// Built WITHOUT the view, so ⌘A + ⌘C works for rows that were never /// materialized. Each case is the same construction the block's view uses @@ -1179,7 +1186,16 @@ struct MarkdownView: View { return TextBlockView.makeNSAttributedString( from: MarkdownRenderer(theme: theme, fontScale: scale).renderHeader(title, level: level), hasInlineMath: false, theme: theme, fontScale: scale) - case .table, .image, .svgImage, .mathBlock, .mermaidDiagram: + case .table(let headers, let rows, let alignments): + // The measurer's instance when it belongs to this parse; otherwise + // the same builder `TableTextBlockView` falls back to. + if installedVersion == contentVersion, let table = measured.tables[block.id], + table.matches(headers: headers, rows: rows, alignments: alignments, fontScale: scale) { + return table.string + } + return TableTextBuilder.build(headers: headers, rows: rows, alignments: alignments, + theme: theme, fontScale: scale).string + case .image, .svgImage, .mathBlock, .mermaidDiagram: return nil } } @@ -1274,6 +1290,7 @@ struct MarkdownView: View { table: BlockHeightTable(heights: heights, kinds: table.kinds, contentWidth: table.contentWidth), converted: measured.converted, + tables: measured.tables, mathPendingRows: measured.mathPendingRows) } diff --git a/QuickMD/QuickMD/TableTextBuilder.swift b/QuickMD/QuickMD/TableTextBuilder.swift new file mode 100644 index 0000000..ad66898 --- /dev/null +++ b/QuickMD/QuickMD/TableTextBuilder.swift @@ -0,0 +1,334 @@ +import AppKit +import SwiftUI + +// MARK: - Tables as text (v1.13 TS-D1…TS-D4) +// +// A `.table` row is ONE NSTextView whose string is a TextKit 1 `NSTextTable` +// (one `NSTextTableBlock` per cell), so the table is a single selectable string +// like every other text row (v1.11 S-D2) instead of a SwiftUI grid that could +// only be selected whole. +// +// This file is compiled into the app AND the test target: the measurer +// (`BlockHeightMeasurer`), the on-screen view (`Views/TableTextView.swift`) and +// `MarkdownView.selectableText` all build the string HERE, and the parity tests +// lay out the very same string. A second construction anywhere would be a +// height that disagrees with the drawn table (an `.exact` row that clips) or +// selection offsets that point at the wrong characters. + +// MARK: - String layout + +/// Where every cell sits inside the string `TableTextBuilder` produces. +/// +/// The string is the cells in reading order — the header band first (only when +/// the table shows one), then each data row, left to right — and every cell is +/// exactly ONE paragraph: +/// +/// \n\n … \n\n … +/// +/// - `cellRanges[band][column]` is the cell's rendered text, WITHOUT its +/// terminating "\n" (an empty cell is a zero-length range sitting right +/// before its "\n"). The "\n" at `NSMaxRange(range)` belongs to the cell: it +/// carries the cell's paragraph style (its `NSTextTableBlock`), and the last +/// cell's "\n" is the last character of the string. +/// - A band ("row" on screen) is the contiguous range from its first cell's +/// location to its last cell's "\n" inclusive (`bandRange(_:)`). +/// - Every band has `columnCount` cells: ragged rows are padded with empty +/// cells, so the column of a character is always well defined. +/// - Offsets are UTF-16 (NSString), the unit `DocumentSelection` uses. +struct TableTextLayout: Equatable { + let columnCount: Int + /// Band 0 is the header band when true. + let showsHeader: Bool + /// `[band][column]` — see the type comment. + let cellRanges: [[NSRange]] + + var bandCount: Int { cellRanges.count } + + /// UTF-16 length of the whole string (0 for a table with no bands). + var length: Int { + guard let last = cellRanges.last?.last else { return 0 } + return NSMaxRange(last) + 1 + } + + /// The whole band including the "\n" of its last cell. + func bandRange(_ band: Int) -> NSRange { + let first = cellRanges[band][0].location + return NSRange(location: first, length: NSMaxRange(cellRanges[band][columnCount - 1]) + 1 - first) + } + + /// The cell a character index falls in; a cell's own "\n" counts as that + /// cell. nil before the start or past the end. + /// + /// Bands are contiguous and ascending, so the band is a binary search — + /// the selection asks this per drag event, and a table can have hundreds + /// of rows. + func cell(containing index: Int) -> (band: Int, column: Int)? { + guard index >= 0, index < length else { return nil } + var low = 0, high = bandCount - 1 + while low < high { + let mid = (low + high) / 2 + if index <= NSMaxRange(cellRanges[mid][columnCount - 1]) { high = mid } else { low = mid + 1 } + } + for column in 0.. Bool { + source == Source(headers: headers, rows: rows, alignments: alignments, fontScale: fontScale) + } +} + +// MARK: - Builder + +/// Builds the attributed string of a `.table` block: one `NSTextTable`, one +/// `NSTextTableBlock` per cell, each cell's inline styling from +/// `MarkdownRenderer.renderInline` (AppKit scope). +/// +/// The look is the one 1.12's SwiftUI table DREW, measured, not what its +/// modifiers asked for (TS-D2). Metrics come from `BlockLayout.Table`: +/// - columns are equal (`fixedLayoutAlgorithm`, no explicit widths). TextKit +/// floors each column to whole points, so the table can be up to +/// `columnCount - 1` pt narrower than the container and a cell can wrap a +/// word earlier than 1.12 did. Accepted: the measurer lays out the same +/// string, so it is self-consistent, and the dividers land on whole points; +/// - a cell's padding is 1.12's `.padding`; its 1 pt rule is a BORDER on its +/// trailing edge (not the last column) and its bottom edge (not the last +/// data row — the header always has one, as 1.12's header separator), so +/// neighbouring cells never draw a rule twice; +/// - cells are centred vertically in their band (`.middleAlignment` — the +/// HStack's default alignment in 1.12), not top-aligned as text tables are +/// by default; +/// - header cells get `headerBackgroundColor`; the table's own background, the +/// rounded clip and the outline are the hosting view's job. +/// +/// Font: every run carries the renderer's explicit `cellFontSize`·scale font, +/// header included — that is what 1.12 drew (its `.font(13 [semibold])` +/// modifiers lost to the runs' own font). +/// +/// Pure: value types plus objects it creates, no actor isolation — safe off-main +/// (the measurer calls it from the parse task). +enum TableTextBuilder { + + typealias Metrics = BlockLayout.Table + + static func build(headers: [String], rows: [[String]], alignments: [TextAlignment], + theme: MarkdownTheme, fontScale: CGFloat) -> TableText { + let renderer = MarkdownRenderer(theme: theme, fontScale: fontScale) + // The separator row is authoritative (constraints.md "Tables"); ragged + // rows widen the table rather than lose cells. + let columnCount = max(1, max(headers.count, alignments.count, rows.map(\.count).max() ?? 0)) + let showsHeader = headers.contains { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + + var bands: [[String]] = [] + if showsHeader { bands.append(headers) } + bands.append(contentsOf: rows) + + let table = NSTextTable() + table.numberOfColumns = columnCount + table.layoutAlgorithm = .fixedLayoutAlgorithm + table.collapsesBorders = false + table.hidesEmptyCells = false + + let border = NSColor(theme.borderColor) + let headerBackground = NSColor(theme.headerBackgroundColor) + // The "\n" of a cell (and so an empty cell's only line) needs the cell + // font too, or its line takes the 12 pt default height. + let terminatorAttributes: [NSAttributedString.Key: Any] = [ + .font: theme.fonts.appKit(size: Metrics.cellFontSize * fontScale), + .foregroundColor: NSColor(theme.textColor), + ] + let alignmentsByColumn = (0.. CGFloat { + guard let storage = layoutManager.textStorage, storage.length > 0 else { return 0 } + layoutManager.ensureLayout(for: container) + let extra = layoutManager.extraLineFragmentRect + return ceil(extra.isEmpty ? layoutManager.usedRect(for: container).height : extra.minY) + } + + /// Height of `string` laid out at `width` by a standalone TextKit 1 stack, + /// configured as `BlockHeightMeasurer.exactHeight` is (and as + /// `SelfSizingTextView`'s measuring stack is), measured with `tableHeight`. + static func height(of string: NSAttributedString, width: CGFloat) -> CGFloat { + let storage = NSTextStorage(attributedString: string) + let layoutManager = NSLayoutManager() + let container = NSTextContainer(size: NSSize(width: max(0, width), + height: CGFloat.greatestFiniteMagnitude)) + container.lineFragmentPadding = 0 + container.widthTracksTextView = false + container.heightTracksTextView = false + layoutManager.addTextContainer(container) + storage.addLayoutManager(layoutManager) + return tableHeight(layoutManager: layoutManager, container: container) + } + + /// A column's `TextAlignment` (from the separator row) as a paragraph + /// alignment. + static func nsAlignment(_ alignment: TextAlignment) -> NSTextAlignment { + switch alignment { + case .leading: return .left + case .center: return .center + case .trailing: return .right + @unknown default: return .left + } + } +} + +// MARK: - Cell geometry and hit-testing + +extension NSTextView { + /// The bounds of a cell (padding + its borders) in view coordinates, or nil + /// when the text view does not hold the table `layout` describes. + func tableCellBounds(band: Int, column: Int, layout: TableTextLayout) -> NSRect? { + guard let layoutManager, let storage = textStorage, + band >= 0, band < layout.bandCount, column >= 0, column < layout.columnCount else { return nil } + let range = layout.cellRanges[band][column] + guard NSMaxRange(range) < storage.length, + let style = storage.attribute(.paragraphStyle, at: range.location, effectiveRange: nil) + as? NSParagraphStyle, let block = style.textBlocks.first else { return nil } + let glyphs = layoutManager.glyphRange(forCharacterRange: NSRange(location: range.location, + length: range.length + 1), + actualCharacterRange: nil) + return layoutManager.boundsRect(for: block, glyphRange: glyphs) + .offsetBy(dx: textContainerOrigin.x, dy: textContainerOrigin.y) + } + + /// The insertion index for `point` (view coordinates), confined to the cell + /// under the point — the table view's answer to the selection's hit-test + /// (TS-D6). + /// + /// `characterIndexForInsertion(at:)` alone is wrong for any point that is + /// not on one of the cell's line fragments: in the padding, or in the empty + /// space of a short cell in a tall band, TextKit picks whichever fragment of + /// ANY cell is nearest — usually the neighbour's text (19 of 36 probes in + /// the prototype). So: find the cell by its block bounds (the nearest cell + /// when the point is outside the table), clamp the point into the used rect + /// of that cell's nearest line, and only then ask TextKit. + func characterIndex(confinedToCellAt point: NSPoint, layout: TableTextLayout) -> Int { + guard let layoutManager, layout.bandCount > 0 else { return 0 } + // Bands stack top to bottom and every cell of a band spans the band's + // height, so column 0 locates the band (binary search), then the + // columns of that band locate the cell. + var low = 0, high = layout.bandCount - 1 + while low < high { + let mid = (low + high) / 2 + let rect = tableCellBounds(band: mid, column: 0, layout: layout) ?? .zero + if point.y < rect.maxY { high = mid } else { low = mid + 1 } + } + let band = low + var column = 0 + var nearest = CGFloat.greatestFiniteMagnitude + for candidate in 0.. 0 else { return range.location } + let glyphs = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) + var line: NSRect? + var lineDistance = CGFloat.greatestFiniteMagnitude + // The USED rect: past the end of a cell's last line, TextKit answers + // with an index outside the cell (measured: the next cell's text). + let origin = textContainerOrigin + layoutManager.enumerateLineFragments(forGlyphRange: glyphs) { _, used, _, _, _ in + let rect = used.offsetBy(dx: origin.x, dy: origin.y) + let distance = max(rect.minY - point.y, 0, point.y - rect.maxY) + if distance < lineDistance { lineDistance = distance; line = rect } + } + guard let line else { return range.location } + let clamped = NSPoint(x: min(max(point.x, line.minX), line.maxX - 0.5), + y: min(max(point.y, line.minY + 0.5), line.maxY - 0.5)) + // The cell is already decided by geometry; TextKit can still answer + // with the end of the string for the right end of a last-band cell's + // last line (once in 36 probes), so keep the answer in the cell. + return min(max(characterIndexForInsertion(at: clamped), range.location), NSMaxRange(range)) + } +} diff --git a/QuickMD/QuickMD/Views/CodeBlockView.swift b/QuickMD/QuickMD/Views/CodeBlockView.swift index 3f1216f..0a9681d 100644 --- a/QuickMD/QuickMD/Views/CodeBlockView.swift +++ b/QuickMD/QuickMD/Views/CodeBlockView.swift @@ -309,7 +309,10 @@ private struct CodeTextView: NSViewRepresentable { /// Read-only NSTextView whose height is a pure function of the proposed width, /// answered synchronously from the wrapper's `sizeThatFits` via -/// `size(fitting:)`. Used by `CodeTextView` and `BlockTextView`. +/// `size(fitting:)`. Used by `CodeTextView` and `BlockTextView`, and subclassed +/// by `TableTextView` (v1.13), which overrides only the two hooks below +/// (`layoutHeight`, `selectionCharacterIndex`) so a table row shares every +/// piece of the selection plumbing with the text rows. /// /// Measurement runs on a *second* layout manager attached to the same text /// storage (`measureLayoutManager`), never on the display container: the @@ -328,7 +331,7 @@ private struct CodeTextView: NSViewRepresentable { /// measured the text and pushed it into SwiftUI `@State` (`.frame(height:)`) /// on the next runloop turn, so every freshly created block changed size after /// appearing. See constraints.md ("Block heights are synchronous"). -final class SelfSizingTextView: NSTextView { +class SelfSizingTextView: NSTextView { /// Identity reference to the last-applied attributed string. Lets the wrapper /// short-circuit `updateNSView` work when SwiftUI hands us the same instance. var cachedAttributed: NSAttributedString? @@ -372,11 +375,18 @@ final class SelfSizingTextView: NSTextView { let lm = measureLayoutManager measureContainer.containerSize = NSSize(width: width, height: CGFloat.greatestFiniteMagnitude) lm.ensureLayout(for: measureContainer) - measuredHeight = ceil(lm.usedRect(for: measureContainer).height) + measuredHeight = layoutHeight(layoutManager: lm, container: measureContainer) measuredWidth = width return measuredHeight } + /// The height of the laid-out text in `container`: `ceil(usedRect)`. + /// Overridden by `TableTextView`, whose string ends in an extra line + /// fragment that is not part of the table (`TableTextBuilder.tableHeight`). + func layoutHeight(layoutManager: NSLayoutManager, container: NSTextContainer) -> CGFloat { + ceil(layoutManager.usedRect(for: container).height) + } + /// `sizeThatFits` implementation shared by both wrappers. A concrete width /// proposal is measured; an unspecified/zero/infinite width (LazyVStack's /// estimate queries) returns the last measurement so the estimate never @@ -421,6 +431,13 @@ final class SelfSizingTextView: NSTextView { /// offsets index into. var selectionTextLength: Int { textStorage?.length ?? 0 } + /// The selection's hit-test hook (v1.13 TS-D6): the insertion index for + /// `point`, in view coordinates. TextKit's own answer for text rows; + /// `TableTextView` confines it to the cell under the point. + func selectionCharacterIndex(at point: NSPoint) -> Int { + characterIndexForInsertion(at: point) + } + /// Join (or move to) the document selection named by the environment the /// hosting cell injects. Called from every `updateNSView` — i.e. after /// creation, after every string replacement (zoom, theme, the async code diff --git a/QuickMD/QuickMD/Views/TableBlockView.swift b/QuickMD/QuickMD/Views/TableBlockView.swift deleted file mode 100644 index 18c4e2d..0000000 --- a/QuickMD/QuickMD/Views/TableBlockView.swift +++ /dev/null @@ -1,189 +0,0 @@ -import SwiftUI - -// MARK: - Table Alignment Protocol - -/// Shared protocol for table alignment logic -/// Used by both TableBlockView (live) and PrintableTableView (export) -protocol TableAlignmentProvider { - var alignments: [TextAlignment] { get } -} - -extension TableAlignmentProvider { - /// Convert TextAlignment to SwiftUI Alignment for frame - func alignmentFor(_ index: Int) -> Alignment { - guard index < alignments.count else { return .leading } - switch alignments[index] { - case .leading: return .leading - case .center: return .center - case .trailing: return .trailing - @unknown default: return .leading - } - } - - /// Get TextAlignment for multilineTextAlignment modifier - func textAlignmentFor(_ index: Int) -> TextAlignment { - guard index < alignments.count else { return .leading } - return alignments[index] - } -} - -// MARK: - Table Block View - -/// Renders a markdown table with headers, rows, and column alignment -/// Supports inline markdown formatting in cells (bold, italic, code, links) -struct TableBlockView: View, TableAlignmentProvider { - let headers: [String] - let rows: [[String]] - let alignments: [TextAlignment] - let theme: MarkdownTheme - var fontScale: CGFloat = 1.0 - var searchText: String = "" - var focusedOccurrence: Int? = nil - - /// Cached renderer instance - created once on init, not on every body evaluation - private let renderer: MarkdownRenderer - - /// Stored column count - computed once on init for efficiency - private let columnCount: Int - - /// Headerless tables (`| | |`) skip the header band entirely - private let showsHeader: Bool - - /// Cell paddings, divider/separator thickness and the cell font size — see - /// `BlockLayout.Table` (shared with `BlockHeightMeasurer`). - typealias Metrics = BlockLayout.Table - - init(headers: [String], rows: [[String]], alignments: [TextAlignment], theme: MarkdownTheme, - fontScale: CGFloat = 1.0, searchText: String = "", focusedOccurrence: Int? = nil) { - self.headers = headers - self.rows = rows - self.alignments = alignments - self.theme = theme - self.fontScale = fontScale - self.searchText = searchText - self.focusedOccurrence = focusedOccurrence - self.renderer = MarkdownRenderer(theme: theme, fontScale: fontScale) - self.columnCount = max(headers.count, alignments.count, rows.map(\.count).max() ?? 0) - self.showsHeader = headers.contains { !$0.trimmingCharacters(in: .whitespaces).isEmpty } - } - - // MARK: - Body - - var body: some View { - // Precompute per-cell occurrence offsets for focused highlighting - let cellOffsets = computeCellOffsets() - - VStack(spacing: 0) { - if showsHeader { - // Header row with inline dividers (no GeometryReader) - HStack(spacing: 0) { - ForEach(0.. String { - index < headers.count ? headers[index] : "" - } - - // MARK: - Per-Cell Occurrence Tracking - - private struct CellOffsets { - let headerOffsets: [Int] // occurrence offset for each header cell - let rowOffsets: [[Int]] // occurrence offset for each data cell - } - - /// Compute cumulative occurrence offset for each cell (headers first, then rows L→R, T→B) - private func computeCellOffsets() -> CellOffsets { - guard !searchText.isEmpty else { - return CellOffsets(headerOffsets: Array(repeating: 0, count: columnCount), - rowOffsets: rows.map { _ in Array(repeating: 0, count: columnCount) }) - } - var offset = 0 - var headerOffsets: [Int] = [] - for i in 0.. Int? { - guard let fo = focusedOccurrence else { return nil } - let cellCount = countOccurrences(in: cellText, of: searchText) - let local = fo - cellOffset - if local >= 0 && local < cellCount { return local } - return nil - } -} diff --git a/QuickMD/QuickMD/Views/TableTextView.swift b/QuickMD/QuickMD/Views/TableTextView.swift new file mode 100644 index 0000000..b7303f6 --- /dev/null +++ b/QuickMD/QuickMD/Views/TableTextView.swift @@ -0,0 +1,219 @@ +import SwiftUI +import AppKit + +// MARK: - Table Block View (v1.13) + +/// A markdown table as ONE read-only NSTextView over an `NSTextTable` (TS-D1), +/// replacing 1.12's SwiftUI grid of `Text` cells, so a table's text joins the +/// document selection like any other text row. +/// +/// The string and its cell layout come from `TableTextBuilder` — normally the +/// very instance `BlockHeightMeasurer` laid out to size the row (`preconverted`, +/// v1.9 D12), which is why the row is `.exact`. The view adds only what a text +/// table cannot draw itself, all from `BlockLayout.Table`: the table background +/// and the rounded clip (`TableTextView.draw`), and the 1 pt outline — a SwiftUI +/// stroke overlay as in 1.12, centred on the edge so half of it lies outside the +/// view's frame, where an AppKit subview would be clipped. +struct TableTextBlockView: View { + let headers: [String] + let rows: [[String]] + let alignments: [TextAlignment] + let theme: MarkdownTheme + var fontScale: CGFloat = 1.0 + var searchText: String = "" + var focusedOccurrence: Int? = nil + /// The measurer's `TableText` for this block id. Used only when it was + /// built from this block (ids are positional — see `TableText`); otherwise, + /// or before the document was measured, the view builds its own. + var preconverted: TableText? = nil + let onLink: (URL) -> Void + + typealias Metrics = BlockLayout.Table + + var body: some View { + let table = preconverted.flatMap { + $0.matches(headers: headers, rows: rows, alignments: alignments, fontScale: fontScale) ? $0 : nil + } ?? TableTextBuilder.build(headers: headers, rows: rows, alignments: alignments, + theme: theme, fontScale: fontScale) + + TableTextRepresentable( + table: table, + headers: headers, + rows: rows, + background: theme.backgroundColor, + searchTerm: searchText, + focusedOccurrence: focusedOccurrence, + onLink: onLink + ) + .frame(maxWidth: .infinity, alignment: .leading) + .overlay(RoundedRectangle(cornerRadius: Metrics.cornerRadius) + .stroke(theme.borderColor, lineWidth: Metrics.borderWidth)) + } +} + +// MARK: - NSTextView Wrapper + +private struct TableTextRepresentable: NSViewRepresentable { + let table: TableText + /// The cells' markdown, for the 1.12 search mapping (`applyTableSearchHighlight`). + let headers: [String] + let rows: [[String]] + let background: Color + let searchTerm: String + let focusedOccurrence: Int? + let onLink: (URL) -> Void + + func makeCoordinator() -> TextViewLinkDelegate { TextViewLinkDelegate(onLink: onLink) } + + func makeNSView(context: Context) -> TableTextView { + let textView = TableTextView() + textView.configureForSelfSizing() + textView.installDocumentSelectionLayoutManager() + textView.delegate = context.coordinator + // Keep the renderer's link color/underline — only add the pointer cursor. + textView.linkTextAttributes = [.cursor: NSCursor.pointingHand] + return textView + } + + func sizeThatFits(_ proposal: ProposedViewSize, nsView: TableTextView, context: Context) -> CGSize? { + nsView.size(fitting: proposal) + } + + func updateNSView(_ textView: TableTextView, context: Context) { + context.coordinator.onLink = onLink + + let fill = NSColor(background) + if textView.tableBackground != fill { + textView.tableBackground = fill + textView.needsDisplay = true + } + + let textChanged = (textView.cachedAttributed !== table.string) + if textChanged { + textView.textStorage?.setAttributedString(table.string) + textView.cachedAttributed = table.string + textView.tableLayout = table.layout + textView.invalidateMeasurement() + } + + let searchChanged = (textView.lastSearchTerm != searchTerm) || + (textView.lastFocusedOccurrence != focusedOccurrence) + if textChanged || searchChanged { + applyTableSearchHighlight(in: textView, layout: table.layout, headers: headers, rows: rows, + term: searchTerm, focusedOccurrence: focusedOccurrence) + textView.lastSearchTerm = searchTerm + textView.lastFocusedOccurrence = focusedOccurrence + } + + // As `BlockTextView`: the row's block id comes from the hosting cell's + // environment, and the covered range is re-read after every string change. + textView.attachSelection(context.environment.blockSelection) + } + + static func dismantleNSView(_ textView: TableTextView, coordinator: TextViewLinkDelegate) { + textView.detachSelection() + } +} + +// MARK: - Table Text View + +/// `SelfSizingTextView` (sizing, refusing first responder, selection drawing, +/// AX — the whole text-row plumbing) with the three things that differ for a +/// table: the rounded clip + background, the height (`TableTextBuilder`'s, +/// not `usedRect`), and the cell-confined hit-test hook. +final class TableTextView: SelfSizingTextView { + /// The cell layout of the installed string; set with it. + var tableLayout: TableTextLayout? + /// The table's own background (1.12 `.background(theme.backgroundColor)`), + /// painted inside the rounded clip. + var tableBackground: NSColor = .clear + + /// Everything — background, header band, rules, selection, search + /// highlights, text — is clipped to the rounded rect (1.12 `.clipShape`). + override func draw(_ dirtyRect: NSRect) { + NSGraphicsContext.saveGraphicsState() + NSBezierPath(roundedRect: bounds, xRadius: BlockLayout.Table.cornerRadius, + yRadius: BlockLayout.Table.cornerRadius).addClip() + tableBackground.setFill() + dirtyRect.fill() + super.draw(dirtyRect) + NSGraphicsContext.restoreGraphicsState() + } + + override func layoutHeight(layoutManager: NSLayoutManager, container: NSTextContainer) -> CGFloat { + TableTextBuilder.tableHeight(layoutManager: layoutManager, container: container) + } + + override func selectionCharacterIndex(at point: NSPoint) -> Int { + guard let tableLayout, tableLayout.length == selectionTextLength else { + return super.selectionCharacterIndex(at: point) + } + return characterIndex(confinedToCellAt: point, layout: tableLayout) + } +} + +// MARK: - Search highlight (1.12 semantics) + +/// Search highlighting for a table, with exactly 1.12's mapping (TS-T3 replaces +/// it with `applyNSSearchHighlight` on the rendered string, TS-D9). +/// +/// `DocumentSearch` counts a table's matches in the cells' MARKDOWN (headers +/// first, then rows, left to right), so the block's focused occurrence is an +/// index into those raw counts. Each cell therefore maps it to a cell-local +/// index (`focused − occurrences in earlier cells`, if below this cell's raw +/// count) and highlights the matches in its RENDERED text, the focused one +/// orange — what 1.12's per-cell `searchHighlight` did. Main-thread by +/// convention, like `applyNSSearchHighlight`. +func applyTableSearchHighlight(in textView: NSTextView, layout: TableTextLayout, + headers: [String], rows: [[String]], + term: String, focusedOccurrence: Int?) { + guard let layoutManager = textView.layoutManager, + let storage = textView.textStorage else { return } + layoutManager.removeTemporaryAttribute(.backgroundColor, + forCharacterRange: NSRange(location: 0, length: storage.length)) + guard !term.isEmpty, storage.length == layout.length else { return } + + let needle = term.lowercased() + let string = storage.string as NSString + let yellow = NSColor.systemYellow.withAlphaComponent(0.55) + let orange = NSColor.systemOrange + var earlier = 0 + + func highlight(band: Int?, column: Int, markdown: String) { + let rawCount = countOccurrences(in: markdown, of: term) + defer { earlier += rawCount } + guard let band, band < layout.bandCount else { return } + let local = focusedOccurrence.map { $0 - earlier }.flatMap { (0.. 0 else { return } + let haystack = string.substring(with: cell).lowercased() as NSString + var location = 0 + var index = 0 + while location < haystack.length { + let found = haystack.range(of: needle, options: [], + range: NSRange(location: location, length: haystack.length - location)) + if found.location == NSNotFound { break } + let target = NSIntersectionRange(NSRange(location: cell.location + found.location, length: found.length), + cell) + if target.length > 0 { + layoutManager.addTemporaryAttribute(.backgroundColor, value: index == local ? orange : yellow, + forCharacterRange: target) + } + location = found.location + max(found.length, 1) + index += 1 + } + } + + // 1.12 counted the header cells even when the band is hidden (all blank). + for column in 0.. Void - func makeCoordinator() -> Coordinator { Coordinator(onLink: onLink) } + func makeCoordinator() -> TextViewLinkDelegate { TextViewLinkDelegate(onLink: onLink) } func makeNSView(context: Context) -> SelfSizingTextView { let textView = SelfSizingTextView() @@ -236,25 +236,31 @@ private struct BlockTextView: NSViewRepresentable { textView.attachSelection(context.environment.blockSelection) } - static func dismantleNSView(_ textView: SelfSizingTextView, coordinator: Coordinator) { + static func dismantleNSView(_ textView: SelfSizingTextView, coordinator: TextViewLinkDelegate) { textView.detachSelection() } +} + +// MARK: - Link delegate + +/// The delegate of every NSTextView-backed row (text, headings, quote and alert +/// bodies, tables): a link click — the document selection's click-through +/// (`SelectionController` → `clicked(onLink:at:)`) — goes to `onLink`, i.e. +/// `MarkdownView.handleLinkActivation`. +final class TextViewLinkDelegate: NSObject, NSTextViewDelegate { + var onLink: (URL) -> Void - final class Coordinator: NSObject, NSTextViewDelegate { - var onLink: (URL) -> Void - - init(onLink: @escaping (URL) -> Void) { self.onLink = onLink } - - func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { - if let url = link as? URL { - onLink(url) - return true - } - if let string = link as? String, let url = URL(string: string) { - onLink(url) - return true - } - return false + init(onLink: @escaping (URL) -> Void) { self.onLink = onLink } + + func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { + if let url = link as? URL { + onLink(url) + return true + } + if let string = link as? String, let url = URL(string: string) { + onLink(url) + return true } + return false } } diff --git a/QuickMD/QuickMD/Views/VirtualBlockList.swift b/QuickMD/QuickMD/Views/VirtualBlockList.swift index 0de602b..ac6c092 100644 --- a/QuickMD/QuickMD/Views/VirtualBlockList.swift +++ b/QuickMD/QuickMD/Views/VirtualBlockList.swift @@ -1903,7 +1903,7 @@ final class SelectionController: NSObject { switch block.content { case .table(let headers, let rows, _): guard selection.range(inRow: row, rowLength: 1) != nil else { continue } - // Same rule as `TableBlockView.showsHeader`: an all-blank header + // Same rule as `TableTextLayout.showsHeader`: an all-blank header // row is not shown, so it is not copied either. let showsHeader = headers.contains { !$0.trimmingCharacters(in: .whitespaces).isEmpty } pieces.append(.table(headers: showsHeader ? headers.map(renderedCell) : nil, diff --git a/QuickMD/QuickMDTests/BlockHeightMeasurerTests.swift b/QuickMD/QuickMDTests/BlockHeightMeasurerTests.swift index 2c6d814..bd5cd55 100644 --- a/QuickMD/QuickMDTests/BlockHeightMeasurerTests.swift +++ b/QuickMD/QuickMDTests/BlockHeightMeasurerTests.swift @@ -414,10 +414,10 @@ final class BlockHeightMeasurerTests: XCTestCase { XCTAssertGreaterThan(height, 0, "block \(block.id) measured to \(height)") switch block.content { - case .text, .blockquote, .alert, .codeBlock: + case .text, .blockquote, .alert, .codeBlock, .table: XCTAssertEqual(kind, .exact, "\(block.id) should be exact") seen.insert(exemplar(block)) - case .heading, .table, .mathBlock, .image, .mermaidDiagram, .svgImage: + case .heading, .mathBlock, .image, .mermaidDiagram, .svgImage: XCTAssertEqual(kind, .reported, "\(block.id) should be reported") seen.insert(exemplar(block)) } @@ -436,6 +436,12 @@ final class BlockHeightMeasurerTests: XCTestCase { default: XCTAssertNil(measured.converted[block.id], "unexpected conversion for \(block.id)") } + // …and table strings for exactly the tables (v1.13). + if case .table = block.content { + XCTAssertNotNil(measured.tables[block.id], "missing table string for \(block.id)") + } else { + XCTAssertNil(measured.tables[block.id], "unexpected table string for \(block.id)") + } } // ~0.065 ms/block measured on an M-series MBP (1001 blocks in 65 ms; @@ -519,7 +525,7 @@ final class BlockHeightMeasurerTests: XCTestCase { XCTAssertGreaterThan(long, short) } - func testTableEstimateGrowsWithRows() { + func testTableHeightGrowsWithRows() { let twoRows = parse("| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |") let fiveRows = parse("| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n| 5 | 6 |\n| 7 | 8 |\n| 9 | 0 |") let small = measure(twoRows, width: 600).table.heights[0] diff --git a/QuickMD/QuickMDTests/TableTextTests.swift b/QuickMD/QuickMDTests/TableTextTests.swift new file mode 100644 index 0000000..48625e3 --- /dev/null +++ b/QuickMD/QuickMDTests/TableTextTests.swift @@ -0,0 +1,325 @@ +import XCTest +import SwiftUI +import AppKit + +/// v1.13 TS-T1: a table is one NSTextView over a TextKit 1 `NSTextTable`. +/// +/// Two things are pinned here: +/// 1. The string layout (TS-D4) — the row's selectable string, so its offsets +/// are what the document selection, copy and search index into: header band +/// first (only when shown), then rows, left to right; one paragraph per +/// cell; ragged rows padded; `cellRanges` / `bandRange` / `cell(containing:)`. +/// 2. Parity (TS-D3) — the row is `.exact`, so the measurer's height must be +/// the height the table really takes in a text view configured as +/// `TableTextView` is (`configureForSelfSizing` + +/// `installDocumentSelectionLayoutManager`), laid out through its DISPLAY +/// container at the same width. `TableTextView` itself lives with the views +/// (not compiled into this target); its only height-relevant code is +/// `TableTextBuilder.tableHeight`, used on both sides here. +final class TableTextTests: XCTestCase { + + private var theme: MarkdownTheme { MarkdownTheme.cached(for: .light) } + + /// The prototype's fixtures (scratchpad/table-proto, 2026-10-22) plus a + /// header-only table. + private static let fixtures: [(name: String, markdown: String)] = [ + ("simple", """ + | Name | Role | Since | + |------|------|-------| + | Ada | Engineer | 2019 | + | Grace | Admiral | 1943 | + | Linus | Maintainer | 1991 | + """), + ("rich", """ + | Feature | Description | Status | + |:--------|:-----------:|-------:| + | **Search** | Finds every occurrence across the document, including text inside tables, and highlights the focused one while you step with Cmd-G | `done` | + | Links | See [the docs](https://example.com/docs) for the full list of supported inline syntax, which wraps onto several lines at narrow widths | **beta** | + | Code | `let x = renderer.renderInline(cell)` sits inline with regular text and must wrap with it | ok | + """), + ("headerless-ragged", """ + | | | | + |---|---|---| + | a | b | + | one | two | three | four | + | only | + """), + ("six-columns", """ + | Column 0 | Column 1 | Column 2 | Column 3 | Column 4 | Column 5 | + |---|---|---|---|---|---| + | Cell 0,0 | Cell 0,1 | **Row 0** has `code` and a longer description that wraps at narrower widths | Cell 0,3 | Cell 0,4 | Cell 0,5 | + | Cell 1,0 | Cell 1,1 | **Row 1** has `code` and a longer description that wraps at narrower widths | Cell 1,3 | Cell 1,4 | Cell 1,5 | + """), + ("header-only", """ + | Lonely | Header | + |---|---| + """), + ] + + private struct Table { + let block: MarkdownBlock + let headers: [String] + let rows: [[String]] + let alignments: [TextAlignment] + } + + private func table(_ markdown: String, fontScale: CGFloat = 1) -> Table { + let blocks = MarkdownBlockParser(theme: theme, fontScale: fontScale).parse(markdown) + for block in blocks { + if case .table(let headers, let rows, let alignments) = block.content { + return Table(block: block, headers: headers, rows: rows, alignments: alignments) + } + } + XCTFail("no table in fixture") + return Table(block: blocks[0], headers: [], rows: [], alignments: []) + } + + private func build(_ t: Table, fontScale: CGFloat = 1) -> TableText { + TableTextBuilder.build(headers: t.headers, rows: t.rows, alignments: t.alignments, + theme: theme, fontScale: fontScale) + } + + /// A text view set up as `TableTextView.makeNSView` sets it up, holding + /// `table`, laid out at `width` through its display container. + private func liveTextView(_ table: TableText, width: CGFloat) -> NSTextView { + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: width, height: 10)) + textView.configureForSelfSizing() + textView.installDocumentSelectionLayoutManager() + textView.textStorage?.setAttributedString(table.string) + textView.layoutManager?.ensureLayout(for: textView.textContainer!) + return textView + } + + // MARK: - String layout (TS-D4) + + func testHeaderThenRowsLeftToRightOneParagraphPerCell() { + let t = table(Self.fixtures[0].markdown) + let built = build(t) + XCTAssertEqual(built.string.string, + "Name\nRole\nSince\nAda\nEngineer\n2019\nGrace\nAdmiral\n1943\nLinus\nMaintainer\n1991\n") + XCTAssertTrue(built.layout.showsHeader) + XCTAssertEqual(built.layout.columnCount, 3) + XCTAssertEqual(built.layout.bandCount, 4) + XCTAssertEqual(built.layout.length, built.string.length) + + // Every cell is exactly one paragraph, and that paragraph is the cell's + // own NSTextTableBlock at (band, column). + let ns = built.string.string as NSString + var paragraphs: [NSRange] = [] + ns.enumerateSubstrings(in: NSRange(location: 0, length: ns.length), options: .byParagraphs) { _, range, _, _ in + paragraphs.append(range) + } + XCTAssertEqual(paragraphs, built.layout.cellRanges.flatMap { $0 }) + for band in 0.. NSTextAlignment? { + (built.string.attribute(.paragraphStyle, at: NSMaxRange(built.layout.cellRanges[band][column]), + effectiveRange: nil) as? NSParagraphStyle)?.alignment + } + XCTAssertEqual(alignment(1, 0), .left) + XCTAssertEqual(alignment(1, 1), .right) + XCTAssertEqual(alignment(1, 2), .left) + } + + func testHeaderlessTableHasNoHeaderBand() { + let t = table(Self.fixtures[2].markdown) + let built = build(t) + XCTAssertFalse(built.layout.showsHeader) + XCTAssertEqual(built.layout.bandCount, t.rows.count) + XCTAssertTrue(built.string.string.hasPrefix("a\nb\n\n"), built.string.string) + // No header background anywhere. + built.string.enumerateAttribute(.paragraphStyle, in: NSRange(location: 0, length: built.string.length)) { value, _, _ in + let block = (value as? NSParagraphStyle)?.textBlocks.first + XCTAssertNil(block?.backgroundColor) + } + } + + func testBandRangeAndCellContaining() { + let built = TableTextBuilder.build(headers: ["H1", "H2"], rows: [["a", ""], ["bb", "c"]], + alignments: [], theme: theme, fontScale: 1) + // "H1\nH2\na\n\nbb\nc\n" + let layout = built.layout + XCTAssertEqual(layout.bandRange(0), NSRange(location: 0, length: 6)) + XCTAssertEqual(layout.bandRange(1), NSRange(location: 6, length: 3)) + XCTAssertEqual(layout.bandRange(2), NSRange(location: 9, length: 5)) + XCTAssertEqual(NSMaxRange(layout.bandRange(2)), built.string.length) + + let expected: [(Int, Int)] = [(0, 0), (0, 0), (0, 0), (0, 1), (0, 1), (0, 1), + (1, 0), (1, 0), (1, 1), + (2, 0), (2, 0), (2, 0), (2, 1), (2, 1)] + XCTAssertEqual(expected.count, built.string.length) + for (index, cell) in expected.enumerated() { + let hit = layout.cell(containing: index) + XCTAssertEqual(hit?.band, cell.0, "index \(index)") + XCTAssertEqual(hit?.column, cell.1, "index \(index)") + } + XCTAssertNil(layout.cell(containing: -1)) + XCTAssertNil(layout.cell(containing: built.string.length)) + } + + func testEmptyTableIsEmptyAndZeroHigh() { + let built = TableTextBuilder.build(headers: ["", ""], rows: [], alignments: [.leading, .leading], + theme: theme, fontScale: 1) + XCTAssertEqual(built.string.length, 0) + XCTAssertEqual(built.layout.bandCount, 0) + XCTAssertEqual(built.layout.length, 0) + XCTAssertNil(built.layout.cell(containing: 0)) + XCTAssertEqual(TableTextBuilder.height(of: built.string, width: 500), 0) + } + + /// What 1.12 drew: the renderer's 14·scale regular in EVERY cell, header + /// included; header band background; rules on trailing/bottom edges only, + /// the header always keeping its bottom rule. + func testCellFontsBackgroundsAndRules() { + for scale in [CGFloat(0.75), 1.0, 1.5] { + let built = build(table(Self.fixtures[0].markdown, fontScale: scale), fontScale: scale) + built.string.enumerateAttribute(.font, in: NSRange(location: 0, length: built.string.length)) { value, range, _ in + guard let font = value as? NSFont else { return XCTFail("no font at \(range)") } + XCTAssertEqual(font.pointSize, BlockLayout.Table.cellFontSize * scale, accuracy: 0.001) + XCTAssertFalse(font.fontDescriptor.symbolicTraits.contains(.bold), "bold at \(range)") + } + } + let built = build(table(Self.fixtures[0].markdown)) + func block(_ band: Int, _ column: Int) -> NSTextTableBlock { + let style = built.string.attribute(.paragraphStyle, at: built.layout.cellRanges[band][column].location, + effectiveRange: nil) as! NSParagraphStyle + return style.textBlocks[0] as! NSTextTableBlock + } + XCTAssertNotNil(block(0, 0).backgroundColor) + XCTAssertNil(block(1, 0).backgroundColor) + XCTAssertEqual(block(0, 0).width(for: .border, edge: .maxX), BlockLayout.Table.dividerWidth) + XCTAssertEqual(block(0, 2).width(for: .border, edge: .maxX), 0) + XCTAssertEqual(block(2, 1).width(for: .border, edge: .maxY), BlockLayout.Table.separatorHeight) + XCTAssertEqual(block(3, 1).width(for: .border, edge: .maxY), 0) + XCTAssertEqual(block(0, 1).width(for: .padding, edge: .minY), BlockLayout.Table.headerCellVerticalPadding) + XCTAssertEqual(block(1, 1).width(for: .padding, edge: .minY), BlockLayout.Table.cellVerticalPadding) + + let headerOnly = build(table(Self.fixtures[4].markdown)) + let headerStyle = headerOnly.string.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as! NSParagraphStyle + XCTAssertEqual(headerStyle.textBlocks[0].width(for: .border, edge: .maxY), BlockLayout.Table.separatorHeight, + "the header separator is drawn even with no rows below it (as 1.12)") + } + + // MARK: - Measurer (TS-D3) + + func testTableRowIsExactAndCarriesTheBuiltString() { + let t = table(Self.fixtures[1].markdown) + let measured = BlockHeightMeasurer.measure(blocks: [t.block], theme: theme, fontScale: 1, + contentWidth: 700, math: nil) + XCTAssertEqual(measured.table.kinds, [.exact]) + XCTAssertTrue(measured.mathPendingRows.isEmpty) + let carried = measured.tables[t.block.id] + XCTAssertNotNil(carried) + // Characters + layout: NSAttributedString equality compares the + // NSTextTable objects by identity, and each build makes its own. + XCTAssertEqual(carried?.string.string, build(t).string.string) + XCTAssertEqual(carried?.layout, build(t).layout) + XCTAssertTrue(carried?.matches(headers: t.headers, rows: t.rows, alignments: t.alignments, + fontScale: 1) ?? false) + XCTAssertFalse(carried?.matches(headers: t.headers, rows: t.rows, alignments: t.alignments, + fontScale: 1.5) ?? true, "another zoom is another string") + } + + /// measurer row == laid-out view + outer padding, to the point, for every + /// fixture × width × zoom. Also checked against the table's real drawn + /// bottom (the last cell's block bounds), which does not go through + /// `tableHeight` at all. + func testMeasuredHeightEqualsLaidOutTableView() { + var compared = 0 + for fixture in Self.fixtures { + for scale in [CGFloat(0.75), 1.0, 1.5] { + let t = table(fixture.markdown, fontScale: scale) + let built = build(t, fontScale: scale) + for width in [CGFloat(400), 700, 1000] { + let row = BlockHeightMeasurer.measure(blocks: [t.block], theme: theme, fontScale: scale, + contentWidth: width, math: nil).table.heights[0] + let measured = row - 2 * BlockLayout.Document.tableOuterVerticalPadding + + let textView = liveTextView(built, width: width) + let live = TableTextBuilder.tableHeight(layoutManager: textView.layoutManager!, + container: textView.textContainer!) + let tag = "\(fixture.name) @\(Int(width)) ×\(scale)" + XCTAssertEqual(measured, live, tag) + + let layout = built.layout + let lastCell = textView.tableCellBounds(band: layout.bandCount - 1, column: 0, layout: layout) + XCTAssertNotNil(lastCell, tag) + XCTAssertEqual(live, ceil(lastCell?.maxY ?? 0), tag) + XCTAssertGreaterThan(live, 0, tag) + compared += 1 + } + } + } + XCTAssertEqual(compared, Self.fixtures.count * 9) + } + + func testHeightGrowsWhenCellsWrap() { + let t = table(Self.fixtures[1].markdown) + let built = build(t) + XCTAssertGreaterThan(TableTextBuilder.height(of: built.string, width: 400), + TableTextBuilder.height(of: built.string, width: 1000)) + } + + // MARK: - Hit-testing helper (TS-D6 groundwork) + + /// Centre and padding corners of every cell resolve to THAT cell — the + /// probes plain `characterIndexForInsertion` got wrong 19/36 times. + func testConfinedHitTestStaysInTheCellUnderThePoint() { + for fixture in Self.fixtures { + let built = build(table(fixture.markdown)) + let textView = liveTextView(built, width: 700) + let layout = built.layout + for band in 0.. Date: Thu, 22 Oct 2026 13:41:04 +0200 Subject: [PATCH 02/10] wip(tables): TS-T3 search counts and highlights the table's rendered text Table matches are counted with the same matcher over the same characters the table view shows (the measurer's TableText string, or a plain string from the same builder helpers), so count == highlights, in reading order; markup and link URLs are no longer counted. Highlight through applyNSSearchHighlight (moved to DocumentSearch.swift for headless tests). The 1.12 bridge and the dead SwiftUI highlight path are removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BhzVb6aPQs2FF1bHdkcYys --- QuickMD/QuickMD/DocumentSearch.swift | 121 ++++++----- QuickMD/QuickMD/MarkdownView.swift | 9 +- QuickMD/QuickMD/TableTextBuilder.swift | 67 ++++-- QuickMD/QuickMD/Views/TableTextView.swift | 78 +------ QuickMD/QuickMD/Views/TextBlockView.swift | 38 +--- QuickMD/QuickMDTests/SearchTests.swift | 252 ++++++++++++++++++++-- 6 files changed, 373 insertions(+), 192 deletions(-) diff --git a/QuickMD/QuickMD/DocumentSearch.swift b/QuickMD/QuickMD/DocumentSearch.swift index 0e56724..37a0b80 100644 --- a/QuickMD/QuickMD/DocumentSearch.swift +++ b/QuickMD/QuickMD/DocumentSearch.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppKit // MARK: - Parser Output @@ -17,56 +18,51 @@ struct ParsedDocument: Sendable { // MARK: - Shared Search Highlighting -/// Applies search term highlighting to an AttributedString. -/// `focusedOccurrence` = which occurrence (0-based) in this text gets orange. -/// `nil` means no occurrence is focused — all matches get yellow. -func searchHighlight(_ attributed: AttributedString, term: String, focusedOccurrence: Int? = nil) -> AttributedString { - guard !term.isEmpty else { return attributed } - let plainText = String(attributed.characters) - let textLower = plainText.lowercased() - let searchLower = term.lowercased() - - // Find all match ranges in the lowercased text - var matchRanges: [(lowerBound: String.Index, upperBound: String.Index)] = [] - var searchStart = textLower.startIndex - while let range = textLower.range(of: searchLower, range: searchStart.. [NSRange] { + guard !term.isEmpty else { return [] } + let needle = term.lowercased() as NSString + let lowerHaystack = string.lowercased() as NSString + guard needle.length > 0, lowerHaystack.length >= needle.length else { return [] } + + var ranges: [NSRange] = [] + var location = 0 + while location < lowerHaystack.length { + let searchRange = NSRange(location: location, length: lowerHaystack.length - location) + let found = lowerHaystack.range(of: needle as String, options: [], range: searchRange) + if found.location == NSNotFound { break } + ranges.append(found) + location = found.location + max(found.length, 1) } - guard !matchRanges.isEmpty else { return attributed } - - // Convert String ranges → AttributedString ranges via single-pass parallel iteration - var result = attributed - var sIdx = plainText.startIndex - var aIdx = result.startIndex - for (matchIndex, matchRange) in matchRanges.enumerated() { - while sIdx < matchRange.lowerBound { - sIdx = plainText.index(after: sIdx) - aIdx = result.characters.index(after: aIdx) - } - let attrStart = aIdx - while sIdx < matchRange.upperBound { - sIdx = plainText.index(after: sIdx) - aIdx = result.characters.index(after: aIdx) - } - let color = (focusedOccurrence == matchIndex) ? Color.orange : Color.yellow.opacity(0.55) - result[attrStart.. Int { - guard !term.isEmpty else { return 0 } - let textLower = text.lowercased() - let termLower = term.lowercased() - var count = 0 - var start = textLower.startIndex - while let range = textLower.range(of: termLower, range: start.. 0 { + let color = (focusedOccurrence == occurrenceIndex) ? orange : yellow + layoutManager.addTemporaryAttribute(.backgroundColor, value: color, forCharacterRange: safeRange) + } } - return count } // MARK: - Document Search @@ -81,20 +77,45 @@ enum DocumentSearch { let matchBlockIds: [String] } - static func computeMatches(in blocks: [MarkdownBlock], term: String) -> Results { + /// `tables`, `theme` and `fontScale` locate the string each `.table` + /// block's view shows: the measurer's `TableText` when it was built from + /// this block (the rule `TableTextBlockView` applies), otherwise + /// `TableTextBuilder.plainString` — the same characters, rendered without + /// building the table. Rendered characters do not depend on the theme or + /// the scale, so the defaults are exact for callers without a view (tests); + /// `MarkdownView` passes its own so the measured strings are reused. + static func computeMatches(in blocks: [MarkdownBlock], term: String, + tables: [String: TableText] = [:], + theme: MarkdownTheme = .cached(for: .light), + fontScale: CGFloat = 1) -> Results { guard !term.isEmpty else { return Results(matchBlockIds: []) } let searchLower = term.lowercased() var matches: [String] = [] for block in blocks { - // Collect all text segments for this block (per-cell for tables, per-line for blockquotes) + // Collect the text segments for this block let segments: [String] switch block.content { case .text(let attr): segments = [String(attr.characters)] - case .table(let headers, let rows, _): - segments = headers + rows.flatMap { $0 } + case .table(let headers, let rows, let alignments): + // Counted on the RENDERED string the table view shows (no + // markup, no link URLs), with the matcher its highlight uses: + // every counted match is drawn, and the block's focused + // occurrence is an index into exactly the highlights, in + // reading order (TS-D9). 1.12 counted the cells' markdown. + let string: String + if let measured = tables[block.id], + measured.matches(headers: headers, rows: rows, alignments: alignments, fontScale: fontScale) { + string = measured.string.string + } else { + string = TableTextBuilder.plainString(headers: headers, rows: rows, alignments: alignments, + theme: theme, fontScale: fontScale) + } + let count = searchMatchRanges(in: string, term: term).count + matches.append(contentsOf: repeatElement(block.id, count: count)) + continue case .codeBlock(let code, _): segments = [code] case .blockquote(let content, _): diff --git a/QuickMD/QuickMD/MarkdownView.swift b/QuickMD/QuickMD/MarkdownView.swift index 9028da7..e45879f 100644 --- a/QuickMD/QuickMD/MarkdownView.swift +++ b/QuickMD/QuickMD/MarkdownView.swift @@ -1341,8 +1341,15 @@ struct MarkdownView: View { // NSTextView wrappers via temporary attributes.) let generation = searchGeneration let blocks = cachedBlocks + // Tables are counted on the strings their views show; the measurer's + // are reused (no table rebuilt per keystroke) when they belong to + // these blocks — `computeMatches` checks that per table. + let measuredBlocks = measured + let currentTheme = theme + let scale = CGFloat(renderedFontScale) Task.detached(priority: .userInitiated) { - let results = DocumentSearch.computeMatches(in: blocks, term: term) + let results = DocumentSearch.computeMatches(in: blocks, term: term, tables: measuredBlocks.tables, + theme: currentTheme, fontScale: scale) await MainActor.run { guard generation == searchGeneration else { return } matchBlockIds = results.matchBlockIds diff --git a/QuickMD/QuickMD/TableTextBuilder.swift b/QuickMD/QuickMD/TableTextBuilder.swift index ad66898..009ed88 100644 --- a/QuickMD/QuickMD/TableTextBuilder.swift +++ b/QuickMD/QuickMD/TableTextBuilder.swift @@ -136,14 +136,7 @@ enum TableTextBuilder { static func build(headers: [String], rows: [[String]], alignments: [TextAlignment], theme: MarkdownTheme, fontScale: CGFloat) -> TableText { let renderer = MarkdownRenderer(theme: theme, fontScale: fontScale) - // The separator row is authoritative (constraints.md "Tables"); ragged - // rows widen the table rather than lose cells. - let columnCount = max(1, max(headers.count, alignments.count, rows.map(\.count).max() ?? 0)) - let showsHeader = headers.contains { !$0.trimmingCharacters(in: .whitespaces).isEmpty } - - var bands: [[String]] = [] - if showsHeader { bands.append(headers) } - bands.append(contentsOf: rows) + let (columnCount, showsHeader, bands) = grid(headers: headers, rows: rows, alignments: alignments) let table = NSTextTable() table.numberOfColumns = columnCount @@ -172,15 +165,7 @@ enum TableTextBuilder { var ranges: [NSRange] = [] ranges.reserveCapacity(columnCount) for column in 0.. String { + let renderer = MarkdownRenderer(theme: theme, fontScale: fontScale) + let (columnCount, _, bands) = grid(headers: headers, rows: rows, alignments: alignments) + var result = "" + for band in bands { + for column in 0.. (columnCount: Int, showsHeader: Bool, bands: [[String]]) { + let columnCount = max(1, max(headers.count, alignments.count, rows.map(\.count).max() ?? 0)) + let showsHeader = headers.contains { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + var bands: [[String]] = [] + if showsHeader { bands.append(headers) } + bands.append(contentsOf: rows) + return (columnCount, showsHeader, bands) + } + + /// One cell's rendered text (AppKit scope), without its "\n". + private static func renderedCell(_ markdown: String, renderer: MarkdownRenderer) -> NSMutableAttributedString { + let cell = NSMutableAttributedString( + attributedString: (try? NSAttributedString(renderer.renderInline(markdown), including: \.appKit)) + ?? NSAttributedString(string: markdown)) + // One paragraph per cell is the layout's invariant (TS-D4). A table row + // is one source line, so this never fires today; it keeps the + // invariant if the renderer ever emits a break. + cell.mutableString.replaceOccurrences(of: "\n", with: " ", options: [], + range: NSRange(location: 0, length: cell.length)) + return cell + } + /// The table's height in a laid-out container — the view's /// (`TableTextView.layoutHeight`) and the measurer's (`height(of:width:)`). /// diff --git a/QuickMD/QuickMD/Views/TableTextView.swift b/QuickMD/QuickMD/Views/TableTextView.swift index b7303f6..2c92a34 100644 --- a/QuickMD/QuickMD/Views/TableTextView.swift +++ b/QuickMD/QuickMD/Views/TableTextView.swift @@ -38,8 +38,6 @@ struct TableTextBlockView: View { TableTextRepresentable( table: table, - headers: headers, - rows: rows, background: theme.backgroundColor, searchTerm: searchText, focusedOccurrence: focusedOccurrence, @@ -55,9 +53,6 @@ struct TableTextBlockView: View { private struct TableTextRepresentable: NSViewRepresentable { let table: TableText - /// The cells' markdown, for the 1.12 search mapping (`applyTableSearchHighlight`). - let headers: [String] - let rows: [[String]] let background: Color let searchTerm: String let focusedOccurrence: Int? @@ -99,8 +94,11 @@ private struct TableTextRepresentable: NSViewRepresentable { let searchChanged = (textView.lastSearchTerm != searchTerm) || (textView.lastFocusedOccurrence != focusedOccurrence) if textChanged || searchChanged { - applyTableSearchHighlight(in: textView, layout: table.layout, headers: headers, rows: rows, - term: searchTerm, focusedOccurrence: focusedOccurrence) + // The text rows' highlight on the whole table string: `DocumentSearch` + // counted this block on the same characters with the same matcher + // (TS-D9), so the focused occurrence is the n-th highlight in + // reading order. + applyNSSearchHighlight(in: textView, term: searchTerm, focusedOccurrence: focusedOccurrence) textView.lastSearchTerm = searchTerm textView.lastFocusedOccurrence = focusedOccurrence } @@ -151,69 +149,3 @@ final class TableTextView: SelfSizingTextView { return characterIndex(confinedToCellAt: point, layout: tableLayout) } } - -// MARK: - Search highlight (1.12 semantics) - -/// Search highlighting for a table, with exactly 1.12's mapping (TS-T3 replaces -/// it with `applyNSSearchHighlight` on the rendered string, TS-D9). -/// -/// `DocumentSearch` counts a table's matches in the cells' MARKDOWN (headers -/// first, then rows, left to right), so the block's focused occurrence is an -/// index into those raw counts. Each cell therefore maps it to a cell-local -/// index (`focused − occurrences in earlier cells`, if below this cell's raw -/// count) and highlights the matches in its RENDERED text, the focused one -/// orange — what 1.12's per-cell `searchHighlight` did. Main-thread by -/// convention, like `applyNSSearchHighlight`. -func applyTableSearchHighlight(in textView: NSTextView, layout: TableTextLayout, - headers: [String], rows: [[String]], - term: String, focusedOccurrence: Int?) { - guard let layoutManager = textView.layoutManager, - let storage = textView.textStorage else { return } - layoutManager.removeTemporaryAttribute(.backgroundColor, - forCharacterRange: NSRange(location: 0, length: storage.length)) - guard !term.isEmpty, storage.length == layout.length else { return } - - let needle = term.lowercased() - let string = storage.string as NSString - let yellow = NSColor.systemYellow.withAlphaComponent(0.55) - let orange = NSColor.systemOrange - var earlier = 0 - - func highlight(band: Int?, column: Int, markdown: String) { - let rawCount = countOccurrences(in: markdown, of: term) - defer { earlier += rawCount } - guard let band, band < layout.bandCount else { return } - let local = focusedOccurrence.map { $0 - earlier }.flatMap { (0.. 0 else { return } - let haystack = string.substring(with: cell).lowercased() as NSString - var location = 0 - var index = 0 - while location < haystack.length { - let found = haystack.range(of: needle, options: [], - range: NSRange(location: location, length: haystack.length - location)) - if found.location == NSNotFound { break } - let target = NSIntersectionRange(NSRange(location: cell.location + found.location, length: found.length), - cell) - if target.length > 0 { - layoutManager.addTemporaryAttribute(.backgroundColor, value: index == local ? orange : yellow, - forCharacterRange: target) - } - location = found.location + max(found.length, 1) - index += 1 - } - } - - // 1.12 counted the header cells even when the band is hidden (all blank). - for column in 0.. 0, lowerHaystack.length >= needle.length else { return } - - let yellow = NSColor.systemYellow.withAlphaComponent(0.55) - let orange = NSColor.systemOrange - - var location = 0 - var occurrenceIndex = 0 - while location < lowerHaystack.length { - let searchRange = NSRange(location: location, length: lowerHaystack.length - location) - let found = lowerHaystack.range(of: needle as String, options: [], range: searchRange) - if found.location == NSNotFound { break } - let safeRange = NSIntersectionRange(found, NSRange(location: 0, length: plain.length)) - if safeRange.length > 0 { - let color = (focusedOccurrence == occurrenceIndex) ? orange : yellow - layoutManager.addTemporaryAttribute(.backgroundColor, value: color, forCharacterRange: safeRange) - } - location = found.location + max(found.length, 1) - occurrenceIndex += 1 - } -} +// `applyNSSearchHighlight` (used by every NSTextView-hosted block) lives in +// DocumentSearch.swift, next to the matcher `DocumentSearch` counts with. // MARK: - Text Block View diff --git a/QuickMD/QuickMDTests/SearchTests.swift b/QuickMD/QuickMDTests/SearchTests.swift index 2f95384..77ee67a 100644 --- a/QuickMD/QuickMDTests/SearchTests.swift +++ b/QuickMD/QuickMDTests/SearchTests.swift @@ -1,8 +1,10 @@ import XCTest import SwiftUI +import AppKit /// DocumentSearch (find-in-document) behavior tests: one match entry per -/// occurrence, all block types searched, base highlights only for text blocks. +/// occurrence, all block types searched; tables counted on the string their +/// view shows and highlighted with the text rows' AppKit highlight (TS-D9). final class SearchTests: XCTestCase { private func parse(_ markdown: String) -> [MarkdownBlock] { @@ -44,6 +46,8 @@ final class SearchTests: XCTestCase { let results = DocumentSearch.computeMatches(in: parse(md), term: "needle") XCTAssertEqual(results.matchBlockIds.count, 6, "heading + text + code + blockquote + table header + table cell") + XCTAssertEqual(results.matchBlockIds.filter { $0.hasPrefix("table-") }.count, 2, + results.matchBlockIds.description) } /// A definition list renders into a `.text` block, so its terms and bodies @@ -55,21 +59,239 @@ final class SearchTests: XCTestCase { XCTAssertTrue(results.matchBlockIds[0].hasPrefix("text-"), results.matchBlockIds[0]) } - func testSearchHighlightSetsBackgroundOnMatches() { - var attr = AttributedString("find the word here") - attr.font = .system(size: 14) - let highlighted = searchHighlight(attr, term: "word") - let highlightedRuns = highlighted.runs.filter { $0.backgroundColor != nil } - XCTAssertEqual(highlightedRuns.count, 1) - // The highlighted run must cover exactly the matched substring - let run = highlightedRuns[0] - XCTAssertEqual(String(highlighted[run.range].characters), "word") + // MARK: - Matcher (shared by the AppKit highlight and the table count) + + func testSearchMatchRanges() { + XCTAssertEqual(searchMatchRanges(in: "aaa", term: "a").count, 3) + XCTAssertEqual(searchMatchRanges(in: "AaA", term: "a").count, 3) + XCTAssertEqual(searchMatchRanges(in: "abc", term: ""), []) + XCTAssertEqual(searchMatchRanges(in: "", term: "x"), []) + XCTAssertEqual(searchMatchRanges(in: "aaaa", term: "aa"), + [NSRange(location: 0, length: 2), NSRange(location: 2, length: 2)], "non-overlapping") + XCTAssertEqual(searchMatchRanges(in: "find the Word here", term: "word"), + [NSRange(location: 9, length: 4)]) + } + + func testNSHighlightMarksEveryMatchAndOnlyTheFocusedOneOrange() { + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 300, height: 10)) + textView.textStorage?.setAttributedString(NSAttributedString(string: "one two one two one")) + applyNSSearchHighlight(in: textView, term: "one", focusedOccurrence: 1) + XCTAssertEqual(highlights(in: textView).map(\.range), + [NSRange(location: 0, length: 3), NSRange(location: 8, length: 3), + NSRange(location: 16, length: 3)]) + XCTAssertEqual(highlights(in: textView).map(\.focused), [false, true, false]) + + applyNSSearchHighlight(in: textView, term: "", focusedOccurrence: nil) + XCTAssertTrue(highlights(in: textView).isEmpty, "an empty term clears the previous highlight") + } + + // MARK: - Tables (TS-D9): count on the rendered string == what is highlighted + + private var theme: MarkdownTheme { MarkdownTheme.cached(for: .light) } + + private struct Table { + let block: MarkdownBlock + let headers: [String] + let rows: [[String]] + let alignments: [TextAlignment] + } + + private func tables(in blocks: [MarkdownBlock]) -> [Table] { + blocks.compactMap { block in + guard case .table(let headers, let rows, let alignments) = block.content else { return nil } + return Table(block: block, headers: headers, rows: rows, alignments: alignments) + } + } + + private func onlyTable(_ markdown: String) -> Table { + let found = tables(in: parse(markdown)) + XCTAssertEqual(found.count, 1, "fixture must hold exactly one table") + return found[0] + } + + private func build(_ table: Table, theme: MarkdownTheme? = nil, fontScale: CGFloat = 1) -> TableText { + TableTextBuilder.build(headers: table.headers, rows: table.rows, alignments: table.alignments, + theme: theme ?? self.theme, fontScale: fontScale) + } + + /// A text view set up as `TableTextRepresentable.makeNSView` sets it up, + /// holding the builder's string (the view itself is not compiled into the + /// test target). + private func tableTextView(_ table: TableText) -> NSTextView { + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 10)) + textView.configureForSelfSizing() + textView.installDocumentSelectionLayoutManager() + textView.textStorage?.setAttributedString(table.string) + textView.layoutManager?.ensureLayout(for: textView.textContainer!) + return textView + } + + /// The temporary background runs `applyNSSearchHighlight` left, in order. + private func highlights(in textView: NSTextView) -> [(range: NSRange, focused: Bool)] { + guard let layoutManager = textView.layoutManager, let length = textView.textStorage?.length else { return [] } + var result: [(range: NSRange, focused: Bool)] = [] + var index = 0 + while index < length { + var effective = NSRange(location: 0, length: 0) + let value = layoutManager.temporaryAttribute(.backgroundColor, atCharacterIndex: index, + effectiveRange: &effective) + if let color = value as? NSColor { + result.append((effective, color.isEqual(NSColor.systemOrange))) + } + index = max(NSMaxRange(effective), index + 1) + } + return result + } + + private func count(_ term: String, in markdown: String) -> Int { + DocumentSearch.computeMatches(in: parse(markdown), term: term).matchBlockIds.count } - func testCountOccurrences() { - XCTAssertEqual(countOccurrences(in: "aaa", of: "a"), 3) - XCTAssertEqual(countOccurrences(in: "AaA", of: "a"), 3) - XCTAssertEqual(countOccurrences(in: "abc", of: ""), 0) - XCTAssertEqual(countOccurrences(in: "", of: "x"), 0) + /// The off-main string (`plainString`, no NSTextTable) is the view's string, + /// character for character — including headerless, ragged, header-only, + /// empty cells, links, code and emphasis — and does not depend on the theme + /// or the zoom. + func testTablePlainStringEqualsTheViewString() { + let fixtures = [ + "| Name | Role |\n|---|---|\n| **Ada** | `eng` |\n| [Grace](https://navy.mil) | *Admiral* |", + "| | | |\n|---|---|---|\n| a | b |\n| one | two | three | four |\n| only |", + "| Lonely | Header |\n|---|---|", + "| A | | C |\n|:-|:-:|-:|\n| | x | |\n| ~~gone~~ | | 1 \\| 2 |", + ] + for markdown in fixtures { + let table = onlyTable(markdown) + let viewString = build(table).string.string + for (theme, scale) in [(self.theme, CGFloat(1)), (MarkdownTheme.cached(for: .dark), 1.5), + (self.theme, 0.75)] { + XCTAssertEqual(TableTextBuilder.plainString(headers: table.headers, rows: table.rows, + alignments: table.alignments, + theme: theme, fontScale: scale), + viewString, markdown) + XCTAssertEqual(build(table, theme: theme, fontScale: scale).string.string, viewString, markdown) + } + } + } + + func testTableTermInsideBoldCodeAndLinkTextIsCountedOnceAndHighlighted() { + let markdown = "| A | B | C |\n|---|---|---|\n| **needle** | `needle` | [needle](https://example.com/x) |" + XCTAssertEqual(count("needle", in: markdown), 3) + + let textView = tableTextView(build(onlyTable(markdown))) + applyNSSearchHighlight(in: textView, term: "needle", focusedOccurrence: nil) + let drawn = highlights(in: textView) + XCTAssertEqual(drawn.count, 3) + let string = textView.string as NSString + XCTAssertEqual(drawn.map { string.substring(with: $0.range) }, ["needle", "needle", "needle"]) + } + + /// 1.12 counted these on the cells' markdown and never drew them (and the + /// focused occurrence drifted after such a cell). + func testTableTermOnlyInMarkupOrURLIsNotCounted() { + let markdown = "| Docs | Note |\n|---|---|\n| [guide](https://needle.example.org/page) | a **b** `c` |" + XCTAssertEqual(count("needle", in: markdown), 0, "link URL") + XCTAssertEqual(count("example.org", in: markdown), 0, "link URL") + XCTAssertEqual(count("**", in: markdown), 0, "emphasis markers") + XCTAssertEqual(count("`", in: markdown), 0, "code markers") + XCTAssertEqual(count("guide", in: markdown), 1, "the link text is what shows") + XCTAssertEqual(count("a b c", in: markdown), 1, "rendered text across styles") + } + + /// Every focused index lands on the k-th match in reading order (header + /// band, then rows, left to right), in the right cell, with nothing else + /// orange — headed, headerless and ragged tables. + func testTableFocusedOccurrenceMapsToCellAndRangeInReadingOrder() { + let cases: [(markdown: String, expected: [(band: Int, column: Int)])] = [ + ("| x key | **x** |\n|---|---|\n| `x` and x | [x](https://x.example) |\n| none | [y](https://x.y) |", + [(0, 0), (0, 1), (1, 0), (1, 0), (1, 1)]), + // Headerless: band 0 is the first data row; the hidden header has + // no cells to match. + ("| | |\n|---|---|\n| a x | b |\n| c | **x** x |", + [(0, 0), (1, 1), (1, 1)]), + // Ragged: a short row is padded with empty cells, so the cells + // after it keep their columns. + ("| H1 | H2 | H3 |\n|---|---|---|\n| x |\n| p | q | x |\n| | x |", + [(1, 0), (2, 2), (3, 1)]), + ] + for (markdown, expected) in cases { + let table = onlyTable(markdown) + let built = build(table) + let results = DocumentSearch.computeMatches(in: [table.block], term: "x") + XCTAssertEqual(results.matchBlockIds.count, expected.count, markdown) + + let textView = tableTextView(built) + let string = textView.string as NSString + for focused in 0.. String in + guard let block = blocks.first(where: { $0.id == id }) else { return "?" } + if case .table = block.content { return "table" } + return "text" + } + XCTAssertEqual(kinds, ["text", "table", "table", "table", "text", "text", "text"]) + XCTAssertEqual(Set(ids.filter { $0.hasPrefix("table-") }).count, 1, + "the second table only has the term in a URL") + } + + /// `MarkdownView` passes the measurer's strings; one built from THIS block + /// is used, one left over from another parse under the same positional id + /// is not (the view applies the same rule before installing it). + func testMeasuredTableStringIsUsedOnlyWhenItBelongsToTheBlock() { + let table = onlyTable("| A |\n|---|\n| needle |") + let other = onlyTable("| A |\n|---|\n| needle needle needle |") + let block = [table.block] + + let own = DocumentSearch.computeMatches(in: block, term: "needle", + tables: [table.block.id: build(table)], + theme: theme, fontScale: 1) + XCTAssertEqual(own.matchBlockIds.count, 1) + + let stale = DocumentSearch.computeMatches(in: block, term: "needle", + tables: [table.block.id: build(other)], + theme: theme, fontScale: 1) + XCTAssertEqual(stale.matchBlockIds.count, 1, "a TableText of another block is ignored") + + let otherScale = DocumentSearch.computeMatches(in: block, term: "needle", + tables: [table.block.id: build(other, fontScale: 1.5)], + theme: theme, fontScale: 1.5) + XCTAssertEqual(otherScale.matchBlockIds.count, 1) } } From 73987f94f137b81f3f7293ce43d27cf300eaf1a8 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Thu, 22 Oct 2026 13:43:28 +0200 Subject: [PATCH 03/10] wip(tables): TS-T1 review fixes - full-width columns, theme identity, no rebuild per pass - FullWidthTextTable: whole-point columns that sum to the full width (the remainder 1 pt each to the first columns), so the header band and rules reach the outline (NSTextTable floored every column: up to n-1 pt short). - TableText.Source carries theme name, dark/light and document fonts; matches(theme:fontScale:) rejects an instance built for another look. - The representable builds or adopts a TableText only when its source changes; the background colour is compared by value. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BhzVb6aPQs2FF1bHdkcYys --- QuickMD/QuickMD/TableTextBuilder.swift | 86 +++++++++++++++++++---- QuickMD/QuickMD/Views/TableTextView.swift | 47 +++++++++---- QuickMD/QuickMDTests/TableTextTests.swift | 70 ++++++++++++++++++ 3 files changed, 176 insertions(+), 27 deletions(-) diff --git a/QuickMD/QuickMD/TableTextBuilder.swift b/QuickMD/QuickMD/TableTextBuilder.swift index ad66898..3c5a64c 100644 --- a/QuickMD/QuickMD/TableTextBuilder.swift +++ b/QuickMD/QuickMD/TableTextBuilder.swift @@ -78,25 +78,87 @@ struct TableTextLayout: Equatable { /// A table block's string plus its layout, built once by `TableTextBuilder`. /// -/// `source` is what it was built from. `BlockHeightMeasurer` hands these to the -/// view through `MeasuredBlocks.tables` (keyed by the POSITIONAL block id), so -/// the view checks `matches(...)` before trusting one — a cell that still shows -/// the previous parse's `table-2` must not draw the new parse's `table-2`. +/// `source` is everything it was built from. `BlockHeightMeasurer` hands these +/// to the view through `MeasuredBlocks.tables` (keyed by the POSITIONAL block +/// id), so the view compares `source` before trusting one — a cell that still +/// shows the previous parse's `table-2` must not draw the new parse's `table-2`. struct TableText { + /// The block's cells plus what `MarkdownView.DocumentIdentity` re-parses + /// for: theme (name + light/dark variant — "Auto" is one name for two + /// palettes), document fonts, zoom. An instance built for another theme or + /// font therefore never matches, whatever the parse ordering. struct Source: Equatable { let headers: [String] let rows: [[String]] let alignments: [TextAlignment] let fontScale: CGFloat + let themeName: String + let isDark: Bool + let fonts: DocumentFonts + + init(headers: [String], rows: [[String]], alignments: [TextAlignment], + theme: MarkdownTheme, fontScale: CGFloat) { + self.headers = headers + self.rows = rows + self.alignments = alignments + self.fontScale = fontScale + themeName = theme.name + isDark = theme.isDark + fonts = theme.fonts + } } let string: NSAttributedString let layout: TableTextLayout let source: Source + func matches(headers: [String], rows: [[String]], alignments: [TextAlignment], + theme: MarkdownTheme, fontScale: CGFloat) -> Bool { + source == Source(headers: headers, rows: rows, alignments: alignments, theme: theme, fontScale: fontScale) + } + + /// Content and zoom only. For `MarkdownView.selectableText`, whose cached + /// instance is already pinned to the installed parse (`installedVersion == + /// contentVersion`, and every theme/font change is a new parse); prefer + /// `matches(…theme:…)` everywhere else. func matches(headers: [String], rows: [[String]], alignments: [TextAlignment], fontScale: CGFloat) -> Bool { - source == Source(headers: headers, rows: rows, alignments: alignments, fontScale: fontScale) + source.headers == headers && source.rows == rows && source.alignments == alignments + && source.fontScale == fontScale + } +} + +/// An `NSTextTable` whose columns fill the available width exactly. +/// +/// With `fixedLayoutAlgorithm` TextKit floors every column to whole points, so +/// the cells stop up to `columnCount - 1` pt short of the container (measured: +/// 6–7 pt for 8 columns) and the header band, the rules and the outline no +/// longer meet — 1.12's grid filled the width. This override hands out the +/// columns itself: whole points, `floor(width / n)` each, the remainder 1 pt at +/// a time to the first columns (and a fractional width's last fraction to the +/// last one), so they tile `rect` with no gap. Only x and width of the content +/// rect change; TextKit still decides the row heights and the vertical layout, +/// and the measurer lays out the same subclass, so parity is unaffected. +final class FullWidthTextTable: NSTextTable { + override func rect(for block: NSTextTableBlock, layoutAt startingPoint: NSPoint, in rect: NSRect, + textContainer: NSTextContainer, characterRange charRange: NSRange) -> NSRect { + var layoutRect = super.rect(for: block, layoutAt: startingPoint, in: rect, + textContainer: textContainer, characterRange: charRange) + let columns = max(1, numberOfColumns) + let column = min(max(0, block.startingColumn), columns - 1) + let whole = floor(rect.width) + let base = floor(whole / CGFloat(columns)) + let remainder = Int(whole - base * CGFloat(columns)) + let x = rect.minX + base * CGFloat(column) + CGFloat(min(column, remainder)) + var width = base + (column < remainder ? 1 : 0) + if column == columns - 1 { width += rect.width - whole } + let leading = block.width(for: .margin, edge: .minX) + block.width(for: .border, edge: .minX) + + block.width(for: .padding, edge: .minX) + let trailing = block.width(for: .margin, edge: .maxX) + block.width(for: .border, edge: .maxX) + + block.width(for: .padding, edge: .maxX) + layoutRect.origin.x = x + leading + layoutRect.size.width = BlockLayout.clampedWidth(width - leading - trailing) + return layoutRect } } @@ -108,11 +170,10 @@ struct TableText { /// /// The look is the one 1.12's SwiftUI table DREW, measured, not what its /// modifiers asked for (TS-D2). Metrics come from `BlockLayout.Table`: -/// - columns are equal (`fixedLayoutAlgorithm`, no explicit widths). TextKit -/// floors each column to whole points, so the table can be up to -/// `columnCount - 1` pt narrower than the container and a cell can wrap a -/// word earlier than 1.12 did. Accepted: the measurer lays out the same -/// string, so it is self-consistent, and the dividers land on whole points; +/// - columns are equal, whole points, and fill the width exactly +/// (`FullWidthTextTable`); a cell can wrap a word earlier than 1.12's +/// fractional columns did. The measurer lays out the same string, so it is +/// self-consistent, and the dividers land on whole points; /// - a cell's padding is 1.12's `.padding`; its 1 pt rule is a BORDER on its /// trailing edge (not the last column) and its bottom edge (not the last /// data row — the header always has one, as 1.12's header separator), so @@ -145,7 +206,7 @@ enum TableTextBuilder { if showsHeader { bands.append(headers) } bands.append(contentsOf: rows) - let table = NSTextTable() + let table = FullWidthTextTable() table.numberOfColumns = columnCount table.layoutAlgorithm = .fixedLayoutAlgorithm table.collapsesBorders = false @@ -213,7 +274,8 @@ enum TableTextBuilder { return TableText( string: result, layout: TableTextLayout(columnCount: columnCount, showsHeader: showsHeader, cellRanges: cellRanges), - source: TableText.Source(headers: headers, rows: rows, alignments: alignments, fontScale: fontScale)) + source: TableText.Source(headers: headers, rows: rows, alignments: alignments, + theme: theme, fontScale: fontScale)) } /// The table's height in a laid-out container — the view's diff --git a/QuickMD/QuickMD/Views/TableTextView.swift b/QuickMD/QuickMD/Views/TableTextView.swift index b7303f6..0d226b6 100644 --- a/QuickMD/QuickMD/Views/TableTextView.swift +++ b/QuickMD/QuickMD/Views/TableTextView.swift @@ -31,15 +31,16 @@ struct TableTextBlockView: View { typealias Metrics = BlockLayout.Table var body: some View { - let table = preconverted.flatMap { - $0.matches(headers: headers, rows: rows, alignments: alignments, fontScale: fontScale) ? $0 : nil - } ?? TableTextBuilder.build(headers: headers, rows: rows, alignments: alignments, - theme: theme, fontScale: fontScale) - + // Nothing is built here: `body` runs on every search keystroke, focus + // move and cell reuse. The representable builds (or adopts + // `preconverted`) only when the text view's installed source differs. TableTextRepresentable( - table: table, headers: headers, rows: rows, + alignments: alignments, + theme: theme, + fontScale: fontScale, + preconverted: preconverted, background: theme.backgroundColor, searchTerm: searchText, focusedOccurrence: focusedOccurrence, @@ -54,10 +55,14 @@ struct TableTextBlockView: View { // MARK: - NSTextView Wrapper private struct TableTextRepresentable: NSViewRepresentable { - let table: TableText - /// The cells' markdown, for the 1.12 search mapping (`applyTableSearchHighlight`). + /// The cells' markdown — what the table is built from, and the 1.12 + /// search mapping's input (`applyTableSearchHighlight`). let headers: [String] let rows: [[String]] + let alignments: [TextAlignment] + let theme: MarkdownTheme + let fontScale: CGFloat + let preconverted: TableText? let background: Color let searchTerm: String let focusedOccurrence: Int? @@ -82,24 +87,33 @@ private struct TableTextRepresentable: NSViewRepresentable { func updateNSView(_ textView: TableTextView, context: Context) { context.coordinator.onLink = onLink - let fill = NSColor(background) - if textView.tableBackground != fill { - textView.tableBackground = fill + // By value: `NSColor(Color)` is a fresh object per call. + if textView.tableBackgroundSource != background { + textView.tableBackgroundSource = background + textView.tableBackground = NSColor(background) textView.needsDisplay = true } - let textChanged = (textView.cachedAttributed !== table.string) + // At most one build per source per view: the measurer's instance when + // it was built from exactly this source, otherwise our own. + let source = TableText.Source(headers: headers, rows: rows, alignments: alignments, + theme: theme, fontScale: fontScale) + let textChanged = textView.tableSource != source if textChanged { + let table = preconverted.flatMap { $0.source == source ? $0 : nil } + ?? TableTextBuilder.build(headers: headers, rows: rows, alignments: alignments, + theme: theme, fontScale: fontScale) textView.textStorage?.setAttributedString(table.string) textView.cachedAttributed = table.string textView.tableLayout = table.layout + textView.tableSource = table.source textView.invalidateMeasurement() } let searchChanged = (textView.lastSearchTerm != searchTerm) || (textView.lastFocusedOccurrence != focusedOccurrence) - if textChanged || searchChanged { - applyTableSearchHighlight(in: textView, layout: table.layout, headers: headers, rows: rows, + if textChanged || searchChanged, let layout = textView.tableLayout { + applyTableSearchHighlight(in: textView, layout: layout, headers: headers, rows: rows, term: searchTerm, focusedOccurrence: focusedOccurrence) textView.lastSearchTerm = searchTerm textView.lastFocusedOccurrence = focusedOccurrence @@ -124,9 +138,12 @@ private struct TableTextRepresentable: NSViewRepresentable { final class TableTextView: SelfSizingTextView { /// The cell layout of the installed string; set with it. var tableLayout: TableTextLayout? + /// What the installed string was built from — the representable's rebuild key. + var tableSource: TableText.Source? /// The table's own background (1.12 `.background(theme.backgroundColor)`), - /// painted inside the rounded clip. + /// painted inside the rounded clip, and the theme colour it came from. var tableBackground: NSColor = .clear + var tableBackgroundSource: Color? /// Everything — background, header band, rules, selection, search /// highlights, text — is clipped to the rounded rect (1.12 `.clipShape`). diff --git a/QuickMD/QuickMDTests/TableTextTests.swift b/QuickMD/QuickMDTests/TableTextTests.swift index 48625e3..db239be 100644 --- a/QuickMD/QuickMDTests/TableTextTests.swift +++ b/QuickMD/QuickMDTests/TableTextTests.swift @@ -285,6 +285,76 @@ final class TableTextTests: XCTestCase { TableTextBuilder.height(of: built.string, width: 1000)) } + /// Review fix (2026-10-22, seen on screen with 8 columns at 1920 pt): plain + /// `NSTextTable` floors every column, so the cells stopped up to + /// `columns - 1` pt short of the outline. `FullWidthTextTable` tiles the + /// width exactly — first column at 0, last column at the right edge, no + /// gaps — and the measurer still equals the laid-out view. + func testColumnsSpanTheFullWidthAndParityHolds() { + let wrapping = "a longer cell text that wraps several times in the narrower columns" + for columns in 1...9 { + let headers = (0.. Date: Thu, 22 Oct 2026 13:47:36 +0200 Subject: [PATCH 04/10] wip(tables): TS-T2 select and copy inside tables Tables are no longer atomic in the document selection: the controller asks the row's text view for the character under the pointer (cell-confined in tables), multi-click selects a word or the cell, and copy builds the covered text of one cell, or TSV of the covered cells (partial first/last cell), or the 1.12 TSV for a whole table. Per-row selection rules moved to a pure SelectionRows.swift so they are unit-tested; Source Edit hint lands on the table's line. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BhzVb6aPQs2FF1bHdkcYys --- QuickMD/QuickMD.xcodeproj/project.pbxproj | 10 + QuickMD/QuickMD/DocumentSelection.swift | 31 +- QuickMD/QuickMD/MarkdownView.swift | 29 +- QuickMD/QuickMD/SelectionRows.swift | 281 ++++++++++++ QuickMD/QuickMD/Views/VirtualBlockList.swift | 162 ++----- .../QuickMDTests/TableSelectionTests.swift | 432 ++++++++++++++++++ 6 files changed, 807 insertions(+), 138 deletions(-) create mode 100644 QuickMD/QuickMD/SelectionRows.swift create mode 100644 QuickMD/QuickMDTests/TableSelectionTests.swift diff --git a/QuickMD/QuickMD.xcodeproj/project.pbxproj b/QuickMD/QuickMD.xcodeproj/project.pbxproj index c66d318..5bc0974 100644 --- a/QuickMD/QuickMD.xcodeproj/project.pbxproj +++ b/QuickMD/QuickMD.xcodeproj/project.pbxproj @@ -29,6 +29,9 @@ A1B2C3D4010001B1 /* TableTextBuilder.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001B0 /* TableTextBuilder.swift */; }; A1B2C3D4010001B2 /* TableTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001B1 /* TableTextView.swift */; }; A1B2C3D4010001B3 /* TableTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001B2 /* TableTextTests.swift */; }; + A1B2C3D4010001C0 /* SelectionRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001C0 /* SelectionRows.swift */; }; + A1B2C3D4010001C1 /* SelectionRows.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001C0 /* SelectionRows.swift */; }; + A1B2C3D4010001C2 /* TableSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001C1 /* TableSelectionTests.swift */; }; A1B2C3D40100000E /* ImageBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000012 /* ImageBlockView.swift */; }; A1B2C3D40100000F /* BlockquoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000013 /* BlockquoteView.swift */; }; A1B2C3D401000010 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000014 /* SearchBar.swift */; }; @@ -210,6 +213,8 @@ A1B2C3D4000001B0 /* TableTextBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableTextBuilder.swift; sourceTree = ""; }; A1B2C3D4000001B1 /* TableTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableTextView.swift; sourceTree = ""; }; A1B2C3D4000001B2 /* TableTextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableTextTests.swift; sourceTree = ""; }; + A1B2C3D4000001C0 /* SelectionRows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionRows.swift; sourceTree = ""; }; + A1B2C3D4000001C1 /* TableSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableSelectionTests.swift; sourceTree = ""; }; A1B2C3D400000012 /* ImageBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageBlockView.swift; sourceTree = ""; }; A1B2C3D400000013 /* BlockquoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockquoteView.swift; sourceTree = ""; }; A1B2C3D400000014 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; @@ -319,6 +324,7 @@ A1B2C3D4000000E1 /* HTMLImageTests.swift */, A1B2C3D4000000F0 /* HeadingLayoutTests.swift */, A1B2C3D4000001B2 /* TableTextTests.swift */, + A1B2C3D4000001C1 /* TableSelectionTests.swift */, A1B2C3D4000000C1 /* DocumentSelectionTests.swift */, A1B2C3D400000111 /* ExternalEditorLineTests.swift */, A1B2C3D400000122 /* DocumentFileFormatTests.swift */, @@ -350,6 +356,7 @@ A1B2C3D400000080 /* DataImageURI.swift */, A1B2C3D400000081 /* ImageLoader.swift */, A1B2C3D4000000C0 /* DocumentSelection.swift */, + A1B2C3D4000001C0 /* SelectionRows.swift */, A1B2C3D400000110 /* DocumentReadingPosition.swift */, A1B2C3D400000120 /* DocumentFileFormat.swift */, A1B2C3D400000121 /* DocumentFileWriter.swift */, @@ -643,6 +650,7 @@ A1B2C3D401000080 /* DataImageURI.swift in Sources */, A1B2C3D401000082 /* ImageLoader.swift in Sources */, A1B2C3D4010000C0 /* DocumentSelection.swift in Sources */, + A1B2C3D4010001C0 /* SelectionRows.swift in Sources */, A1B2C3D401000110 /* DocumentReadingPosition.swift in Sources */, A1B2C3D401000120 /* DocumentFileFormat.swift in Sources */, A1B2C3D401000122 /* DocumentFileWriter.swift in Sources */, @@ -692,6 +700,7 @@ A1B2C3D401000084 /* ImageLoadingTests.swift in Sources */, A1B2C3D4010000E2 /* HTMLImageTests.swift in Sources */, A1B2C3D4010000C1 /* DocumentSelection.swift in Sources (Tests) */, + A1B2C3D4010001C1 /* SelectionRows.swift in Sources (Tests) */, A1B2C3D4010000C2 /* DocumentSelectionTests.swift in Sources */, A1B2C3D401000111 /* DocumentReadingPosition.swift in Sources (Tests) */, A1B2C3D401000112 /* ExternalEditorLineTests.swift in Sources */, @@ -715,6 +724,7 @@ A1B2C3D401000182 /* SourceEditLandingTests.swift in Sources */, A1B2C3D4010000F0 /* HeadingLayoutTests.swift in Sources */, A1B2C3D4010001B3 /* TableTextTests.swift in Sources */, + A1B2C3D4010001C2 /* TableSelectionTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/QuickMD/QuickMD/DocumentSelection.swift b/QuickMD/QuickMD/DocumentSelection.swift index 093cfc2..7f491da 100644 --- a/QuickMD/QuickMD/DocumentSelection.swift +++ b/QuickMD/QuickMD/DocumentSelection.swift @@ -21,8 +21,9 @@ import AppKit /// `offset` is a UTF-16 offset into the row's SELECTABLE STRING — exactly the /// string the row's text view displays (`NSString` indexing, the same unit /// TextKit uses, so a point maps to a glyph without conversion). For an atomic -/// row (table, image, display math, diagram) the string is a single unit: -/// `0` = before the row, `1` = after it. +/// row (image, display math, diagram — `SelectionRows.isAtomic`) the string is +/// a single unit: `0` = before the row, `1` = after it. A table is a text row +/// since v1.13 (TS-D5): its string is its cells (`TableTextLayout`). struct SelectionPoint: Comparable, Hashable { var row: Int var offset: Int @@ -103,7 +104,7 @@ struct DocumentSelection: Equatable { /// /// When the drag started INSIDE an atomic row the row is selected whole as /// soon as the pointer leaves it — in whichever direction — and a drag that - /// stays inside it selects nothing (there is no per-cell selection; S-D3). + /// stays inside it selects nothing (an image or a diagram has no text; S-D3). /// So the anchor's offset is not where the press happened but the edge /// that puts the row inside the selection: before it (0) when the focus is /// below, after it (1) when the focus is above. @@ -287,9 +288,15 @@ enum SelectionPiece { /// A code block's covered substring — VERBATIM. Blank lines inside code are /// content, so none of the paragraph clean-up applies. case code(NSAttributedString) - /// A table, as tab-separated values of the cells' RENDERED text (no `**`). - /// `headers` is nil for a headerless table (the view shows no header band). + /// A WHOLE table, as tab-separated values of the cells' RENDERED text (no + /// `**`) — exactly 1.12's copy. `headers` is nil for a headerless table + /// (the view shows no header band). case table(headers: [String]?, rows: [[String]]) + /// PART of a table spanning several cells (v1.13 TS-D8): the covered part + /// of each covered cell, per band — tab-separated, one line per band. The + /// first and last band hold only the cells from / up to where the + /// selection starts / ends (`SelectionRows.tablePiece`). + case tableCells(bands: [[String]]) /// An image or SVG block: its alt text, or nothing when it has none. case image(alt: String) /// A display-math block: `$$\n\n$$`. @@ -342,6 +349,9 @@ enum DocumentCopyBuilder { return replacingAttachments(in: code) case .table(let headers, let rows): return NSAttributedString(string: tsv(headers: headers, rows: rows)) + case .tableCells(let bands): + return NSAttributedString(string: bands.map { $0.map(tsvField).joined(separator: "\t") } + .joined(separator: "\n")) case .image(let alt): return NSAttributedString(string: alt.trimmingCharacters(in: .whitespacesAndNewlines)) case .displayMath(let latex): @@ -357,9 +367,7 @@ enum DocumentCopyBuilder { let columnCount = max(headers?.count ?? 0, rows.map(\.count).max() ?? 0) func line(_ cells: [String]) -> String { (0.. String { + cell.components(separatedBy: CharacterSet(charactersIn: "\t\n\r\u{2028}\u{2029}")) + .joined(separator: " ") + } + /// Inline-math attachments become `$$` in the font of the text /// before them; any other attachment is dropped (RTF cannot carry it and a /// U+FFFC in plain text is noise). diff --git a/QuickMD/QuickMD/MarkdownView.swift b/QuickMD/QuickMD/MarkdownView.swift index 9028da7..f0783d4 100644 --- a/QuickMD/QuickMD/MarkdownView.swift +++ b/QuickMD/QuickMD/MarkdownView.swift @@ -248,6 +248,9 @@ struct MarkdownView: View { selectableText: { block, version in selectableText(for: block, installedVersion: version) }, + selectableTable: { block, version in + selectableTable(for: block, installedVersion: version) + }, onCopySelection: { output in copySelectionToClipboard(output) }, focusRequest: documentFocusRequest, // Stays mounted under the editor (its anchor and offset @@ -1186,20 +1189,28 @@ struct MarkdownView: View { return TextBlockView.makeNSAttributedString( from: MarkdownRenderer(theme: theme, fontScale: scale).renderHeader(title, level: level), hasInlineMath: false, theme: theme, fontScale: scale) - case .table(let headers, let rows, let alignments): - // The measurer's instance when it belongs to this parse; otherwise - // the same builder `TableTextBlockView` falls back to. - if installedVersion == contentVersion, let table = measured.tables[block.id], - table.matches(headers: headers, rows: rows, alignments: alignments, fontScale: scale) { - return table.string - } - return TableTextBuilder.build(headers: headers, rows: rows, alignments: alignments, - theme: theme, fontScale: scale).string + case .table: + return selectableTable(for: block, installedVersion: installedVersion)?.string case .image, .svgImage, .mathBlock, .mermaidDiagram: return nil } } + /// A `.table` block's selectable string with its cell layout (TS-D4 / + /// TS-D8): the measurer's instance when it belongs to this parse; + /// otherwise the same builder `TableTextBlockView` falls back to. Nil for + /// any other block. + private func selectableTable(for block: MarkdownBlock, installedVersion: Int) -> TableText? { + guard case .table(let headers, let rows, let alignments) = block.content else { return nil } + let scale = CGFloat(renderedFontScale) + if installedVersion == contentVersion, let table = measured.tables[block.id], + table.matches(headers: headers, rows: rows, alignments: alignments, fontScale: scale) { + return table + } + return TableTextBuilder.build(headers: headers, rows: rows, alignments: alignments, + theme: theme, fontScale: scale) + } + /// ⌘+ / ⌘− / ⌘0 (menu, shortcuts, the zoom pill). Announces the resulting /// level in the toast so the user always knows where the ladder stands; /// the top-right zoom pill keeps showing it while != 100%. diff --git a/QuickMD/QuickMD/SelectionRows.swift b/QuickMD/QuickMD/SelectionRows.swift new file mode 100644 index 0000000..0bc3b8b --- /dev/null +++ b/QuickMD/QuickMD/SelectionRows.swift @@ -0,0 +1,281 @@ +import Foundation +import AppKit + +// MARK: - What the document selection covers, row by row +// +// `SelectionController` (VirtualBlockList.swift) owns the selection, the views +// and the gestures; what a selection MEANS for each row — the pieces a copy is +// built from (S-D8, v1.13 TS-D8) and Source Edit's hint (S-D14b, TS-D10) — is +// decided here, from the blocks and their selectable strings alone, so it works +// for rows that were never materialized and is unit-tested directly. Compiled +// into the app AND the test target (no view code). + +enum SelectionRows { + + /// Rows selected whole, with no text of their own to select (S-D3). A + /// table is NOT one since v1.13 (TS-D5): it is one text view over its + /// cells, its selectable string is `TableTextBuilder`'s. + static func isAtomic(_ block: MarkdownBlock) -> Bool { + switch block.content { + case .image, .svgImage, .mathBlock, .mermaidDiagram: return true + case .text, .codeBlock, .blockquote, .alert, .heading, .table: return false + } + } + + // MARK: Copy (S-D8 / TS-D8) + + /// The copy pieces of `selection`, in document order. + /// + /// `text` answers the selectable string of a text row (S-D2) and `table` + /// a table row's string plus its cell layout — the SAME string the row's + /// view shows, so the selection's offsets index the right characters. + static func pieces(of selection: DocumentSelection, blocks: [MarkdownBlock], + text: (MarkdownBlock) -> NSAttributedString?, + table: (MarkdownBlock) -> TableText?) -> [SelectionPiece] { + guard let span = selection.rowSpan else { return [] } + var pieces: [SelectionPiece] = [] + pieces.reserveCapacity(span.count) + // A whole table copies as 1.12 did: the cells' rendered characters + // (no `**`). The characters do not depend on the theme, so any will do. + var cellRenderer: MarkdownRenderer? + func renderedCell(_ markdown: String) -> String { + let renderer = cellRenderer ?? MarkdownRenderer(theme: MarkdownTheme.cached(for: .light)) + cellRenderer = renderer + return String(renderer.renderInline(markdown).characters) + } + + for row in span where row < blocks.count { + let block = blocks[row] + switch block.content { + case .table(let headers, let rows, _): + guard let built = table(block), + let covered = selection.range(inRow: row, rowLength: built.string.length), + let piece = tablePiece(built, covered: covered, whole: { + // Same rule as `TableTextLayout.showsHeader`: an all-blank + // header row is not shown, so it is not copied either. + let showsHeader = headers.contains { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + return .table(headers: showsHeader ? headers.map(renderedCell) : nil, + rows: rows.map { $0.map(renderedCell) }) + }) else { continue } + pieces.append(piece) + case .image(_, let alt, _): + guard selection.range(inRow: row, rowLength: 1) != nil else { continue } + pieces.append(.image(alt: alt)) + case .svgImage: + guard selection.range(inRow: row, rowLength: 1) != nil else { continue } + pieces.append(.image(alt: "")) + case .mathBlock(let latex): + guard selection.range(inRow: row, rowLength: 1) != nil else { continue } + pieces.append(.displayMath(latex: latex)) + case .mermaidDiagram(let source): + guard selection.range(inRow: row, rowLength: 1) != nil else { continue } + pieces.append(.mermaid(source: source)) + case .codeBlock, .text, .blockquote, .alert, .heading: + guard let string = text(block), + let covered = selection.range(inRow: row, rowLength: string.length) else { continue } + let part = string.attributedSubstring(from: covered) + if case .codeBlock = block.content { + pieces.append(.code(part)) + } else { + pieces.append(.text(part)) + } + } + } + return pieces + } + + /// The piece for the `covered` part of a table's string (TS-D8): + /// - every cell's text covered (a drag through the table, ⌘A) → `whole()`, + /// which is 1.12's TSV; + /// - inside ONE cell → that text, as a text row copies (fonts, links), but + /// without the cell's `NSTextTableBlock` — pasted RTF must not turn a + /// word into a one-cell table; + /// - across cells → `.tableCells`: the covered cells in reading order, a + /// partial first / last cell with only its covered part. + /// + /// A cell counts as covered when the selection covers any of it, except a + /// selection that only STARTS on a cell's terminating "\n" (it began after + /// that cell's text: a press right of the text, or in an empty cell). + /// That keeps start and end symmetric — the empty cell a drag starts or + /// ends in is not copied, empty cells it crosses are (as empty fields). + /// Cells come from `TableTextLayout`, never from re-reading the string. + static func tablePiece(_ table: TableText, covered: NSRange, + whole: () -> SelectionPiece) -> SelectionPiece? { + let layout = table.layout + let end = NSMaxRange(covered) + guard covered.length > 0, covered.location >= 0, layout.length == table.string.length, + end <= layout.length, let lastCell = layout.cellRanges.last?.last else { return nil } + if covered.location == 0 && end >= NSMaxRange(lastCell) { return whole() } + + var start = covered.location + if let cell = layout.cell(containing: start), + start == NSMaxRange(layout.cellRanges[cell.band][cell.column]) { + start += 1 + } + guard start < end, let first = layout.cell(containing: start), + let last = layout.cell(containing: end - 1) else { return nil } + func coveredText(_ band: Int, _ column: Int) -> NSRange { + let cell = layout.cellRanges[band][column] + let lower = max(cell.location, start) + let upper = min(NSMaxRange(cell), end) + return NSRange(location: lower, length: max(0, upper - lower)) + } + + if first.band == last.band && first.column == last.column { + let range = coveredText(first.band, first.column) + guard range.length > 0 else { return nil } + return .text(withoutTextBlocks(table.string.attributedSubstring(from: range))) + } + let string = table.string.string as NSString + var bands: [[String]] = [] + for band in first.band...last.band { + let lower = band == first.band ? first.column : 0 + let upper = band == last.band ? last.column : layout.columnCount - 1 + bands.append((lower...upper).map { string.substring(with: coveredText(band, $0)) }) + } + return .tableCells(bands: bands) + } + + /// `text` with every paragraph style's text blocks removed (alignment, + /// spacing and the rest kept). + private static func withoutTextBlocks(_ text: NSAttributedString) -> NSAttributedString { + let result = NSMutableAttributedString(attributedString: text) + result.enumerateAttribute(.paragraphStyle, in: NSRange(location: 0, length: result.length)) { value, range, _ in + guard let style = value as? NSParagraphStyle, !style.textBlocks.isEmpty, + let plain = style.mutableCopy() as? NSMutableParagraphStyle else { return } + plain.textBlocks = [] + result.addAttribute(.paragraphStyle, value: plain, range: range) + } + return result + } + + // MARK: Source Edit hint (S-D14b / TS-D10) + + /// Source Edit's carry-over: the selection's plain text (`output`, as ⌘C + /// copies it) plus up to `contextLength` rendered characters before it in + /// its first row and after it in its last. Nil — and NOTHING built — when + /// the selection spans more than `maxRows` rows, touches an atomic row (an + /// image or a diagram cannot match the source literally) or covers more + /// than `maxLength` UTF-16 units: the checks only measure the rows' + /// selectable strings, `output` is asked after them. + /// + /// A table row takes part like a text row (TS-D10), so the hint's lines + /// start at the table's `sourceLine`: a word selected in a cell is found + /// in the table's source line, and a cross-cell selection (TSV, which + /// never matches literally) still opens the editor at the table. Its + /// context stops at the CELL's edges — the "\n" between two rendered + /// cells is a `|` in the source and would end the agreement anyway. + static func sourceEditHint(for selection: DocumentSelection?, blocks: [MarkdownBlock], + maxRows: Int, maxLength: Int, contextLength: Int, + text: (MarkdownBlock) -> NSAttributedString?, + table: (MarkdownBlock) -> TableText?, + output: () -> String?) + -> (text: String, before: String, after: String, rows: ClosedRange)? { + guard let selection, let span = selection.rowSpan, span.count <= maxRows, + span.upperBound < blocks.count else { return nil } + var covered = 0 + var before = "" + var after = "" + for row in span { + let block = blocks[row] + let string: NSAttributedString + var layout: TableTextLayout? + switch block.content { + case .codeBlock, .text, .blockquote, .alert, .heading: + guard let rowText = text(block) else { return nil } + string = rowText + case .table: + guard let built = table(block) else { return nil } + string = built.string + layout = built.layout + case .image, .svgImage, .mathBlock, .mermaidDiagram: + return nil + } + guard let range = selection.range(inRow: row, rowLength: string.length) else { continue } + covered += range.length + guard covered <= maxLength else { return nil } + let ns = string.string as NSString + if row == span.lowerBound { + let floor = layout.flatMap { cellText(at: range.location, in: $0) }?.location ?? 0 + let start = max(floor, range.location - contextLength) + before = ns.substring(with: NSRange(location: start, length: max(0, range.location - start))) + } + if row == span.upperBound { + let end = NSMaxRange(range) + let ceiling = layout.flatMap { cellText(at: end - 1, in: $0) }.map(NSMaxRange) ?? ns.length + let stop = min(ceiling, end + contextLength) + after = ns.substring(with: NSRange(location: end, length: max(0, stop - end))) + } + } + guard covered > 0, let plain = output() else { return nil } + return (plain, before, after, span) + } + + /// The text range (without its "\n") of the cell holding `index`. + private static func cellText(at index: Int, in layout: TableTextLayout) -> NSRange? { + layout.cell(containing: index).map { layout.cellRanges[$0.band][$0.column] } + } +} + +// MARK: - Points in a text view (S-D6 / TS-D6 / TS-D7) + +extension NSTextView { + /// The character whose glyph is under `local` (view coordinates), or nil + /// past the end. With `requireInside`, only when the point is ON the + /// glyph — a click in the empty space after a link must not open it. + func selectionGlyphCharacterIndex(at local: NSPoint, requireInside: Bool) -> Int? { + guard let layoutManager, let textContainer, let storage = textStorage, + storage.length > 0 else { return nil } + let origin = textContainerOrigin + let point = NSPoint(x: local.x - origin.x, y: local.y - origin.y) + var fraction: CGFloat = 0 + let glyph = layoutManager.glyphIndex(for: point, in: textContainer, + fractionOfDistanceThroughGlyph: &fraction) + guard glyph < layoutManager.numberOfGlyphs else { return nil } + if requireInside { + let box = layoutManager.boundingRect(forGlyphRange: NSRange(location: glyph, length: 1), + in: textContainer) + guard box.contains(point) else { return nil } + } + let index = layoutManager.characterIndexForGlyph(at: glyph) + return index < storage.length ? index : nil + } + + /// The character a double- or triple-click at `local` is about (TS-D7) — + /// what `selectionRange(forProposedRange:granularity:)` expands to a word + /// or a paragraph (in a table: the cell). + /// + /// `insertion` is the row's own hit-test answer for the same press (the + /// TS-D6 hook, `SelfSizingTextView.selectionCharacterIndex`). The glyph + /// under the point is used when it agrees with it (it is one side of that + /// insertion point) — always the case on a text line. When they disagree, + /// TextKit's "nearest glyph" went somewhere the hook would not: in a + /// table cell's padding, or below a short cell in a tall band, that is a + /// NEIGHBOURING cell, so the hook wins. + /// + /// `inTableCell`: a press in the empty part of a cell (right of a short + /// text — most of a wide cell) lands on the cell's terminating "\n", + /// which a word selection would select alone. It is moved back onto the + /// cell's last character, so the double-click is about the cell's last + /// word. (Text rows keep TextKit's answer, as before v1.13.) An empty + /// cell has nothing else: its "\n" stays (the triple-click still selects + /// the cell). + func multiClickCharacterIndex(at local: NSPoint, insertion: Int, inTableCell: Bool) -> Int? { + guard let storage = textStorage, storage.length > 0 else { return nil } + let string = storage.string as NSString + var index: Int + if let glyph = selectionGlyphCharacterIndex(at: local, requireInside: false), + glyph == insertion || glyph == insertion - 1 { + index = glyph + } else { + index = min(max(insertion, 0), string.length - 1) + } + guard inTableCell else { return index } + func isLineBreak(_ index: Int) -> Bool { + let unit = string.character(at: index) + return unit == 0x0A || unit == 0x0D || unit == 0x2028 || unit == 0x2029 + } + if index > 0, isLineBreak(index), !isLineBreak(index - 1) { index -= 1 } + return index + } +} diff --git a/QuickMD/QuickMD/Views/VirtualBlockList.swift b/QuickMD/QuickMD/Views/VirtualBlockList.swift index ac6c092..fd7a808 100644 --- a/QuickMD/QuickMD/Views/VirtualBlockList.swift +++ b/QuickMD/QuickMD/Views/VirtualBlockList.swift @@ -172,6 +172,10 @@ struct VirtualBlockList: NSViewRepresentable { /// the block comes from, so the parent can tell whether its per-id caches /// belong to them (block ids are positional and are reused by every parse). let selectableText: (_ block: MarkdownBlock, _ contentVersion: Int) -> NSAttributedString? + /// A `.table` row's selectable string WITH its cell layout (v1.13 TS-D8): + /// copy and the Source Edit hint map a covered range to cells through + /// `TableTextLayout`. Same caching rule as `selectableText`. + let selectableTable: (_ block: MarkdownBlock, _ contentVersion: Int) -> TableText? /// ⌘C / context-menu Copy / auto-copy (S-D10) produced this output. The parent writes it to the /// pasteboard and shows the toast (`MarkdownView.copySelectionToClipboard`). let onCopySelection: (DocumentCopyOutput) -> Void @@ -284,6 +288,7 @@ struct VirtualBlockList: NSViewRepresentable { coordinator.onHeightReport = onHeightReport coordinator.setContentWidth = { width in contentWidth = width } coordinator.selection.selectableText = selectableText + coordinator.selection.selectableTable = selectableTable coordinator.selection.onCopy = onCopySelection coordinator.setDocumentCovered(isCovered) // Covered (graphic preview, Source Edit): out of the accessibility @@ -1598,6 +1603,8 @@ final class SelectionController: NSObject { /// See `VirtualBlockList.selectableText`. var selectableText: (MarkdownBlock, Int) -> NSAttributedString? = { _, _ in nil } + /// See `VirtualBlockList.selectableTable`. + var selectableTable: (MarkdownBlock, Int) -> TableText? = { _, _ in nil } /// See `VirtualBlockList.onCopySelection`. var onCopy: (DocumentCopyOutput) -> Void = { _ in } @@ -1704,12 +1711,12 @@ final class SelectionController: NSObject { /// the row has no text view to draw the selection in — atomic rows /// (S-D3), and a text-kind row whose text view has not registered (yet). /// Since S-D7 every text kind, headings included, draws through an - /// NSTextView, so a materialized text row drops the tint as soon as its - /// view registers (`register`). + /// NSTextView — and since v1.13 tables too (TS-D10) — so a materialized + /// text row drops the tint as soon as its view registers (`register`). func wantsTint(row: Int) -> Bool { guard let selection, !selection.isEmpty, row >= 0, row < blocks.count else { return false } let block = blocks[row] - if Self.isAtomic(block) { return selection.range(inRow: row, rowLength: 1) != nil } + if SelectionRows.isAtomic(block) { return selection.range(inRow: row, rowLength: 1) != nil } if let view = registry[block.id]?.view, view.selectionController === self { return false } return selection.range(inRow: row, rowLength: rowLength(row)) != nil } @@ -1768,19 +1775,12 @@ final class SelectionController: NSObject { // MARK: - Rows - /// Rows selected whole, with no text of their own to select (S-D3). - static func isAtomic(_ block: MarkdownBlock) -> Bool { - switch block.content { - case .table, .image, .svgImage, .mathBlock, .mermaidDiagram: return true - case .text, .codeBlock, .blockquote, .alert, .heading: return false - } - } - - /// UTF-16 length of `row`'s selectable string (1 for an atomic row). + /// UTF-16 length of `row`'s selectable string (1 for an atomic row — + /// `SelectionRows.isAtomic`). private func rowLength(_ row: Int) -> Int { guard row >= 0, row < blocks.count else { return 0 } let block = blocks[row] - if Self.isAtomic(block) { return 1 } + if SelectionRows.isAtomic(block) { return 1 } if let cached = lengthCache[row] { return cached } let length = selectableText(block, contentVersion)?.length ?? 0 lengthCache[row] = length @@ -1845,92 +1845,27 @@ final class SelectionController: NSObject { Self.speech?.stopSpeaking(at: .immediate) } - /// Source Edit's carry-over (S-D14b): the selection's plain text (as ⌘C - /// copies it) plus up to `contextLength` rendered characters before it in - /// its first row and after it in its last. Nil — and NOTHING built — when - /// the selection spans more than `maxRows` rows, touches a row without - /// selectable text (a table or diagram cannot match the source literally) - /// or covers more than `maxLength` UTF-16 units: the checks only measure - /// the rows' selectable strings, the output is built after them. + /// Source Edit's carry-over (S-D14b): see `SelectionRows.sourceEditHint` + /// (a range inside a table now gives a hint too — TS-D10). func sourceEditHint(maxRows: Int, maxLength: Int, contextLength: Int) -> (text: String, before: String, after: String, rows: ClosedRange)? { - guard let selection, let span = selection.rowSpan, span.count <= maxRows, - span.upperBound < blocks.count else { return nil } - var covered = 0 - var before = "" - var after = "" - for row in span { - let block = blocks[row] - switch block.content { - case .codeBlock, .text, .blockquote, .alert, .heading: break - case .table, .image, .svgImage, .mathBlock, .mermaidDiagram: return nil - } - guard let string = selectableText(block, contentVersion) else { return nil } - guard let range = selection.range(inRow: row, rowLength: string.length) else { continue } - covered += range.length - guard covered <= maxLength else { return nil } - let ns = string.string as NSString - if row == span.lowerBound { - let start = max(0, range.location - contextLength) - before = ns.substring(with: NSRange(location: start, length: range.location - start)) - } - if row == span.upperBound { - let end = min(ns.length, NSMaxRange(range) + contextLength) - after = ns.substring(with: NSRange(location: NSMaxRange(range), length: end - NSMaxRange(range))) - } - } - guard covered > 0, let text = selectionOutput()?.plain else { return nil } - return (text, before, after, span) + let version = contentVersion + return SelectionRows.sourceEditHint( + for: selection, blocks: blocks, maxRows: maxRows, maxLength: maxLength, contextLength: contextLength, + text: { selectableText($0, version) }, + table: { selectableTable($0, version) }, + output: { selectionOutput()?.plain }) } /// Plain text + RTF of the selection — the ONE builder behind ⌘C, the /// context menu, Services and Speech. Nil when nothing is selected. func selectionOutput() -> DocumentCopyOutput? { - guard let selection, let span = selection.rowSpan else { return nil } - var pieces: [SelectionPiece] = [] - pieces.reserveCapacity(span.count) - // Table cells: their rendered characters (no `**`). The characters do - // not depend on the theme, so any theme will do. - var cellRenderer: MarkdownRenderer? - func renderedCell(_ markdown: String) -> String { - let renderer = cellRenderer ?? MarkdownRenderer(theme: MarkdownTheme.cached(for: .light)) - cellRenderer = renderer - return String(renderer.renderInline(markdown).characters) - } - - for row in span where row < blocks.count { - let block = blocks[row] - switch block.content { - case .table(let headers, let rows, _): - guard selection.range(inRow: row, rowLength: 1) != nil else { continue } - // Same rule as `TableTextLayout.showsHeader`: an all-blank header - // row is not shown, so it is not copied either. - let showsHeader = headers.contains { !$0.trimmingCharacters(in: .whitespaces).isEmpty } - pieces.append(.table(headers: showsHeader ? headers.map(renderedCell) : nil, - rows: rows.map { $0.map(renderedCell) })) - case .image(_, let alt, _): - guard selection.range(inRow: row, rowLength: 1) != nil else { continue } - pieces.append(.image(alt: alt)) - case .svgImage: - guard selection.range(inRow: row, rowLength: 1) != nil else { continue } - pieces.append(.image(alt: "")) - case .mathBlock(let latex): - guard selection.range(inRow: row, rowLength: 1) != nil else { continue } - pieces.append(.displayMath(latex: latex)) - case .mermaidDiagram(let source): - guard selection.range(inRow: row, rowLength: 1) != nil else { continue } - pieces.append(.mermaid(source: source)) - case .codeBlock, .text, .blockquote, .alert, .heading: - guard let text = selectableText(block, contentVersion), - let covered = selection.range(inRow: row, rowLength: text.length) else { continue } - let part = text.attributedSubstring(from: covered) - if case .codeBlock = block.content { - pieces.append(.code(part)) - } else { - pieces.append(.text(part)) - } - } - } + guard let selection else { return nil } + let version = contentVersion + let pieces = SelectionRows.pieces(of: selection, blocks: blocks, + text: { selectableText($0, version) }, + table: { selectableTable($0, version) }) + guard !pieces.isEmpty else { return nil } let output = DocumentCopyBuilder.build(pieces) return output.plain.isEmpty ? nil : output } @@ -1971,7 +1906,7 @@ final class SelectionController: NSObject { private func lookUpItem(for event: NSEvent) -> NSMenuItem? { guard let hit = hit(atWindowPoint: event.locationInWindow), let textView = hit.textView, let local = hit.pointInTextView, - let index = Self.characterIndex(in: textView, at: local, requireInside: true), + let index = textView.selectionGlyphCharacterIndex(at: local, requireInside: true), let storage = textView.textStorage else { return nil } let word = textView.selectionRange(forProposedRange: NSRange(location: index, length: 0), granularity: .selectByWord) @@ -2014,7 +1949,10 @@ final class SelectionController: NSObject { } /// Maps a window point to a document position THROUGH THE TABLE: row under - /// the point → that row's registered text view → insertion index. Never + /// the point → that row's registered text view → its hit-test hook + /// (`selectionCharacterIndex(at:)`, TS-D6: TextKit's insertion index, or + /// for a table the cell-confined one — a point in a cell's padding or + /// beside the table snaps into the nearest cell). Never /// through the view that received the mouse-down, which a drag can scroll /// out of the prepared area and the table can recycle for another row. /// @@ -2040,7 +1978,7 @@ final class SelectionController: NSObject { let block = blocks[row] let rowRect = tableView.rect(ofRow: row) - if Self.isAtomic(block) { + if SelectionRows.isAtomic(block) { // Upper half = before the row, lower half = after it: dragging // DOWN selects the row once the pointer is past its middle, and so // does dragging UP — symmetrical, like a very tall character. @@ -2058,7 +1996,7 @@ final class SelectionController: NSObject { offset = length } else { let clamped = NSPoint(x: min(max(local.x, 0), textView.bounds.width), y: local.y) - offset = min(max(0, textView.characterIndexForInsertion(at: clamped)), length) + offset = min(max(0, textView.selectionCharacterIndex(at: clamped)), length) } return Hit(point: SelectionPoint(row: row, offset: offset), isAtomic: false, textView: textView, pointInTextView: local) @@ -2079,28 +2017,6 @@ final class SelectionController: NSObject { return view } - /// The character whose glyph is under `local` (text-view coordinates), or - /// nil past the end. With `requireInside`, only when the point is ON the - /// glyph — a click in the empty space after a link must not open it. - private static func characterIndex(in textView: SelfSizingTextView, at local: NSPoint, - requireInside: Bool) -> Int? { - guard let layoutManager = textView.layoutManager, let container = textView.textContainer, - textView.selectionTextLength > 0 else { return nil } - let origin = textView.textContainerOrigin - let point = NSPoint(x: local.x - origin.x, y: local.y - origin.y) - var fraction: CGFloat = 0 - let glyph = layoutManager.glyphIndex(for: point, in: container, - fractionOfDistanceThroughGlyph: &fraction) - guard glyph < layoutManager.numberOfGlyphs else { return nil } - if requireInside { - let box = layoutManager.boundingRect(forGlyphRange: NSRange(location: glyph, length: 1), - in: container) - guard box.contains(point) else { return nil } - } - let index = layoutManager.characterIndexForGlyph(at: glyph) - return index < textView.selectionTextLength ? index : nil - } - // MARK: - Mouse (S-D6) /// Pointer travel below which a press-release is a click, not a drag. @@ -2140,8 +2056,12 @@ final class SelectionController: NSObject { anchorIsAtomic = false setSelection(DocumentSelection(anchor: anchor, focus: start.point)) } else if clickCount >= 2, let textView = start.textView, let local = start.pointInTextView, - let index = Self.characterIndex(in: textView, at: local, requireInside: false) { - // Double-click = word, triple-click = paragraph, in that row. + let index = textView.multiClickCharacterIndex(at: local, insertion: start.point.offset, + inTableCell: textView is TableTextView) { + // Double-click = word, triple-click = paragraph, in that row — in + // a table the cell (one paragraph per cell, TS-D7). The character + // is checked against the hook's answer (`start`), so a click in a + // cell's padding is about that cell, not TextKit's nearest glyph. let granularity: NSSelectionGranularity = clickCount == 2 ? .selectByWord : .selectByParagraph let range = textView.selectionRange(forProposedRange: NSRange(location: index, length: 0), granularity: granularity) @@ -2286,7 +2206,7 @@ final class SelectionController: NSObject { if !dragged, clickCount == 1, !isExtending, let release = hit(atWindowPoint: event.locationInWindow), let textView = release.textView, let local = release.pointInTextView, - let index = Self.characterIndex(in: textView, at: local, requireInside: true), + let index = textView.selectionGlyphCharacterIndex(at: local, requireInside: true), let link = textView.textStorage?.attribute(.link, at: index, effectiveRange: nil) { textView.clicked(onLink: link, at: index) } diff --git a/QuickMD/QuickMDTests/TableSelectionTests.swift b/QuickMD/QuickMDTests/TableSelectionTests.swift new file mode 100644 index 0000000..0d30eca --- /dev/null +++ b/QuickMD/QuickMDTests/TableSelectionTests.swift @@ -0,0 +1,432 @@ +import XCTest +import SwiftUI +import AppKit + +/// v1.13 TS-T2: text inside tables joins the document selection. +/// +/// A table row is a text row now (TS-D5): its selectable string is +/// `TableTextBuilder`'s (one paragraph per cell), so what is pinned here is +/// what a covered range of that string MEANS — the copy (TS-D8), the Source +/// Edit hint (TS-D10) — and how a press inside a cell becomes the word or the +/// cell a double/triple click selects (TS-D6/TS-D7), on a real text view laid +/// out as `TableTextView` is. `SelectionController` itself lives with the +/// views (not compiled into this target); everything it decides about rows +/// goes through `SelectionRows`, tested here. +final class TableSelectionTests: XCTestCase { + + private var theme: MarkdownTheme { MarkdownTheme.cached(for: .light) } + + /// "Name\nValue\nalpha one\n1\nbeta\ntwo words\n\ngamma\n" + private static let tableMarkdown = """ + | Name | Value | + |------|-------| + | alpha one | 1 | + | beta | two words | + | | gamma | + """ + + private static let documentMarkdown = """ + Intro paragraph. + + \(tableMarkdown) + + Outro. + """ + + private func parse(_ markdown: String) -> [MarkdownBlock] { + MarkdownBlockParser(theme: theme).parse(markdown) + } + + /// The selectable string of a text row, as `MarkdownView.selectableText` + /// builds it for `.text` (no inline math here). + private func text(_ block: MarkdownBlock) -> NSAttributedString? { + guard case .text(let attributed) = block.content else { return nil } + return BlockTextConverter.makeNSAttributedString(from: attributed, hasInlineMath: false, + theme: theme, fontScale: 1, math: .none) + } + + private func table(_ block: MarkdownBlock) -> TableText? { + guard case .table(let headers, let rows, let alignments) = block.content else { return nil } + return TableTextBuilder.build(headers: headers, rows: rows, alignments: alignments, + theme: theme, fontScale: 1) + } + + private func tableBlock(_ markdown: String = tableMarkdown) -> (block: MarkdownBlock, text: TableText) { + let block = parse(markdown).first { if case .table = $0.content { return true } else { return false } }! + return (block, table(block)!) + } + + /// 1.12's whole-table copy for `block` (what `SelectionRows.pieces` hands + /// `tablePiece` as `whole`). + private func wholeTablePiece(_ block: MarkdownBlock) -> SelectionPiece { + guard case .table(let headers, let rows, _) = block.content else { fatalError("not a table") } + let renderer = MarkdownRenderer(theme: theme) + func rendered(_ cell: String) -> String { String(renderer.renderInline(cell).characters) } + return .table(headers: headers.map(rendered), rows: rows.map { $0.map(rendered) }) + } + + /// The copy of `covered` in the fixture table. + private func copy(_ covered: NSRange, of fixture: (block: MarkdownBlock, text: TableText)? = nil) -> DocumentCopyOutput? { + let fixture = fixture ?? tableBlock() + guard let piece = SelectionRows.tablePiece(fixture.text, covered: covered, + whole: { wholeTablePiece(fixture.block) }) else { return nil } + return DocumentCopyBuilder.build([piece]) + } + + /// `length` characters of cell (band, column), from `offset` into it. + private func cellRange(_ layout: TableTextLayout, _ band: Int, _ column: Int, + from offset: Int = 0, length: Int? = nil) -> NSRange { + let cell = layout.cellRanges[band][column] + return NSRange(location: cell.location + offset, length: length ?? (cell.length - offset)) + } + + /// From (band, column, offset) to (band, column, offset), as a range. + private func span(_ layout: TableTextLayout, from a: (Int, Int, Int), to b: (Int, Int, Int)) -> NSRange { + let start = layout.cellRanges[a.0][a.1].location + a.2 + let end = layout.cellRanges[b.0][b.1].location + b.2 + return NSRange(location: start, length: end - start) + } + + // MARK: - TS-D5 + + func testTablesAreTextRowsNow() { + let blocks = parse(Self.documentMarkdown + "\n\n![alt](x.png)\n\n$$\nx\n$$\n") + let kinds = blocks.map { block -> String in + switch block.content { + case .table: return "table" + case .image: return "image" + case .mathBlock: return "math" + default: return "text" + } + } + XCTAssertTrue(kinds.contains("table")) + for block in blocks { + switch block.content { + case .table, .text: XCTAssertFalse(SelectionRows.isAtomic(block)) + case .image, .mathBlock: XCTAssertTrue(SelectionRows.isAtomic(block)) + default: break + } + } + } + + // MARK: - TS-D8 copy + + func testInsideOneCellCopiesTheCoveredText() { + let fixture = tableBlock() + let layout = fixture.text.layout + // "one" of "alpha one" + let output = copy(cellRange(layout, 1, 0, from: 6), of: fixture) + XCTAssertEqual(output?.plain, "one") + // A whole cell, as a triple-click selects it (its "\n" included). + let cell = layout.cellRanges[2][1] + XCTAssertEqual(copy(NSRange(location: cell.location, length: cell.length + 1))?.plain, "two words") + } + + func testOneCellRTFHasNoThemeColourAndNoTextTable() throws { + let fixture = tableBlock(""" + | Name | Value | + |------|-------| + | see [docs](https://example.com) **now** | 1 | + """) + let layout = fixture.text.layout + let output = try XCTUnwrap(copy(layout.cellRanges[1][0], of: fixture)) + XCTAssertEqual(output.plain, "see docs now") + let rtf = output.rtf + let full = NSRange(location: 0, length: rtf.length) + var sawLink = false + rtf.enumerateAttributes(in: full) { attributes, range, _ in + XCTAssertNil(attributes[.foregroundColor], "colour at \(range)") + XCTAssertNil(attributes[.backgroundColor], "background at \(range)") + XCTAssertNotNil(attributes[.font], "font at \(range)") + if let style = attributes[.paragraphStyle] as? NSParagraphStyle { + XCTAssertTrue(style.textBlocks.isEmpty, "text table block at \(range)") + } + if attributes[.link] != nil { sawLink = true } + } + XCTAssertTrue(sawLink, "links are kept, as for text rows") + // The pasteboard's RTF of one cell is not a table either. + let data = try XCTUnwrap(rtf.rtf(from: full, documentAttributes: [:])) + let rtfSource = try XCTUnwrap(String(data: data, encoding: .ascii)) + XCTAssertFalse(rtfSource.contains("\\trowd"), rtfSource) + } + + func testAcrossCellsOfOneBandIsTabSeparated() { + let layout = tableBlock().text.layout + // "ta" of "beta" … "two" of "two words" + XCTAssertEqual(copy(span(layout, from: (2, 0, 2), to: (2, 1, 3)))?.plain, "ta\ttwo") + } + + func testHeaderToRowStartsWithTheCoveredHeaderCells() { + let layout = tableBlock().text.layout + // From the table's start into "alpha one". + XCTAssertEqual(copy(span(layout, from: (0, 0, 0), to: (1, 0, 5)))?.plain, "Name\tValue\nalpha") + } + + func testPartialFirstAndLastCellAcrossBands() { + let layout = tableBlock().text.layout + // "lue" of the header "Value" … "two" of "two words". + XCTAssertEqual(copy(span(layout, from: (0, 1, 2), to: (2, 1, 3)))?.plain, + "lue\nalpha one\t1\nbeta\ttwo") + } + + func testEmptyCellsCrossedAreFieldsButNotTheOneADragStartsIn() { + let layout = tableBlock().text.layout + // Crossing the empty first cell of the last band keeps its field. + XCTAssertEqual(copy(span(layout, from: (2, 1, 0), to: (3, 1, 3)))?.plain, "two words\n\tgam") + // A drag that starts IN that empty cell (the hook answers its "\n") + // and stays in the band copies only what it covered. + XCTAssertEqual(copy(span(layout, from: (3, 0, 0), to: (3, 1, 3)))?.plain, "gam") + // A press right of a cell's text (its "\n") into the next cell. + XCTAssertEqual(copy(span(layout, from: (1, 0, 9), to: (1, 1, 1)))?.plain, "1") + // Only a "\n" covered: nothing. + XCTAssertNil(copy(NSRange(location: NSMaxRange(layout.cellRanges[1][0]), length: 1))) + } + + func testTabsInsideCellsBecomeSpaces() { + let built = TableTextBuilder.build(headers: ["H1", "H2"], rows: [["a\tb", "c"]], alignments: [], + theme: theme, fontScale: 1) + let block = MarkdownBlock(id: "table-0", content: .table(headers: ["H1", "H2"], rows: [["a\tb", "c"]], + alignments: []), sourceLine: 0) + let layout = built.layout + let output = copy(span(layout, from: (1, 0, 0), to: (1, 1, 1)), of: (block, built)) + XCTAssertEqual(output?.plain, "a b\tc") + } + + func testWholeTableIsExactlyThe112TSV() { + let fixture = tableBlock() + let length = fixture.text.string.length + let expected = "Name\tValue\nalpha one\t1\nbeta\ttwo words\n\tgamma" + XCTAssertEqual(DocumentCopyBuilder.build([wholeTablePiece(fixture.block)]).plain, expected) + XCTAssertEqual(copy(NSRange(location: 0, length: length))?.plain, expected) + // Ending right after the last cell's text (before its "\n") is still every cell. + XCTAssertEqual(copy(NSRange(location: 0, length: length - 1))?.plain, expected) + } + + func testHeaderlessTableCopiesWithoutAHeaderLine() { + let fixture = tableBlock(""" + | | | + |---|---| + | a | b | + | c | d | + """) + XCTAssertFalse(fixture.text.layout.showsHeader) + let whole = SelectionRows.pieces( + of: DocumentSelection(anchor: SelectionPoint(row: 0, offset: 0), + focus: SelectionPoint(row: 0, offset: fixture.text.string.length)), + blocks: [fixture.block], text: text, table: table) + XCTAssertEqual(DocumentCopyBuilder.build(whole).plain, "a\tb\nc\td") + } + + // MARK: - Mixed selections through `SelectionRows.pieces` + + private func document() -> (blocks: [MarkdownBlock], tableRow: Int) { + let blocks = parse(Self.documentMarkdown) + let row = blocks.firstIndex { if case .table = $0.content { return true } else { return false } }! + return (blocks, row) + } + + private func output(_ selection: DocumentSelection, _ blocks: [MarkdownBlock]) -> String { + DocumentCopyBuilder.build(SelectionRows.pieces(of: selection, blocks: blocks, + text: text, table: table)).plain + } + + func testParagraphThroughTableToParagraph() { + let (blocks, row) = document() + XCTAssertEqual(row, 1) + let last = blocks.count - 1 + let selection = DocumentSelection(anchor: SelectionPoint(row: 0, offset: 0), + focus: SelectionPoint(row: last, offset: text(blocks[last])!.length)) + XCTAssertEqual(output(selection, blocks), + "Intro paragraph.\n\nName\tValue\nalpha one\t1\nbeta\ttwo words\n\tgamma\n\nOutro.") + } + + func testFromAParagraphAboveIntoACell() { + let (blocks, row) = document() + let intro = text(blocks[0])!.string as NSString + let layout = table(blocks[row])!.layout + let selection = DocumentSelection( + anchor: SelectionPoint(row: 0, offset: intro.range(of: "paragraph").location), + focus: SelectionPoint(row: row, offset: layout.cellRanges[1][0].location + 5)) + XCTAssertEqual(output(selection, blocks), "paragraph.\n\nName\tValue\nalpha") + // Upwards: same text. + let upwards = DocumentSelection(anchor: selection.focus, focus: selection.anchor) + XCTAssertEqual(output(upwards, blocks), "paragraph.\n\nName\tValue\nalpha") + } + + func testFromACellIntoTheParagraphBelow() { + let (blocks, row) = document() + let layout = table(blocks[row])!.layout + let selection = DocumentSelection( + anchor: SelectionPoint(row: row, offset: layout.cellRanges[3][1].location + 3), + focus: SelectionPoint(row: row + 1, offset: text(blocks[row + 1])!.length)) + XCTAssertEqual(output(selection, blocks), "ma\n\nOutro.") + } + + func testSelectAllOverADocumentWithTables() { + let blocks = parse(Self.documentMarkdown + "\n\n" + Self.tableMarkdown) + func length(_ row: Int) -> Int { + (table(blocks[row])?.string ?? text(blocks[row]))?.length ?? 0 + } + XCTAssertNotNil(table(blocks[blocks.count - 1]), "the document ends with a table") + let all = DocumentSelection.selectAll(rowCount: blocks.count, lengths: length)! + let tsv = "Name\tValue\nalpha one\t1\nbeta\ttwo words\n\tgamma" + XCTAssertEqual(output(all, blocks), "Intro paragraph.\n\n\(tsv)\n\nOutro.\n\n\(tsv)") + } + + // MARK: - TS-D10 Source Edit hint + + private func hint(_ selection: DocumentSelection, _ blocks: [MarkdownBlock]) + -> (text: String, before: String, after: String, rows: ClosedRange)? { + SelectionRows.sourceEditHint( + for: selection, blocks: blocks, + maxRows: SourceEditSession.selectionHintMaxRows, + maxLength: SourceEditSession.selectionHintMaxLength, + contextLength: SourceEditSession.selectionHintContextLength, + text: text, table: table, + output: { DocumentCopyBuilder.build(SelectionRows.pieces(of: selection, blocks: blocks, + text: self.text, table: self.table)).plain }) + } + + func testHintInsideACellIsFoundInTheTablesSourceLine() throws { + let (blocks, row) = document() + let layout = table(blocks[row])!.layout + let words = layout.cellRanges[2][1] // "two words" + let selection = DocumentSelection(anchor: SelectionPoint(row: row, offset: words.location + 4), + focus: SelectionPoint(row: row, offset: NSMaxRange(words))) + let h = try XCTUnwrap(hint(selection, blocks)) + XCTAssertEqual(h.text, "words") + XCTAssertEqual(h.before, "two ", "context stops at the cell's edge") + XCTAssertEqual(h.after, "") + XCTAssertEqual(h.rows, row...row) + + let lines = try XCTUnwrap(DocumentReadingPosition.selectionHint( + text: h.text, before: h.before, after: h.after, rows: h.rows, blocks: blocks)) + XCTAssertEqual(lines.lines.lowerBound, blocks[row].sourceLine, "the editor opens at the table") + let source = Self.documentMarkdown as NSString + let found = try XCTUnwrap(SourceEditSupport.sourceRange(ofSelection: lines.text, before: lines.before, + after: lines.after, in: source, lines: lines.lines)) + XCTAssertEqual(source.substring(with: found), "words") + XCTAssertEqual(source.substring(with: source.lineRange(for: found)), "| beta | two words |\n") + } + + func testHintAcrossCellsStillPointsAtTheTable() throws { + let (blocks, row) = document() + let layout = table(blocks[row])!.layout + let selection = DocumentSelection(anchor: SelectionPoint(row: row, offset: layout.cellRanges[1][0].location), + focus: SelectionPoint(row: row, offset: layout.cellRanges[2][0].location + 2)) + let h = try XCTUnwrap(hint(selection, blocks)) + XCTAssertEqual(h.text, "alpha one\t1\nbe") + let lines = try XCTUnwrap(DocumentReadingPosition.selectionHint(text: h.text, rows: h.rows, blocks: blocks)) + XCTAssertEqual(lines.lines.lowerBound, blocks[row].sourceLine) + // A TSV never matches the source literally: the editor falls back to the line. + XCTAssertNil(SourceEditSupport.sourceRange(ofSelection: lines.text, in: Self.documentMarkdown, + lines: lines.lines)) + } + + func testHintFromAParagraphIntoATableCoversBothRows() throws { + let (blocks, row) = document() + let layout = table(blocks[row])!.layout + let selection = DocumentSelection(anchor: SelectionPoint(row: 0, offset: 6), + focus: SelectionPoint(row: row, offset: layout.cellRanges[0][0].location + 2)) + let h = try XCTUnwrap(hint(selection, blocks)) + XCTAssertEqual(h.rows, 0...row) + XCTAssertEqual(h.after, "me", "after-context inside the last cell") + } + + // MARK: - TS-D6 / TS-D7 on a real text view + + /// A text view set up as `TableTextView` (`configureForSelfSizing` + + /// `installDocumentSelectionLayoutManager`) holding `table`, laid out at + /// `width` through its display container. + private func liveTextView(_ string: NSAttributedString, width: CGFloat) -> NSTextView { + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: width, height: 10)) + textView.configureForSelfSizing() + textView.installDocumentSelectionLayoutManager() + textView.textStorage?.setAttributedString(string) + textView.layoutManager?.ensureLayout(for: textView.textContainer!) + return textView + } + + private static let multiClickFixtures = [tableMarkdown, """ + | Feature | Description | Status | + |:--------|:-----------:|-------:| + | **Search** | Finds every occurrence across the document, including text inside tables, and highlights the focused one | `done` | + | Links | See [the docs](https://example.com/docs) for the full list | **beta** | + """] + + /// A double- / triple-click anywhere in a cell — its text, its padding + /// corners, the empty space under a short cell in a tall band — selects + /// a word of THAT cell / that cell exactly, never a neighbour and never + /// the whole table. The press goes through the same path as in the app: + /// the hook's insertion index (`characterIndex(confinedToCellAt:)`, what + /// `TableTextView.selectionCharacterIndex` returns), then + /// `multiClickCharacterIndex`, then TextKit's granularity. + func testMultiClickSelectsAWordOfTheCellAndTheCellUnderThePress() { + var probed = 0 + for markdown in Self.multiClickFixtures { + for width in [CGFloat(400), 700] { + let built = tableBlock(markdown).text + let layout = built.layout + let textView = liveTextView(built.string, width: width) + for band in 0.. 0 && NSMaxRange(word) <= NSMaxRange(paragraph) + && word.location >= paragraph.location, "\(tag): word \(word)") + if cell.length > 0 { + XCTAssertTrue(NSMaxRange(word) <= NSMaxRange(cell), "\(tag): word \(word) is the \\n") + } + probed += 1 + } + } + } + } + } + XCTAssertGreaterThan(probed, 100) + } + + /// On ordinary text the glyph under the pointer is what a double-click is + /// about, exactly as before tables joined the selection. + func testMultiClickOnTextIsTheGlyphUnderThePointer() { + let string = NSAttributedString(string: "Hello wonderful world, again and again.", + attributes: [.font: NSFont.systemFont(ofSize: 14)]) + let textView = liveTextView(string, width: 600) + let layoutManager = textView.layoutManager! + for index in [0, 3, 6, 10, 15, 16, 22, 30] { + let glyph = layoutManager.glyphIndexForCharacter(at: index) + let box = layoutManager.boundingRect(forGlyphRange: NSRange(location: glyph, length: 1), + in: textView.textContainer!) + for x in [box.minX + 1, box.maxX - 1] { + let point = NSPoint(x: x + textView.textContainerOrigin.x, y: box.midY + textView.textContainerOrigin.y) + let insertion = textView.characterIndexForInsertion(at: point) + XCTAssertEqual(textView.multiClickCharacterIndex(at: point, insertion: insertion, inTableCell: false), index, + "index \(index) x \(x)") + } + } + } +} From 040cb4ea33ce8347f4e2a7d291a1a44bbf1112f6 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Thu, 22 Oct 2026 13:51:37 +0200 Subject: [PATCH 05/10] fix(search): no highlight drift when lowercasing changes the length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit searchMatchRanges returned ranges in the lowercased copy's coordinates; "İ" lowercases to two UTF-16 units, so every later highlight of a row (a whole table) shifted by one. When the lowercased length differs, search the original string with .caseInsensitive; otherwise unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BhzVb6aPQs2FF1bHdkcYys --- QuickMD/QuickMD/DocumentSearch.swift | 22 ++++++++++--- QuickMD/QuickMDTests/SearchTests.swift | 43 ++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/QuickMD/QuickMD/DocumentSearch.swift b/QuickMD/QuickMD/DocumentSearch.swift index a8137a0..a1ffaac 100644 --- a/QuickMD/QuickMD/DocumentSearch.swift +++ b/QuickMD/QuickMD/DocumentSearch.swift @@ -23,17 +23,29 @@ struct ParsedDocument: Sendable { /// comparison, non-overlapping. The ONE matcher behind the NSTextView-hosted /// highlights (`applyNSSearchHighlight`) — and behind `DocumentSearch`'s count /// for tables, which is how a table's count equals what its view highlights. +/// +/// The ranges must index the ORIGINAL string (the highlight applies them to +/// the view's storage). Lowercasing can change the UTF-16 length ("İ" U+0130 +/// becomes "i̇", two units), and a range found in the lowercased copy then +/// points one unit further per such character — every later highlight of the +/// row (a whole table) drifts and the last one can be clipped. Only then is +/// the original searched with `.caseInsensitive`; ordinary text keeps the +/// lowercased search exactly as before. func searchMatchRanges(in string: String, term: String) -> [NSRange] { guard !term.isEmpty else { return [] } - let needle = term.lowercased() as NSString + let original = string as NSString let lowerHaystack = string.lowercased() as NSString - guard needle.length > 0, lowerHaystack.length >= needle.length else { return [] } + let lengthPreserved = lowerHaystack.length == original.length + let haystack = lengthPreserved ? lowerHaystack : original + let needle = (lengthPreserved ? term.lowercased() : term) as NSString + let options: NSString.CompareOptions = lengthPreserved ? [] : .caseInsensitive + guard needle.length > 0, haystack.length >= needle.length else { return [] } var ranges: [NSRange] = [] var location = 0 - while location < lowerHaystack.length { - let searchRange = NSRange(location: location, length: lowerHaystack.length - location) - let found = lowerHaystack.range(of: needle as String, options: [], range: searchRange) + while location < haystack.length { + let searchRange = NSRange(location: location, length: haystack.length - location) + let found = haystack.range(of: needle as String, options: options, range: searchRange) if found.location == NSNotFound { break } ranges.append(found) location = found.location + max(found.length, 1) diff --git a/QuickMD/QuickMDTests/SearchTests.swift b/QuickMD/QuickMDTests/SearchTests.swift index 77ee67a..fb81287 100644 --- a/QuickMD/QuickMDTests/SearchTests.swift +++ b/QuickMD/QuickMDTests/SearchTests.swift @@ -241,6 +241,49 @@ final class SearchTests: XCTestCase { } } + /// "İ" (U+0130) lowercases to two UTF-16 units. The ranges must still be + /// the ORIGINAL string's — in a table one such character used to shift + /// every later highlight by one. + func testLengthChangingLowercaseDoesNotShiftRanges() { + let text = "İstanbul and x, then X" + let expected = [(text as NSString).range(of: "x"), (text as NSString).range(of: "X")] + XCTAssertEqual(searchMatchRanges(in: text, term: "x"), expected) + XCTAssertEqual(searchMatchRanges(in: text, term: "STANBUL"), [NSRange(location: 1, length: 7)], + "case-insensitive on the original string") + XCTAssertEqual(searchMatchRanges(in: text, term: "İ"), [NSRange(location: 0, length: 1)]) + + // A text row through the shared highlight. + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 300, height: 10)) + textView.textStorage?.setAttributedString(NSAttributedString(string: text)) + applyNSSearchHighlight(in: textView, term: "x", focusedOccurrence: 1) + XCTAssertEqual(highlights(in: textView).map(\.range), expected) + XCTAssertEqual(highlights(in: textView).map(\.focused), [false, true]) + + // A table: "İstanbul" in the first cell, the term in later cells. + let markdown = "| İstanbul | x |\n|---|---|\n| **x** İ | [x](https://x.example) |" + let table = onlyTable(markdown) + let built = build(table) + let string = built.string.string as NSString + var original: [NSRange] = [] + var location = 0 + while true { + let found = string.range(of: "x", options: [], + range: NSRange(location: location, length: string.length - location)) + if found.location == NSNotFound { break } + original.append(found) + location = NSMaxRange(found) + } + XCTAssertEqual(original.count, 3) + XCTAssertEqual(DocumentSearch.computeMatches(in: [table.block], term: "x").matchBlockIds.count, 3) + let tableView = tableTextView(built) + for focused in 0.. Date: Thu, 22 Oct 2026 13:55:26 +0200 Subject: [PATCH 06/10] fix(tables): selectableTable uses the strict theme-aware TableText match Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BhzVb6aPQs2FF1bHdkcYys --- QuickMD/QuickMD/MarkdownView.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/QuickMD/QuickMD/MarkdownView.swift b/QuickMD/QuickMD/MarkdownView.swift index 7f5a5f9..c0066ad 100644 --- a/QuickMD/QuickMD/MarkdownView.swift +++ b/QuickMD/QuickMD/MarkdownView.swift @@ -1197,14 +1197,15 @@ struct MarkdownView: View { } /// A `.table` block's selectable string with its cell layout (TS-D4 / - /// TS-D8): the measurer's instance when it belongs to this parse; - /// otherwise the same builder `TableTextBlockView` falls back to. Nil for - /// any other block. + /// TS-D8): the measurer's instance when it belongs to this parse and was + /// built with the theme the view renders with (the strict `matches`, as + /// `TableTextBlockView` checks it); otherwise the same builder the view + /// falls back to. Nil for any other block. private func selectableTable(for block: MarkdownBlock, installedVersion: Int) -> TableText? { guard case .table(let headers, let rows, let alignments) = block.content else { return nil } let scale = CGFloat(renderedFontScale) if installedVersion == contentVersion, let table = measured.tables[block.id], - table.matches(headers: headers, rows: rows, alignments: alignments, fontScale: scale) { + table.matches(headers: headers, rows: rows, alignments: alignments, theme: theme, fontScale: scale) { return table } return TableTextBuilder.build(headers: headers, rows: rows, alignments: alignments, From 6ffccabdbed067d57d4168d23f78fc2b2a872485 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Thu, 22 Oct 2026 14:05:02 +0200 Subject: [PATCH 07/10] fix(tables): review fixes for selection and copy - multi-click on text rows is exactly the pre-v1.13 glyph rule; the cell rule compares against the glyph's composed character range (emoji, ZWJ) - Source Edit hint inside one table band searches that band's source line only (repeated values in a column no longer land on another row) - a single-cell copy drops the column's alignment as well as the text block Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BhzVb6aPQs2FF1bHdkcYys --- QuickMD/QuickMD/DocumentReadingPosition.swift | 8 +- QuickMD/QuickMD/SelectionRows.swift | 85 +++++--- QuickMD/QuickMD/Views/VirtualBlockList.swift | 5 +- .../QuickMDTests/TableSelectionTests.swift | 195 +++++++++++++++++- 4 files changed, 260 insertions(+), 33 deletions(-) diff --git a/QuickMD/QuickMD/DocumentReadingPosition.swift b/QuickMD/QuickMD/DocumentReadingPosition.swift index 8a0068f..8a990b9 100644 --- a/QuickMD/QuickMD/DocumentReadingPosition.swift +++ b/QuickMD/QuickMD/DocumentReadingPosition.swift @@ -67,11 +67,17 @@ final class DocumentReadingPosition { /// `sourceLine` of the block after the last selected one — to the end of /// the text when there is none. Nil for empty text or rows outside /// `blocks` (a stale selection racing a re-install). + /// + /// `lines`, when given, replaces that range (a selection inside one band + /// of a table names the band's own line — `SelectionRows.sourceEditHint`). static func selectionHint(text: String, before: String = "", after: String = "", - rows: ClosedRange, + rows: ClosedRange, lines: Range? = nil, blocks: [MarkdownBlock]) -> SourceEditSession.SelectionHint? { guard !text.isEmpty, blocks.indices.contains(rows.lowerBound), blocks.indices.contains(rows.upperBound) else { return nil } + if let lines, !lines.isEmpty { + return SourceEditSession.SelectionHint(text: text, before: before, after: after, lines: lines) + } let start = blocks[rows.lowerBound].sourceLine let next = rows.upperBound + 1 let end = next < blocks.count ? blocks[next].sourceLine : Int.max diff --git a/QuickMD/QuickMD/SelectionRows.swift b/QuickMD/QuickMD/SelectionRows.swift index 0bc3b8b..a95d64d 100644 --- a/QuickMD/QuickMD/SelectionRows.swift +++ b/QuickMD/QuickMD/SelectionRows.swift @@ -88,8 +88,9 @@ enum SelectionRows { /// - every cell's text covered (a drag through the table, ⌘A) → `whole()`, /// which is 1.12's TSV; /// - inside ONE cell → that text, as a text row copies (fonts, links), but - /// without the cell's `NSTextTableBlock` — pasted RTF must not turn a - /// word into a one-cell table; + /// without the cell's `NSTextTableBlock` and the column's alignment — + /// pasted RTF must not turn a word into a one-cell table, nor into a + /// right-aligned or centred paragraph; /// - across cells → `.tableCells`: the covered cells in reading order, a /// partial first / last cell with only its covered part. /// @@ -124,7 +125,7 @@ enum SelectionRows { if first.band == last.band && first.column == last.column { let range = coveredText(first.band, first.column) guard range.length > 0 else { return nil } - return .text(withoutTextBlocks(table.string.attributedSubstring(from: range))) + return .text(withoutCellLayout(table.string.attributedSubstring(from: range))) } let string = table.string.string as NSString var bands: [[String]] = [] @@ -136,14 +137,17 @@ enum SelectionRows { return .tableCells(bands: bands) } - /// `text` with every paragraph style's text blocks removed (alignment, - /// spacing and the rest kept). - private static func withoutTextBlocks(_ text: NSAttributedString) -> NSAttributedString { + /// `text` with what belongs to the TABLE removed from its paragraph + /// styles: the text blocks (the cell) and the alignment (the column's, + /// from the separator row — back to natural). The rest is kept. + private static func withoutCellLayout(_ text: NSAttributedString) -> NSAttributedString { let result = NSMutableAttributedString(attributedString: text) result.enumerateAttribute(.paragraphStyle, in: NSRange(location: 0, length: result.length)) { value, range, _ in - guard let style = value as? NSParagraphStyle, !style.textBlocks.isEmpty, + guard let style = value as? NSParagraphStyle, + !style.textBlocks.isEmpty || style.alignment != .natural, let plain = style.mutableCopy() as? NSMutableParagraphStyle else { return } plain.textBlocks = [] + plain.alignment = .natural result.addAttribute(.paragraphStyle, value: plain, range: range) } return result @@ -165,17 +169,25 @@ enum SelectionRows { /// never matches literally) still opens the editor at the table. Its /// context stops at the CELL's edges — the "\n" between two rendered /// cells is a `|` in the source and would end the agreement anyway. + /// + /// `lines`: nil = the rows' own lines (`DocumentReadingPosition + /// .selectionHint` derives them from `rows`). A selection inside ONE band + /// of a table names that band's source line instead: a whole-cell + /// selection has no context, and the first literal match in the table's + /// lines can be another row's cell — `| 10 | a |` above `| 1 | b |`, the + /// "1" of "10" (`sourceLine(ofBand:in:tableLine:)`). static func sourceEditHint(for selection: DocumentSelection?, blocks: [MarkdownBlock], maxRows: Int, maxLength: Int, contextLength: Int, text: (MarkdownBlock) -> NSAttributedString?, table: (MarkdownBlock) -> TableText?, output: () -> String?) - -> (text: String, before: String, after: String, rows: ClosedRange)? { + -> (text: String, before: String, after: String, rows: ClosedRange, lines: Range?)? { guard let selection, let span = selection.rowSpan, span.count <= maxRows, span.upperBound < blocks.count else { return nil } var covered = 0 var before = "" var after = "" + var lines: Range? for row in span { let block = blocks[row] let string: NSAttributedString @@ -206,9 +218,25 @@ enum SelectionRows { let stop = min(ceiling, end + contextLength) after = ns.substring(with: NSRange(location: end, length: max(0, stop - end))) } + if span.count == 1, let layout, + let first = layout.cell(containing: range.location)?.band, + let last = layout.cell(containing: NSMaxRange(range) - 1)?.band, first == last { + let line = sourceLine(ofBand: first, in: layout, tableLine: block.sourceLine) + lines = line..<(line + 1) + } } guard covered > 0, let plain = output() else { return nil } - return (plain, before, after, span) + return (plain, before, after, span, lines) + } + + /// The 0-based source line of `band`, from the table's `sourceLine` (its + /// header line). `MarkdownBlockParser` reads a table as the header line, + /// the separator line, then one line per row — and keeps the header line + /// even when it is blank (`| | |`, a headerless table: no header band, so + /// band 0 is the first row, two lines down). + static func sourceLine(ofBand band: Int, in layout: TableTextLayout, tableLine: Int) -> Int { + guard layout.showsHeader else { return tableLine + 2 + band } + return band == 0 ? tableLine : tableLine + 1 + band } /// The text range (without its "\n") of the cell holding `index`. @@ -245,32 +273,37 @@ extension NSTextView { /// what `selectionRange(forProposedRange:granularity:)` expands to a word /// or a paragraph (in a table: the cell). /// - /// `insertion` is the row's own hit-test answer for the same press (the - /// TS-D6 hook, `SelfSizingTextView.selectionCharacterIndex`). The glyph - /// under the point is used when it agrees with it (it is one side of that - /// insertion point) — always the case on a text line. When they disagree, - /// TextKit's "nearest glyph" went somewhere the hook would not: in a - /// table cell's padding, or below a short cell in a tall band, that is a - /// NEIGHBOURING cell, so the hook wins. + /// A text row (`inTableCell == false`) gets exactly what it got before + /// v1.13: the glyph TextKit finds nearest to the point — on a multi-unit + /// glyph (emoji, ZWJ sequence, ligature) on either half, and above/below + /// the text the word under the pointer's x. /// - /// `inTableCell`: a press in the empty part of a cell (right of a short - /// text — most of a wide cell) lands on the cell's terminating "\n", - /// which a word selection would select alone. It is moved back onto the - /// cell's last character, so the double-click is about the cell's last - /// word. (Text rows keep TextKit's answer, as before v1.13.) An empty - /// cell has nothing else: its "\n" stays (the triple-click still selects - /// the cell). + /// In a table cell, TextKit's "nearest glyph" can be in a NEIGHBOURING + /// cell (a press in a cell's padding, or under a short cell in a tall + /// band). `insertion` is the row's own hit-test answer for the same press + /// (the TS-D6 hook, `SelfSizingTextView.selectionCharacterIndex`, confined + /// to the cell under the point): the glyph is used when the insertion + /// point is on one of its edges — compared against the glyph's whole + /// composed character sequence, so the right half of an emoji still + /// agrees — and otherwise the hook wins. A press in the empty part of a + /// cell (right of a short text — most of a wide cell) lands on the cell's + /// terminating "\n", which a word selection would select alone, so it is + /// moved back onto the cell's last character: the double-click is about + /// the cell's last word. An empty cell has nothing else: its "\n" stays + /// (the triple-click still selects the cell). func multiClickCharacterIndex(at local: NSPoint, insertion: Int, inTableCell: Bool) -> Int? { + let glyph = selectionGlyphCharacterIndex(at: local, requireInside: false) + guard inTableCell else { return glyph } guard let storage = textStorage, storage.length > 0 else { return nil } let string = storage.string as NSString var index: Int - if let glyph = selectionGlyphCharacterIndex(at: local, requireInside: false), - glyph == insertion || glyph == insertion - 1 { + if let glyph, glyph < string.length, + case let composed = string.rangeOfComposedCharacterSequence(at: glyph), + insertion >= composed.location, insertion <= NSMaxRange(composed) { index = glyph } else { index = min(max(insertion, 0), string.length - 1) } - guard inTableCell else { return index } func isLineBreak(_ index: Int) -> Bool { let unit = string.character(at: index) return unit == 0x0A || unit == 0x0D || unit == 0x2028 || unit == 0x2029 diff --git a/QuickMD/QuickMD/Views/VirtualBlockList.swift b/QuickMD/QuickMD/Views/VirtualBlockList.swift index fd7a808..f50ecbd 100644 --- a/QuickMD/QuickMD/Views/VirtualBlockList.swift +++ b/QuickMD/QuickMD/Views/VirtualBlockList.swift @@ -305,7 +305,8 @@ struct VirtualBlockList: NSViewRepresentable { maxLength: SourceEditSession.selectionHintMaxLength, contextLength: SourceEditSession.selectionHintContextLength) else { return nil } return DocumentReadingPosition.selectionHint(text: hint.text, before: hint.before, after: hint.after, - rows: hint.rows, blocks: coordinator.blocks) + rows: hint.rows, lines: hint.lines, + blocks: coordinator.blocks) } if focusRequest != coordinator.lastFocusRequest { coordinator.lastFocusRequest = focusRequest @@ -1848,7 +1849,7 @@ final class SelectionController: NSObject { /// Source Edit's carry-over (S-D14b): see `SelectionRows.sourceEditHint` /// (a range inside a table now gives a hint too — TS-D10). func sourceEditHint(maxRows: Int, maxLength: Int, contextLength: Int) - -> (text: String, before: String, after: String, rows: ClosedRange)? { + -> (text: String, before: String, after: String, rows: ClosedRange, lines: Range?)? { let version = contentVersion return SelectionRows.sourceEditHint( for: selection, blocks: blocks, maxRows: maxRows, maxLength: maxLength, contextLength: contextLength, diff --git a/QuickMD/QuickMDTests/TableSelectionTests.swift b/QuickMD/QuickMDTests/TableSelectionTests.swift index 0d30eca..b1b8047 100644 --- a/QuickMD/QuickMDTests/TableSelectionTests.swift +++ b/QuickMD/QuickMDTests/TableSelectionTests.swift @@ -276,7 +276,7 @@ final class TableSelectionTests: XCTestCase { // MARK: - TS-D10 Source Edit hint private func hint(_ selection: DocumentSelection, _ blocks: [MarkdownBlock]) - -> (text: String, before: String, after: String, rows: ClosedRange)? { + -> (text: String, before: String, after: String, rows: ClosedRange, lines: Range?)? { SelectionRows.sourceEditHint( for: selection, blocks: blocks, maxRows: SourceEditSession.selectionHintMaxRows, @@ -300,8 +300,10 @@ final class TableSelectionTests: XCTestCase { XCTAssertEqual(h.rows, row...row) let lines = try XCTUnwrap(DocumentReadingPosition.selectionHint( - text: h.text, before: h.before, after: h.after, rows: h.rows, blocks: blocks)) - XCTAssertEqual(lines.lines.lowerBound, blocks[row].sourceLine, "the editor opens at the table") + text: h.text, before: h.before, after: h.after, rows: h.rows, lines: h.lines, blocks: blocks)) + // Band 2 = the second data row: header, separator, first row, then it. + XCTAssertEqual(lines.lines, (blocks[row].sourceLine + 3)..<(blocks[row].sourceLine + 4), + "the band's own source line") let source = Self.documentMarkdown as NSString let found = try XCTUnwrap(SourceEditSupport.sourceRange(ofSelection: lines.text, before: lines.before, after: lines.after, in: source, lines: lines.lines)) @@ -316,7 +318,9 @@ final class TableSelectionTests: XCTestCase { focus: SelectionPoint(row: row, offset: layout.cellRanges[2][0].location + 2)) let h = try XCTUnwrap(hint(selection, blocks)) XCTAssertEqual(h.text, "alpha one\t1\nbe") - let lines = try XCTUnwrap(DocumentReadingPosition.selectionHint(text: h.text, rows: h.rows, blocks: blocks)) + XCTAssertNil(h.lines, "two bands: the table's lines") + let lines = try XCTUnwrap(DocumentReadingPosition.selectionHint(text: h.text, rows: h.rows, lines: h.lines, + blocks: blocks)) XCTAssertEqual(lines.lines.lowerBound, blocks[row].sourceLine) // A TSV never matches the source literally: the editor falls back to the line. XCTAssertNil(SourceEditSupport.sourceRange(ofSelection: lines.text, in: Self.documentMarkdown, @@ -331,6 +335,99 @@ final class TableSelectionTests: XCTestCase { let h = try XCTUnwrap(hint(selection, blocks)) XCTAssertEqual(h.rows, 0...row) XCTAssertEqual(h.after, "me", "after-context inside the last cell") + XCTAssertNil(h.lines, "two rows: their lines") + } + + /// Where Source Edit selects a WHOLE cell (no context to tell matches + /// apart) of `markdown`: the source text of the match and its line. + private func sourceSelection(ofCell band: Int, _ column: Int, in markdown: String) + throws -> (text: String, line: String) { + let blocks = parse(markdown) + let row = try XCTUnwrap(blocks.firstIndex { if case .table = $0.content { return true } else { return false } }) + let cell = table(blocks[row])!.layout.cellRanges[band][column] + // As a triple-click selects it: the cell and its "\n". + let selection = DocumentSelection(anchor: SelectionPoint(row: row, offset: cell.location), + focus: SelectionPoint(row: row, offset: NSMaxRange(cell) + 1)) + let h = try XCTUnwrap(hint(selection, blocks)) + let resolved = try XCTUnwrap(DocumentReadingPosition.selectionHint( + text: h.text, before: h.before, after: h.after, rows: h.rows, lines: h.lines, blocks: blocks)) + let source = markdown as NSString + let found = try XCTUnwrap(SourceEditSupport.sourceRange(ofSelection: resolved.text, before: resolved.before, + after: resolved.after, in: source, + lines: resolved.lines)) + return (source.substring(with: found), source.substring(with: source.lineRange(for: found))) + } + + /// Repeated values in a column: the selected cell's OWN row, never the + /// first literal match in the table (the "1" of "10"). + func testWholeCellHintFindsItsOwnRowAmongRepeatedValues() throws { + let markdown = """ + Intro. + + | Count | Done | + |------:|:----:| + | 10 | yes | + | 1 | yes | + | 1 | no | + | 100 | yes | + + After. + """ + var found = try sourceSelection(ofCell: 2, 0, in: markdown) + XCTAssertEqual(found.text, "1") + XCTAssertEqual(found.line, "| 1 | yes |\n") + found = try sourceSelection(ofCell: 3, 0, in: markdown) + XCTAssertEqual(found.line, "| 1 | no |\n") + found = try sourceSelection(ofCell: 4, 1, in: markdown) // last band + XCTAssertEqual(found.text, "yes") + XCTAssertEqual(found.line, "| 100 | yes |\n") + found = try sourceSelection(ofCell: 0, 1, in: markdown) // header band + XCTAssertEqual(found.line, "| Count | Done |\n") + } + + func testWholeCellHintInAHeaderlessTable() throws { + let markdown = """ + | | | + |---|---| + | 10 | yes | + | 1 | yes | + """ + var found = try sourceSelection(ofCell: 0, 1, in: markdown) // first band = first row + XCTAssertEqual(found.line, "| 10 | yes |\n") + found = try sourceSelection(ofCell: 1, 0, in: markdown) // last band + XCTAssertEqual(found.text, "1") + XCTAssertEqual(found.line, "| 1 | yes |") + } + + func testBandSourceLines() { + let header = TableTextBuilder.build(headers: ["A"], rows: [["1"], ["2"]], alignments: [], + theme: theme, fontScale: 1).layout + XCTAssertEqual((0..<3).map { SelectionRows.sourceLine(ofBand: $0, in: header, tableLine: 7) }, [7, 9, 10]) + let headerless = TableTextBuilder.build(headers: [""], rows: [["1"], ["2"]], alignments: [], + theme: theme, fontScale: 1).layout + XCTAssertEqual((0..<2).map { SelectionRows.sourceLine(ofBand: $0, in: headerless, tableLine: 7) }, [9, 10]) + } + + /// A word copied from a right-aligned or centred column pastes as an + /// ordinary (natural) paragraph. + func testOneCellCopyDropsTheColumnAlignment() throws { + let fixture = tableBlock(""" + | Left | Centre | Right | + |:-----|:------:|------:| + | a | b | c | + """) + let layout = fixture.text.layout + for column in 0..<3 { + let source = fixture.text.string.attribute(.paragraphStyle, at: layout.cellRanges[1][column].location, + effectiveRange: nil) as? NSParagraphStyle + XCTAssertEqual(source?.alignment, [NSTextAlignment.left, .center, .right][column], "fixture") + let output = try XCTUnwrap(copy(layout.cellRanges[1][column], of: fixture)) + output.rtf.enumerateAttribute(.paragraphStyle, in: NSRange(location: 0, length: output.rtf.length)) { value, _, _ in + guard let style = value as? NSParagraphStyle else { return } + XCTAssertEqual(style.alignment, .natural, "column \(column)") + XCTAssertTrue(style.textBlocks.isEmpty, "column \(column)") + } + } } // MARK: - TS-D6 / TS-D7 on a real text view @@ -429,4 +526,94 @@ final class TableSelectionTests: XCTestCase { } } } + + /// The point at the left or right edge (`fraction` 0.1 / 0.9) of the glyph + /// that shows character `index`, in view coordinates. + private func point(onGlyphOf index: Int, fraction: CGFloat, in textView: NSTextView, + dy: CGFloat = 0) -> NSPoint { + let layoutManager = textView.layoutManager! + let glyph = layoutManager.glyphIndexForCharacter(at: index) + let box = layoutManager.boundingRect(forGlyphRange: NSRange(location: glyph, length: 1), + in: textView.textContainer!) + let origin = textView.textContainerOrigin + return NSPoint(x: origin.x + box.minX + box.width * fraction, y: origin.y + box.midY + dy) + } + + private func word(at index: Int, in textView: NSTextView) -> String { + let range = textView.selectionRange(forProposedRange: NSRange(location: index, length: 0), + granularity: .selectByWord) + return (textView.string as NSString).substring(with: range) + } + + /// A double-click on a glyph made of several UTF-16 units (emoji, a ZWJ + /// sequence) or of several characters (a ligature) is about THAT glyph on + /// either half — the insertion point after it is several units away from + /// the glyph's first character. (7fc791f compared them unit by unit and + /// selected the space after the emoji.) + func testMultiClickOnAMultiUnitGlyphOfATextRowIsThatGlyph() { + let font = NSFont.systemFont(ofSize: 18) + let cases: [(text: String, target: String)] = [ + ("hi \u{1F600} there", "\u{1F600}"), + ("a \u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467} family", "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"), + ("an office", "office"), + ] + for (text, target) in cases { + let textView = liveTextView(NSAttributedString(string: text, attributes: [.font: font]), width: 600) + let location = (text as NSString).range(of: target).location + for fraction in [CGFloat(0.1), 0.9] { + let point = point(onGlyphOf: location, fraction: fraction, in: textView) + let insertion = textView.characterIndexForInsertion(at: point) + let index = textView.multiClickCharacterIndex(at: point, insertion: insertion, inTableCell: false) + XCTAssertEqual(index, textView.selectionGlyphCharacterIndex(at: point, requireInside: false), + "\(text) @\(fraction): the glyph under the pointer") + XCTAssertEqual(index.map { word(at: $0, in: textView) }, target, "\(text) @\(fraction)") + } + } + } + + /// Above or below a text row's text (in its view's inset) a double-click + /// is about what it was about before v1.13: TextKit's nearest glyph — + /// measured, the text's first / last character, so the first / last word, + /// not the word under the pointer's x. + func testMultiClickInATextRowsPaddingKeepsThePreTablesAnswer() { + let text = "first second third\nfourth fifth sixth" + let textView = liveTextView(NSAttributedString(string: text, attributes: [.font: NSFont.systemFont(ofSize: 14)]), + width: 600) + textView.textContainerInset = NSSize(width: 0, height: 12) + textView.layoutManager?.ensureLayout(for: textView.textContainer!) + let used = textView.layoutManager!.usedRect(for: textView.textContainer!) + let ns = text as NSString + let x = point(onGlyphOf: ns.range(of: "second").location + 2, fraction: 0.5, in: textView).x + let probes: [(NSPoint, String)] = [ + (NSPoint(x: x, y: 4), "first"), // in the top inset + (NSPoint(x: x, y: textView.textContainerOrigin.y + used.maxY + 4), "sixth"), // below the text + ] + for (point, expected) in probes { + let insertion = textView.characterIndexForInsertion(at: point) + let glyph = textView.selectionGlyphCharacterIndex(at: point, requireInside: false) + let index = textView.multiClickCharacterIndex(at: point, insertion: insertion, inTableCell: false) + XCTAssertEqual(index, glyph, "y \(point.y): the pre-v1.13 rule") + XCTAssertEqual(index.map { word(at: $0, in: textView) }, expected, "y \(point.y)") + } + } + + /// The same multi-unit glyph inside a table CELL: the cell rule still + /// accepts the glyph (its composed range contains the insertion point). + func testMultiClickOnAnEmojiInATableCell() throws { + let fixture = tableBlock(""" + | A | B | + |---|---| + | hi \u{1F600} there | x | + """) + let layout = fixture.text.layout + let textView = liveTextView(fixture.text.string, width: 700) + let location = (fixture.text.string.string as NSString).range(of: "\u{1F600}").location + for fraction in [CGFloat(0.1), 0.9] { + let point = point(onGlyphOf: location, fraction: fraction, in: textView) + let insertion = textView.characterIndex(confinedToCellAt: point, layout: layout) + let index = try XCTUnwrap(textView.multiClickCharacterIndex(at: point, insertion: insertion, + inTableCell: true)) + XCTAssertEqual(word(at: index, in: textView), "\u{1F600}", "@\(fraction)") + } + } } From 6a7552cd0c6518b5da14c1a6b214c111f57a8f53 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Thu, 22 Oct 2026 14:19:02 +0200 Subject: [PATCH 08/10] perf(selection): background fills ask only for their own part of the selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A table fills a background and borders per cell, and every fill computed the rects of the WHOLE selection to cut its holes: quadratic. ⌘A ⌘C on a 200-row table took 7 s on first draw and grew the process to 3 GB (1.12: 0.35 s, 110 MB). `selectionRects(in:limitedTo:)` takes the fill's character range and keeps the whole covered range as the selected range, so line ends extend exactly as before; the limited rects add up to the whole selection's (test). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BhzVb6aPQs2FF1bHdkcYys --- QuickMD/QuickMD/DocumentSelection.swift | 21 +++++++++---- .../QuickMDTests/TableSelectionTests.swift | 30 +++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/QuickMD/QuickMD/DocumentSelection.swift b/QuickMD/QuickMD/DocumentSelection.swift index 7f491da..ae164d1 100644 --- a/QuickMD/QuickMD/DocumentSelection.swift +++ b/QuickMD/QuickMD/DocumentSelection.swift @@ -192,17 +192,23 @@ final class DocumentSelectionLayoutManager: NSLayoutManager { } /// The selection's rects in `textView` coordinates (empty when nothing of - /// this text is covered). `withinSelectedGlyphRange` = the same range, so - /// wrapped lines extend to the container edge as a native selection does. - func selectionRects(in textView: NSTextView) -> [NSRect] { + /// this text is covered), for the part of the selection inside `limit` + /// (all of it by default). `withinSelectedGlyphRange` is always the WHOLE + /// covered range, so wrapped lines extend to the container edge as a + /// native selection does, and a limited call returns exactly its share of + /// the unlimited rects. + func selectionRects(in textView: NSTextView, limitedTo limit: NSRange? = nil) -> [NSRect] { guard let covered = selectionCoveredRange, covered.length > 0, let storage = textStorage, NSMaxRange(covered) <= storage.length, let container = textView.textContainer else { return [] } - let glyphs = glyphRange(forCharacterRange: covered, actualCharacterRange: nil) + let part = limit.map { NSIntersectionRange(covered, $0) } ?? covered + guard part.length > 0 else { return [] } + let selected = glyphRange(forCharacterRange: covered, actualCharacterRange: nil) + let glyphs = glyphRange(forCharacterRange: part, actualCharacterRange: nil) guard glyphs.length > 0 else { return [] } let origin = textView.textContainerOrigin var rects: [NSRect] = [] - enumerateEnclosingRects(forGlyphRange: glyphs, withinSelectedGlyphRange: glyphs, + enumerateEnclosingRects(forGlyphRange: glyphs, withinSelectedGlyphRange: selected, in: container) { rect, _ in rects.append(rect.offsetBy(dx: origin.x, dy: origin.y)) } @@ -229,7 +235,10 @@ final class DocumentSelectionLayoutManager: NSLayoutManager { super.fillBackgroundRectArray(rectArray, count: rectCount, forCharacterRange: charRange, color: color) return } - let selected = selectionRects(in: textView) + // Only the selection inside this fill's characters can overlap it. + // A table fills a background (and borders) per CELL: the whole + // selection per call made ⌘A on a 200-row table take seconds and GBs. + let selected = selectionRects(in: textView, limitedTo: charRange) guard !selected.isEmpty else { super.fillBackgroundRectArray(rectArray, count: rectCount, forCharacterRange: charRange, color: color) return diff --git a/QuickMD/QuickMDTests/TableSelectionTests.swift b/QuickMD/QuickMDTests/TableSelectionTests.swift index b1b8047..3b9be64 100644 --- a/QuickMD/QuickMDTests/TableSelectionTests.swift +++ b/QuickMD/QuickMDTests/TableSelectionTests.swift @@ -616,4 +616,34 @@ final class TableSelectionTests: XCTestCase { XCTAssertEqual(word(at: index, in: textView), "\u{1F600}", "@\(fraction)") } } + + // MARK: - Selection drawing (table backgrounds are filled per cell) + + /// `fillBackgroundRectArray` runs once per cell background and border of + /// a table, so it asks only for the selection rects of THAT fill's + /// characters (`limitedTo:`); the whole selection per call made ⌘A on a + /// 200-row table cost seconds and gigabytes. The limited calls must add up + /// to exactly the whole selection's rects, each inside its own share. + func testLimitedSelectionRectsAddUpToTheWholeSelection() { + let fixture = tableBlock(Self.multiClickFixtures[1]) + let layout = fixture.text.layout + let textView = liveTextView(fixture.text.string, width: 640) + let manager = textView.layoutManager as! DocumentSelectionLayoutManager + // From inside the first header cell to inside the last cell. + manager.selectionCoveredRange = NSRange(location: 3, length: layout.length - 6) + let whole = Set(manager.selectionRects(in: textView).map(NSStringFromRect)) + XCTAssertGreaterThan(whole.count, 3) + var parts = Set() + for band in layout.cellRanges { + for cell in band { + let fill = NSRange(location: cell.location, length: cell.length + 1) // cell + its "\n" + let rects = Set(manager.selectionRects(in: textView, limitedTo: fill).map(NSStringFromRect)) + XCTAssertTrue(rects.isSubset(of: whole), "cell \(cell)") + parts.formUnion(rects) + } + } + XCTAssertEqual(parts, whole) + XCTAssertTrue(manager.selectionRects(in: textView, + limitedTo: NSRange(location: 0, length: 2)).isEmpty) + } } From 18fa9c2878e5761a09dafa73d7a274a75e943f4f Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Thu, 22 Oct 2026 14:28:16 +0200 Subject: [PATCH 09/10] fix(tables): final review fixes - hit-test: a table clamps the point into its view and asks the cell hook in every direction (TS-D6); text rows keep their edges - whole-table copy reads the built cells (one source of truth, no second renderInline pass), 1.12 parity pinned on rich fixtures - text rows are counted with searchMatchRanges, so count == highlights - drop the unused 4-arg TableText.matches and TableTextLayout.bandRange; stale comments (tables are .exact, on-screen cells are AppKit) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BhzVb6aPQs2FF1bHdkcYys --- QuickMD/QuickMD/DocumentSearch.swift | 14 ++- QuickMD/QuickMD/MarkdownRenderer.swift | 7 +- QuickMD/QuickMD/SelectionRows.swift | 67 +++++++++---- QuickMD/QuickMD/TableTextBuilder.swift | 18 +--- QuickMD/QuickMD/Views/VirtualBlockList.swift | 22 ++--- QuickMD/QuickMDTests/SearchTests.swift | 30 ++++++ .../QuickMDTests/TableSelectionTests.swift | 97 +++++++++++++++++-- QuickMD/QuickMDTests/TableTextTests.swift | 23 +++-- 8 files changed, 201 insertions(+), 77 deletions(-) diff --git a/QuickMD/QuickMD/DocumentSearch.swift b/QuickMD/QuickMD/DocumentSearch.swift index a1ffaac..60ac6fc 100644 --- a/QuickMD/QuickMD/DocumentSearch.swift +++ b/QuickMD/QuickMD/DocumentSearch.swift @@ -103,7 +103,6 @@ enum DocumentSearch { fontScale: CGFloat = 1) -> Results { guard !term.isEmpty else { return Results(matchBlockIds: []) } - let searchLower = term.lowercased() var matches: [String] = [] for block in blocks { @@ -150,14 +149,13 @@ enum DocumentSearch { segments = [] } - // Count individual occurrences across all segments + // Count individual occurrences across all segments — with the + // matcher `applyNSSearchHighlight` draws with, so a text row's + // count is its number of highlights (a length-changing lowercase, + // "İ", switches both to `.caseInsensitive` on the original). for segment in segments { - let textLower = segment.lowercased() - var searchStart = textLower.startIndex - while let range = textLower.range(of: searchLower, range: searchStart.. String { - let renderer = cellRenderer ?? MarkdownRenderer(theme: MarkdownTheme.cached(for: .light)) - cellRenderer = renderer - return String(renderer.renderInline(markdown).characters) - } for row in span where row < blocks.count { let block = blocks[row] switch block.content { - case .table(let headers, let rows, _): + case .table: guard let built = table(block), let covered = selection.range(inRow: row, rowLength: built.string.length), - let piece = tablePiece(built, covered: covered, whole: { - // Same rule as `TableTextLayout.showsHeader`: an all-blank - // header row is not shown, so it is not copied either. - let showsHeader = headers.contains { !$0.trimmingCharacters(in: .whitespaces).isEmpty } - return .table(headers: showsHeader ? headers.map(renderedCell) : nil, - rows: rows.map { $0.map(renderedCell) }) - }) else { continue } + let piece = tablePiece(built, covered: covered) else { continue } pieces.append(piece) case .image(_, let alt, _): guard selection.range(inRow: row, rowLength: 1) != nil else { continue } @@ -85,8 +71,8 @@ enum SelectionRows { } /// The piece for the `covered` part of a table's string (TS-D8): - /// - every cell's text covered (a drag through the table, ⌘A) → `whole()`, - /// which is 1.12's TSV; + /// - every cell's text covered (a drag through the table, ⌘A) → `.table`, + /// 1.12's TSV, read from the string's own cells (`wholeTable`); /// - inside ONE cell → that text, as a text row copies (fonts, links), but /// without the cell's `NSTextTableBlock` and the column's alignment — /// pasted RTF must not turn a word into a one-cell table, nor into a @@ -100,13 +86,12 @@ enum SelectionRows { /// That keeps start and end symmetric — the empty cell a drag starts or /// ends in is not copied, empty cells it crosses are (as empty fields). /// Cells come from `TableTextLayout`, never from re-reading the string. - static func tablePiece(_ table: TableText, covered: NSRange, - whole: () -> SelectionPiece) -> SelectionPiece? { + static func tablePiece(_ table: TableText, covered: NSRange) -> SelectionPiece? { let layout = table.layout let end = NSMaxRange(covered) guard covered.length > 0, covered.location >= 0, layout.length == table.string.length, end <= layout.length, let lastCell = layout.cellRanges.last?.last else { return nil } - if covered.location == 0 && end >= NSMaxRange(lastCell) { return whole() } + if covered.location == 0 && end >= NSMaxRange(lastCell) { return wholeTable(table) } var start = covered.location if let cell = layout.cell(containing: start), @@ -137,6 +122,21 @@ enum SelectionRows { return .tableCells(bands: bands) } + /// The whole table as 1.12 copied it: header (only when the table shows + /// one) and rows, each cell's RENDERED text. Read from the table's own + /// string and layout — the cells `TableTextBuilder` rendered once for the + /// view, the measurer and search — rather than rendering every cell + /// again (1.12 did, on the main thread: a second `renderInline` per cell + /// for ⌘A ⌘C). Bands are already padded to `columnCount`, which is what + /// 1.12's `tsv` padded to (the parser normalizes every row to it). + private static func wholeTable(_ table: TableText) -> SelectionPiece { + let layout = table.layout + let string = table.string.string as NSString + let bands = layout.cellRanges.map { $0.map(string.substring(with:)) } + guard layout.showsHeader, let header = bands.first else { return .table(headers: nil, rows: bands) } + return .table(headers: header, rows: Array(bands.dropFirst())) + } + /// `text` with what belongs to the TABLE removed from its paragraph /// styles: the text blocks (the cell) and the alignment (the column's, /// from the separator row — back to natural). The rest is kept. @@ -247,6 +247,31 @@ enum SelectionRows { // MARK: - Points in a text view (S-D6 / TS-D6 / TS-D7) +extension SelectionRows { + /// The selection offset for `local` (the row's text-view coordinates, + /// flipped) — `SelectionController.hit` for a row with a registered view. + /// `characterIndex` is the view's hit-test hook + /// (`SelfSizingTextView.selectionCharacterIndex`). + /// + /// A text row: above its view = the row's start, below = its end (the + /// gap and the chrome around the text belong to the row's edges), beside + /// it = the line's start or end. A TABLE (`isTable`) clamps the point into + /// the view and asks the hook in every direction, which snaps it to the + /// nearest band and cell (TS-D6): a drag along the header that slips a + /// few points above the table must stay in the header cells, not jump to + /// the start of the table; below the last band likewise. + static func hitOffset(at local: NSPoint, viewSize: NSSize, length: Int, isTable: Bool, + characterIndex: (NSPoint) -> Int) -> Int { + if !isTable { + if local.y < 0 { return 0 } + if local.y > viewSize.height { return length } + } + let clamped = NSPoint(x: min(max(local.x, 0), viewSize.width), + y: min(max(local.y, 0), viewSize.height)) + return min(max(0, characterIndex(clamped)), length) + } +} + extension NSTextView { /// The character whose glyph is under `local` (view coordinates), or nil /// past the end. With `requireInside`, only when the point is ON the diff --git a/QuickMD/QuickMD/TableTextBuilder.swift b/QuickMD/QuickMD/TableTextBuilder.swift index fbad35b..e3a2585 100644 --- a/QuickMD/QuickMD/TableTextBuilder.swift +++ b/QuickMD/QuickMD/TableTextBuilder.swift @@ -31,7 +31,7 @@ import SwiftUI /// carries the cell's paragraph style (its `NSTextTableBlock`), and the last /// cell's "\n" is the last character of the string. /// - A band ("row" on screen) is the contiguous range from its first cell's -/// location to its last cell's "\n" inclusive (`bandRange(_:)`). +/// location to its last cell's "\n" inclusive. /// - Every band has `columnCount` cells: ragged rows are padded with empty /// cells, so the column of a character is always well defined. /// - Offsets are UTF-16 (NSString), the unit `DocumentSelection` uses. @@ -50,12 +50,6 @@ struct TableTextLayout: Equatable { return NSMaxRange(last) + 1 } - /// The whole band including the "\n" of its last cell. - func bandRange(_ band: Int) -> NSRange { - let first = cellRanges[band][0].location - return NSRange(location: first, length: NSMaxRange(cellRanges[band][columnCount - 1]) + 1 - first) - } - /// The cell a character index falls in; a cell's own "\n" counts as that /// cell. nil before the start or past the end. /// @@ -116,16 +110,6 @@ struct TableText { theme: MarkdownTheme, fontScale: CGFloat) -> Bool { source == Source(headers: headers, rows: rows, alignments: alignments, theme: theme, fontScale: fontScale) } - - /// Content and zoom only. For `MarkdownView.selectableText`, whose cached - /// instance is already pinned to the installed parse (`installedVersion == - /// contentVersion`, and every theme/font change is a new parse); prefer - /// `matches(…theme:…)` everywhere else. - func matches(headers: [String], rows: [[String]], alignments: [TextAlignment], - fontScale: CGFloat) -> Bool { - source.headers == headers && source.rows == rows && source.alignments == alignments - && source.fontScale == fontScale - } } /// An `NSTextTable` whose columns fill the available width exactly. diff --git a/QuickMD/QuickMD/Views/VirtualBlockList.swift b/QuickMD/QuickMD/Views/VirtualBlockList.swift index f50ecbd..b49629a 100644 --- a/QuickMD/QuickMD/Views/VirtualBlockList.swift +++ b/QuickMD/QuickMD/Views/VirtualBlockList.swift @@ -34,9 +34,9 @@ import os // Feedback discipline (the reason this is not the LazyVStack estimator again): // rows whose height the measurer can compute exactly (`RowKind.exact`) never // report anything. Rows whose real size only exists once a view is placed -// (`.reported`: headings, tables, images, display math, Mermaid) report their -// natural height ONCE per model generation; the coordinator applies the -// correction and compensates the scroll offset. No unplaced row is ever +// (`.reported`: headings, images, display math, Mermaid — tables are `.exact` +// since v1.13) report their natural height ONCE per model generation; the +// coordinator applies the correction and compensates the scroll offset. No unplaced row is ever // re-estimated, and no row's height is a function of the height we gave it (the // hosted content is `fixedSize`d vertically, so the row height cannot feed back // into the measured height). @@ -1533,7 +1533,7 @@ final class BlockHostingCell: NSTableCellView { } /// The highlight over a selected atomic row (S-D3): the selection colour at a -/// third of its strength, so the table / image / diagram stays readable under it. +/// third of its strength, so the image / diagram stays readable under it. /// /// Drawn in AppKit, above the hosted SwiftUI content — and invisible to the /// mouse: `hitTest` answers nil, so the Mermaid diagram's transparent preview @@ -1989,16 +1989,10 @@ final class SelectionController: NSObject { if let textView = registeredTextView(for: block.id, in: tableView) { let local = textView.convert(windowPoint, from: nil) // flipped - let length = textView.selectionTextLength - let offset: Int - if local.y < 0 { - offset = 0 - } else if local.y > textView.bounds.height { - offset = length - } else { - let clamped = NSPoint(x: min(max(local.x, 0), textView.bounds.width), y: local.y) - offset = min(max(0, textView.selectionCharacterIndex(at: clamped)), length) - } + let offset = SelectionRows.hitOffset(at: local, viewSize: textView.bounds.size, + length: textView.selectionTextLength, + isTable: textView is TableTextView, + characterIndex: textView.selectionCharacterIndex(at:)) return Hit(point: SelectionPoint(row: row, offset: offset), isAtomic: false, textView: textView, pointInTextView: local) } diff --git a/QuickMD/QuickMDTests/SearchTests.swift b/QuickMD/QuickMDTests/SearchTests.swift index fb81287..11e962b 100644 --- a/QuickMD/QuickMDTests/SearchTests.swift +++ b/QuickMD/QuickMDTests/SearchTests.swift @@ -284,6 +284,36 @@ final class SearchTests: XCTestCase { } } + /// A text row containing "İ": its count is exactly what its view + /// highlights, for every term (the count used the lowercased Swift + /// `String.range` loop while the highlight used `searchMatchRanges`, which + /// searches the original `.caseInsensitive` when lowercasing changes the + /// length — the two disagreed, e.g. on a dotted capital next to a + /// combining dot). + func testTextRowWithDottedCapitalCountEqualsHighlights() throws { + let theme = MarkdownTheme.cached(for: .light) + let markdown = "İİi\u{307} İstanbul, istanbul and ISTANBUL; x İ x." + let blocks = parse(markdown) + let block = try XCTUnwrap(blocks.first) + guard case .text(let attributed) = block.content else { return XCTFail("not a text row") } + let shown = BlockTextConverter.makeNSAttributedString(from: attributed, hasInlineMath: false, + theme: theme, fontScale: 1, math: .none) + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 10)) + textView.textStorage?.setAttributedString(shown) + for term in ["i", "I", "İ", "stanbul", "ISTANBUL", "x", "İ\u{307}", "\u{307}", "i\u{307}"] { + let tag = "term \(term.unicodeScalars.map { String($0.value, radix: 16) })" + // What `applyNSSearchHighlight` draws, one range per occurrence + // (adjacent runs of the drawn attribute would merge, so the + // drawing is checked by total length). + let occurrences = searchMatchRanges(in: textView.string, term: term) + applyNSSearchHighlight(in: textView, term: term, focusedOccurrence: nil) + XCTAssertEqual(highlights(in: textView).map(\.range.length).reduce(0, +), + occurrences.map(\.length).reduce(0, +), tag) + let counted = DocumentSearch.computeMatches(in: blocks, term: term).matchBlockIds.count + XCTAssertEqual(counted, occurrences.count, tag) + } + } + /// Paragraphs and tables interleave in document order; each table counts /// its rendered matches only. func testMixedDocumentCountsParagraphsAndTablesInOrder() { diff --git a/QuickMD/QuickMDTests/TableSelectionTests.swift b/QuickMD/QuickMDTests/TableSelectionTests.swift index 3b9be64..508ebb3 100644 --- a/QuickMD/QuickMDTests/TableSelectionTests.swift +++ b/QuickMD/QuickMDTests/TableSelectionTests.swift @@ -56,20 +56,21 @@ final class TableSelectionTests: XCTestCase { return (block, table(block)!) } - /// 1.12's whole-table copy for `block` (what `SelectionRows.pieces` hands - /// `tablePiece` as `whole`). + /// 1.12's whole-table copy for `block`, built as 1.12 built it (each + /// cell's markdown rendered again, the header only when shown) — the + /// parity oracle for `SelectionRows`, which reads the built cells. private func wholeTablePiece(_ block: MarkdownBlock) -> SelectionPiece { guard case .table(let headers, let rows, _) = block.content else { fatalError("not a table") } - let renderer = MarkdownRenderer(theme: theme) + let renderer = MarkdownRenderer(theme: MarkdownTheme.cached(for: .light)) func rendered(_ cell: String) -> String { String(renderer.renderInline(cell).characters) } - return .table(headers: headers.map(rendered), rows: rows.map { $0.map(rendered) }) + let showsHeader = headers.contains { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + return .table(headers: showsHeader ? headers.map(rendered) : nil, rows: rows.map { $0.map(rendered) }) } /// The copy of `covered` in the fixture table. private func copy(_ covered: NSRange, of fixture: (block: MarkdownBlock, text: TableText)? = nil) -> DocumentCopyOutput? { let fixture = fixture ?? tableBlock() - guard let piece = SelectionRows.tablePiece(fixture.text, covered: covered, - whole: { wholeTablePiece(fixture.block) }) else { return nil } + guard let piece = SelectionRows.tablePiece(fixture.text, covered: covered) else { return nil } return DocumentCopyBuilder.build([piece]) } @@ -202,6 +203,49 @@ final class TableSelectionTests: XCTestCase { XCTAssertEqual(copy(NSRange(location: 0, length: length - 1))?.plain, expected) } + /// The whole-table copy is read from the BUILT cells; on every kind of + /// cell content it is character for character what 1.12 produced by + /// rendering each cell's markdown again. + func testWholeTableCopyHas112ParityOnRichFixtures() { + let fixtures = [ + """ + | Feature | Description | Status | + |:--------|:-----------:|-------:| + | **Search** | See [the docs](https://example.com/docs) and *more* | `done` | + | Code | `let x = a \\| b` inline ~~old~~ | ok | + | Escaped | a \\| b | c | + """, + """ + | A | B | C | + |---|---|---| + | 1 | + | 1 | 2 | 3 | 4 | + | | x | + """, + """ + | | | | + |---|---|---| + | a | **b** | + | [l](https://x.y) | `c` | $x$ | + """, + """ + | Only | Header | + |---|---| + """, + ] + for markdown in fixtures { + let fixture = tableBlock(markdown) + let length = fixture.text.string.length + let oracle = DocumentCopyBuilder.build([wholeTablePiece(fixture.block)]).plain + let copied = DocumentCopyBuilder.build(SelectionRows.pieces( + of: DocumentSelection(anchor: SelectionPoint(row: 0, offset: 0), + focus: SelectionPoint(row: 0, offset: length)), + blocks: [fixture.block], text: text, table: table)).plain + XCTAssertEqual(copied, oracle, markdown) + XCTAssertFalse(copied.isEmpty, markdown) + } + } + func testHeaderlessTableCopiesWithoutAHeaderLine() { let fixture = tableBlock(""" | | | @@ -444,6 +488,47 @@ final class TableSelectionTests: XCTestCase { return textView } + /// TS-D6: a drag that leaves a TABLE's view (above the header, below the + /// last band, beside it) snaps to the nearest cell — the hook is asked + /// with the point clamped into the view — while a text row keeps its + /// edges (above = start, below = end). + func testHitOffsetOutsideATableSnapsToTheNearestCell() throws { + let built = tableBlock().text + let layout = built.layout + let textView = liveTextView(built.string, width: 600) + let height = TableTextBuilder.tableHeight(layoutManager: textView.layoutManager!, + container: textView.textContainer!) + textView.setFrameSize(NSSize(width: 600, height: height)) + let size = textView.bounds.size + let length = built.string.length + let hook = { (point: NSPoint) in textView.characterIndex(confinedToCellAt: point, layout: layout) } + func cell(_ point: NSPoint, isTable: Bool = true) -> (band: Int, column: Int)? { + layout.cell(containing: SelectionRows.hitOffset(at: point, viewSize: size, length: length, + isTable: isTable, characterIndex: hook)) + } + let header = try XCTUnwrap(textView.tableCellBounds(band: 0, column: 1, layout: layout)) + let last = try XCTUnwrap(textView.tableCellBounds(band: layout.bandCount - 1, column: 1, layout: layout)) + let band1 = try XCTUnwrap(textView.tableCellBounds(band: 1, column: 0, layout: layout)) + + for dy in [CGFloat(2), 30] { + let above = cell(NSPoint(x: header.midX, y: -dy)) + XCTAssertEqual(above?.band, 0, "\(dy) pt above the header") + XCTAssertEqual(above?.column, 1, "\(dy) pt above the header") + let below = cell(NSPoint(x: last.midX, y: size.height + dy)) + XCTAssertEqual(below?.band, layout.bandCount - 1, "\(dy) pt below the last band") + XCTAssertEqual(below?.column, 1, "\(dy) pt below the last band") + } + let beside = cell(NSPoint(x: -20, y: band1.midY)) + XCTAssertEqual(beside?.band, 1) + XCTAssertEqual(beside?.column, 0) + + // A text row (`isTable: false`): its edges, as before v1.13. + XCTAssertEqual(SelectionRows.hitOffset(at: NSPoint(x: header.midX, y: -2), viewSize: size, length: length, + isTable: false, characterIndex: hook), 0) + XCTAssertEqual(SelectionRows.hitOffset(at: NSPoint(x: last.midX, y: size.height + 2), viewSize: size, + length: length, isTable: false, characterIndex: hook), length) + } + private static let multiClickFixtures = [tableMarkdown, """ | Feature | Description | Status | |:--------|:-----------:|-------:| diff --git a/QuickMD/QuickMDTests/TableTextTests.swift b/QuickMD/QuickMDTests/TableTextTests.swift index db239be..ad87f25 100644 --- a/QuickMD/QuickMDTests/TableTextTests.swift +++ b/QuickMD/QuickMDTests/TableTextTests.swift @@ -8,7 +8,7 @@ import AppKit /// 1. The string layout (TS-D4) — the row's selectable string, so its offsets /// are what the document selection, copy and search index into: header band /// first (only when shown), then rows, left to right; one paragraph per -/// cell; ragged rows padded; `cellRanges` / `bandRange` / `cell(containing:)`. +/// cell; ragged rows padded; `cellRanges` / bands / `cell(containing:)`. /// 2. Parity (TS-D3) — the row is `.exact`, so the measurer's height must be /// the height the table really takes in a text view configured as /// `TableTextView` is (`configureForSelfSizing` + @@ -159,15 +159,22 @@ final class TableTextTests: XCTestCase { } } - func testBandRangeAndCellContaining() { + /// A band: from its first cell's location to its last cell's "\n" inclusive. + private func bandRange(_ layout: TableTextLayout, _ band: Int) -> NSRange { + let first = layout.cellRanges[band][0].location + return NSRange(location: first, + length: NSMaxRange(layout.cellRanges[band][layout.columnCount - 1]) + 1 - first) + } + + func testBandsAreContiguousAndCellContaining() { let built = TableTextBuilder.build(headers: ["H1", "H2"], rows: [["a", ""], ["bb", "c"]], alignments: [], theme: theme, fontScale: 1) // "H1\nH2\na\n\nbb\nc\n" let layout = built.layout - XCTAssertEqual(layout.bandRange(0), NSRange(location: 0, length: 6)) - XCTAssertEqual(layout.bandRange(1), NSRange(location: 6, length: 3)) - XCTAssertEqual(layout.bandRange(2), NSRange(location: 9, length: 5)) - XCTAssertEqual(NSMaxRange(layout.bandRange(2)), built.string.length) + XCTAssertEqual(bandRange(layout, 0), NSRange(location: 0, length: 6)) + XCTAssertEqual(bandRange(layout, 1), NSRange(location: 6, length: 3)) + XCTAssertEqual(bandRange(layout, 2), NSRange(location: 9, length: 5)) + XCTAssertEqual(NSMaxRange(bandRange(layout, 2)), built.string.length) let expected: [(Int, Int)] = [(0, 0), (0, 0), (0, 0), (0, 1), (0, 1), (0, 1), (1, 0), (1, 0), (1, 1), @@ -240,9 +247,9 @@ final class TableTextTests: XCTestCase { XCTAssertEqual(carried?.string.string, build(t).string.string) XCTAssertEqual(carried?.layout, build(t).layout) XCTAssertTrue(carried?.matches(headers: t.headers, rows: t.rows, alignments: t.alignments, - fontScale: 1) ?? false) + theme: theme, fontScale: 1) ?? false) XCTAssertFalse(carried?.matches(headers: t.headers, rows: t.rows, alignments: t.alignments, - fontScale: 1.5) ?? true, "another zoom is another string") + theme: theme, fontScale: 1.5) ?? true, "another zoom is another string") } /// measurer row == laid-out view + outer padding, to the point, for every From c5ea9df35928227e2ddc72b82b6e12fcf903d8d2 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Thu, 22 Oct 2026 14:34:45 +0200 Subject: [PATCH 10/10] docs: select text inside tables (README, CHANGELOG) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BhzVb6aPQs2FF1bHdkcYys --- QuickMD/CHANGELOG.md | 5 +++++ README.md | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/QuickMD/CHANGELOG.md b/QuickMD/CHANGELOG.md index 01d23e8..e24e91c 100644 --- a/QuickMD/CHANGELOG.md +++ b/QuickMD/CHANGELOG.md @@ -9,10 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Reload** (⌘R, View ▸ Reload, or the ↻ button in the top-right cluster): re-reads the document from disk now and restarts the file watcher, for the times auto-reload does not notice a change (some network volumes and editors' save strategies). A toast says whether anything changed, or that the file could not be read; a file that is gone shows the "File no longer exists" banner, which clears by itself when the file comes back. Off while Source Edit shows, where the editor already follows the disk. Contributed by [@rinh](https://github.com/rinh) (#37). +- **Select text inside tables:** a word, a whole cell (triple-click) or any range of cells, starting or ending anywhere in the document. A selection inside one cell copies as plain text, across cells as tab-separated values; a whole table copies exactly as before. Asked for by [@sporto](https://github.com/sporto) (#33). + +### Changed +- Search inside tables counts what the table shows: a term that appears only in Markdown markup or in a link's address is no longer counted (it was counted but never highlighted), and stepping with ⌘G follows the cells in reading order. ### Fixed - Full screen and Split View: opening another file adds a tab in place. Before, the tab group moved to a full-screen Space of its own, leaving the Split View broken, half the screen black and no menu bar. A full-screen window size is also no longer remembered as the size for new windows. Contributed by [@onemannoplan](https://github.com/onemannoplan) (#36). - The Support pill (GitHub build) no longer keeps a blue focus ring after a click when keyboard navigation is on in System Settings. +- Search highlights no longer shift after letters that change length when lowercased (such as the Turkish dotted capital İ). ## [1.12.0] - 2026-10-05 diff --git a/README.md b/README.md index 1116d95..679a4b8 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Perfect for developers, writers, students, and anyone who works with Markdown da - Word-level highlighting across all block types (text, code, tables, blockquotes) - Table of Contents sidebar (`⌘⇧T`) — auto-generated from headings - Reading mode (`⌘⇧R`) — hides both sidebars and the hover buttons and centres the text in a 720 pt column; `Esc` brings everything back -- **Select across the whole document** — drag through paragraphs, headings, lists, quotes, tables and code (it scrolls for you), Shift-click to extend, `⌘A` / `⌘C` to copy clean text (tables as tab-separated values, math as LaTeX). Every copy shows how many characters and words were copied; optional auto-copy of selections in Settings → General +- **Select across the whole document** — drag through paragraphs, headings, lists, quotes, code and tables (a word, a cell or a range of cells), it scrolls for you, Shift-click to extend, `⌘A` / `⌘C` to copy clean text (table cells as tab-separated values, math as LaTeX). Every copy shows how many characters and words were copied; optional auto-copy of selections in Settings → General - Copy entire document (`⌘⇧C`) or individual sections (hover heading → copy icon) - Export to PDF (`⌘⇧E`) — **vector text** (selectable, searchable) with **rendered Mermaid diagrams** — and Print (`⌘P`)