From 60494dc915894b3ef134c77dfe36f9f350f0815c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 17 Aug 2026 20:40:38 +0700 Subject: [PATCH] fix(editor): lay out wrapped text once per row instead of once per prefix --- CHANGELOG.md | 1 + .../TextLine/Typesetter/Typesetter.swift | 7 +- .../Editor/TypesetterWrapLengthTests.swift | 88 +++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 TableProTests/Views/Editor/TypesetterWrapLengthTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c72263f2..4160022d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Viewing a very long single line with word wrap on could use enormous amounts of memory and stall or kill the app. This hit the JSON value viewer, chat code blocks and the SQL review sheet, which always wrap, and the SQL editor when Word Wrap is on. Wrapped text is now laid out once instead of once per wrapped row, so a long line stays fast no matter how long it is. - A strip along the right edge of the SQL editor, as wide as 140 points, took clicks and did nothing with them. Clicking there now puts the caret on the line you clicked, like any other empty part of the editor. The same strip sat in the trigger editor, the JSON view, the structure DDL, the SQL review sheet, the import preview and chat code blocks, where it swallowed text selection instead. (#2156) - Query results were read-only whenever the `SELECT` gave its table an alias, as in `select * from users u where u.id = 1`. Editing works on those results now, and also on queries written across several lines, preceded by a comment, or ending in `FOR UPDATE`. (#2150) - A `UNION`, `EXCEPT` or `INTERSECT` result could be edited as though it were a single table, and the edit was written to one of the branches rather than to the rows on screen. Those results are read-only now. So are results from a join, a subquery, a CTE, a temporal `FOR SYSTEM_TIME` read, and `FROM ONLY`. diff --git a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift index b5edb8594..fa86be756 100644 --- a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift +++ b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift @@ -180,8 +180,11 @@ final public class Typesetter { constrainingWidth: displayData.maxWidth - context.fragmentContext.width ) - // Indicates the subrange on the range that the typesetter knows about. This may not be the entire line - let typesetSubrange = NSRange(location: context.currentPosition - range.location, length: lineBreak) + // Indicates the subrange on the range that the typesetter knows about. This may not be the entire line. + // `lineBreak` is an offset into the run, not a length, so the fragment has to be measured from where + // this fragment starts. Using it as a length re-typesets everything before it once per fragment. + let startOffset = context.currentPosition - range.location + let typesetSubrange = NSRange(location: startOffset, length: lineBreak - startOffset) let typesetData = typesetLine(typesetter: typesetter, range: typesetSubrange) // The typesetter won't tell us if 0 characters can fit in the constrained space. This checks to diff --git a/TableProTests/Views/Editor/TypesetterWrapLengthTests.swift b/TableProTests/Views/Editor/TypesetterWrapLengthTests.swift new file mode 100644 index 000000000..39250aa66 --- /dev/null +++ b/TableProTests/Views/Editor/TypesetterWrapLengthTests.swift @@ -0,0 +1,88 @@ +// +// TypesetterWrapLengthTests.swift +// TableProTests +// +// Regression tests for wrapped line typesetting. `suggestLineBreak` returns an offset into the run, +// not a length, but the typesetter passed it straight through as the CTLine length. Every fragment +// after the first then re-typeset all the text before it, so glyph work and retained memory grew +// with the square of the line length and a long single line could exhaust memory. +// +// These live here rather than in CodeEditTextViewTests because the TablePro scheme does not run +// that package's test target, so a test there would never gate a regression. +// + +import AppKit +@testable import CodeEditTextView +import Foundation +import Testing + +@MainActor +@Suite("Typesetter wrapped fragment lengths") +struct TypesetterWrapLengthTests { + private static let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedSystemFont(ofSize: 10, weight: .regular) + ] + + private func typesetWrapped(characterCount: Int, maxWidth: CGFloat) -> Typesetter { + let typesetter = Typesetter() + typesetter.typeset( + NSAttributedString(string: String(repeating: "A", count: characterCount), attributes: Self.attributes), + documentRange: NSRange(location: 0, length: characterCount), + displayData: TextLine.DisplayData( + maxWidth: maxWidth, + lineHeightMultiplier: 1.0, + estimatedLineHeight: 20.0, + breakStrategy: .character + ), + markedRanges: nil, + attachments: [] + ) + return typesetter + } + + @Test("A wrapped line typesets each character exactly once") + func wrappedLineTypesetsEachCharacterOnce() { + let characterCount = 1_000 + let typesetter = typesetWrapped(characterCount: characterCount, maxWidth: 150) + + var typesetCharacters = 0 + for fragment in typesetter.lineFragments { + typesetCharacters += fragment.data.contents.reduce(0) { $0 + $1.length } + } + + #expect( + typesetCharacters == characterCount, + "Each character must be typeset once, not once per following fragment" + ) + } + + @Test("Each wrapped fragment typesets exactly the characters it covers") + func eachFragmentTypesetsOnlyItsOwnCharacters() { + let typesetter = typesetWrapped(characterCount: 1_000, maxWidth: 150) + + for fragment in typesetter.lineFragments { + let typesetLength = fragment.data.contents.reduce(0) { $0 + $1.length } + #expect( + typesetLength == fragment.range.length, + "A fragment covering \(fragment.range.length) characters typeset \(typesetLength)" + ) + } + } + + @Test("Wrapping stays linear as the line grows") + func wrappingStaysLinearAsTheLineGrows() { + let small = typesetWrapped(characterCount: 1_000, maxWidth: 150) + let large = typesetWrapped(characterCount: 4_000, maxWidth: 150) + + func typesetCharacters(in typesetter: Typesetter) -> Int { + var total = 0 + for fragment in typesetter.lineFragments { + total += fragment.data.contents.reduce(0) { $0 + $1.length } + } + return total + } + + // Four times the text must cost four times the typesetting, not sixteen. + #expect(typesetCharacters(in: large) == typesetCharacters(in: small) * 4) + } +}