diff --git a/CHANGELOG.md b/CHANGELOG.md index 61139d080..1c39fdff7 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. - Format Query crashed the app on a string literal that was still open and ended in a backslash, as in `select * from t where c like 'C:\`. It formats such a query without crashing now. Format Query also used to move the last character of an unclosed `/*` comment out of the comment and reformat it as code; the whole comment is left alone now. - Pasting a large block of text into the query editor could crash the app. A paste of more than about a thousand characters is parsed in the background, and the editor's syntax highlighting was updated from that background work while the editor was still applying the same paste on screen. Highlighting is now updated on the main thread again, as the rest of the editor already did. (#2158) - 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) 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) + } +}