Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,16 @@ public final class EmphasisManager {
emphasisGroups[id, default: []].map(\.emphasis)
}

/// The tool tip an emphasis presents at a point in the text view, or `nil` where none is registered.
///
/// Emphases own their tool tips, and AppKit only ever asks the owner. This is how anything outside the
/// manager reads what the editor would actually show under the pointer.
public func toolTip(at point: CGPoint) -> String? {
guard let textView else { return nil }
let text = toolTips.view(textView, stringForToolTip: 0, point: point, userData: nil)
return text.isEmpty ? nil : text
}

private func registerToolTip(for emphasisLayer: EmphasisLayer) {
guard emphasisLayer.emphasis.toolTip != nil, emphasisLayer.isAttached else { return }
toolTips.register(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//
// EditorKeyMonitorCompositionTests.swift
// TableProTests
// TableProEditorKitTests
//

import AppKit
Expand Down Expand Up @@ -63,7 +63,7 @@ internal struct EditorKeyChord: Sendable, CustomTestStringConvertible {

@MainActor
func event(in window: NSWindow?) -> NSEvent? {
EditorControllerFixture.keyDown(keyCode: keyCode, characters: characters, modifiers: modifiers, in: window)
Mock.keyDown(keyCode: keyCode, characters: characters, modifiers: modifiers, in: window)
}
}

Expand Down Expand Up @@ -101,10 +101,10 @@ private struct StubSuggestionEntry: CodeSuggestionEntry {
internal struct EditorKeyMonitorCompositionTests {
@Test("The editor leaves its plain keys to the input method mid-composition", arguments: EditorKeyChord.inputMethodKeys)
func editorKeysDeferToComposition(chord: EditorKeyChord) throws {
let (window, editor) = EditorControllerFixture.makeFocusedInWindow(string: " SELECT 1\nFROM ")
let (window, editor) = Mock.focusedTextViewController(string: " SELECT 1\nFROM ")
let delegate = RecordingCompletionDelegate()
editor.completionDelegate = delegate
EditorControllerFixture.beginComposition("le", in: editor.textView)
Mock.beginComposition("le", in: editor.textView)
let composed = editor.textView.string
let event = try #require(chord.event(in: window))

Expand All @@ -115,10 +115,10 @@ internal struct EditorKeyMonitorCompositionTests {

@Test("The editor keeps its Command chords from the menu bar mid-composition and runs none", arguments: EditorKeyChord.commandChords)
func editorCommandChordsWithheldDuringComposition(chord: EditorKeyChord) throws {
let (window, editor) = EditorControllerFixture.makeFocusedInWindow(string: " SELECT 1\nFROM ")
let (window, editor) = Mock.focusedTextViewController(string: " SELECT 1\nFROM ")
let delegate = RecordingCompletionDelegate()
editor.completionDelegate = delegate
EditorControllerFixture.beginComposition("le", in: editor.textView)
Mock.beginComposition("le", in: editor.textView)
let composed = editor.textView.string
let event = try #require(chord.event(in: window))

Expand All @@ -129,8 +129,8 @@ internal struct EditorKeyMonitorCompositionTests {

@Test("A Command chord the editor does not own reaches the menu bar mid-composition", arguments: EditorKeyChord.foreignCommandChords)
func foreignCommandChordsPassDuringComposition(chord: EditorKeyChord) throws {
let (window, editor) = EditorControllerFixture.makeFocusedInWindow(string: "SELECT ")
EditorControllerFixture.beginComposition("le", in: editor.textView)
let (window, editor) = Mock.focusedTextViewController(string: "SELECT ")
Mock.beginComposition("le", in: editor.textView)
let event = try #require(chord.event(in: window))

#expect(editor.handleEvent(event: event) === event)
Expand All @@ -139,11 +139,11 @@ internal struct EditorKeyMonitorCompositionTests {

@Test("A composition the input method empties gives the editor its keys back", arguments: EditorKeyChord.editorCommands)
func emptiedCompositionReturnsKeysToEditor(chord: EditorKeyChord) throws {
let (window, editor) = EditorControllerFixture.makeFocusedInWindow(string: " SELECT 1\nFROM ")
let (window, editor) = Mock.focusedTextViewController(string: " SELECT 1\nFROM ")
let delegate = RecordingCompletionDelegate()
editor.completionDelegate = delegate
EditorControllerFixture.beginComposition("le", in: editor.textView)
EditorControllerFixture.emptyComposition(in: editor.textView)
Mock.beginComposition("le", in: editor.textView)
Mock.emptyComposition(in: editor.textView)
try #require(editor.textView.hasMarkedText() == false)
let event = try #require(chord.event(in: window))

Expand All @@ -152,7 +152,7 @@ internal struct EditorKeyMonitorCompositionTests {

@Test("The editor claims each of those keys when nothing is composing", arguments: EditorKeyChord.editorCommands)
func editorCommandsClaimSettledText(chord: EditorKeyChord) throws {
let (window, editor) = EditorControllerFixture.makeFocusedInWindow(string: " SELECT 1\nFROM ")
let (window, editor) = Mock.focusedTextViewController(string: " SELECT 1\nFROM ")
let delegate = RecordingCompletionDelegate()
editor.completionDelegate = delegate
let event = try #require(chord.event(in: window))
Expand All @@ -162,10 +162,10 @@ internal struct EditorKeyMonitorCompositionTests {

@Test("The completion list leaves its keys to the input method mid-composition", arguments: EditorKeyChord.completionListKeys)
func completionListDefersToComposition(chord: EditorKeyChord) throws {
let (window, editor) = EditorControllerFixture.makeFocusedInWindow(string: "SELECT ")
let (window, editor) = Mock.focusedTextViewController(string: "SELECT ")
let delegate = RecordingCompletionDelegate()
let panel = makeCompletionList(for: editor, delegate: delegate)
EditorControllerFixture.beginComposition("le", in: editor.textView)
Mock.beginComposition("le", in: editor.textView)
let event = try #require(chord.event(in: window))

#expect(panel.handleKeyDown(event) === event)
Expand All @@ -175,7 +175,7 @@ internal struct EditorKeyMonitorCompositionTests {

@Test("The completion list claims its keys when nothing is composing", arguments: EditorKeyChord.completionListKeys)
func completionListClaimsSettledText(chord: EditorKeyChord) throws {
let (window, editor) = EditorControllerFixture.makeFocusedInWindow(string: "SELECT ")
let (window, editor) = Mock.focusedTextViewController(string: "SELECT ")
let delegate = RecordingCompletionDelegate()
let panel = makeCompletionList(for: editor, delegate: delegate)
let event = try #require(chord.event(in: window))
Expand All @@ -185,21 +185,21 @@ internal struct EditorKeyMonitorCompositionTests {

@Test("Escape mid-composition in the editor leaves the find panel open")
func findPanelDefersToEditorComposition() throws {
let (window, editor) = EditorControllerFixture.makeFocusedInWindow(string: "SELECT ")
let (window, editor) = Mock.focusedTextViewController(string: "SELECT ")
let finder = try #require(editor.findViewController)
finder.showFindPanel(animated: false)
defer { finder.hideFindPanel(animated: false) }
_ = window.makeFirstResponder(editor.textView)
EditorControllerFixture.beginComposition("le", in: editor.textView)
let escape = try #require(EditorControllerFixture.keyDown(keyCode: kVK_Escape, characters: "\u{1b}", in: window))
Mock.beginComposition("le", in: editor.textView)
let escape = try #require(Mock.keyDown(keyCode: kVK_Escape, characters: "\u{1b}", in: window))

#expect(finder.findPanel.handleKeyDown(escape) === escape)
#expect(finder.viewModel.isShowingFindPanel)
}

@Test("Escape mid-composition in a text field leaves the find panel open")
func findPanelDefersToFieldComposition() throws {
let (window, editor) = EditorControllerFixture.makeFocusedInWindow(string: "SELECT ")
let (window, editor) = Mock.focusedTextViewController(string: "SELECT ")
let finder = try #require(editor.findViewController)
finder.showFindPanel(animated: false)
defer { finder.hideFindPanel(animated: false) }
Expand All @@ -213,20 +213,20 @@ internal struct EditorKeyMonitorCompositionTests {
replacementRange: NSRange(location: NSNotFound, length: 0)
)
try #require(fieldEditor.hasMarkedText())
let escape = try #require(EditorControllerFixture.keyDown(keyCode: kVK_Escape, characters: "\u{1b}", in: window))
let escape = try #require(Mock.keyDown(keyCode: kVK_Escape, characters: "\u{1b}", in: window))

#expect(finder.findPanel.handleKeyDown(escape) === escape)
#expect(finder.viewModel.isShowingFindPanel)
}

@Test("Escape with nothing composing closes the find panel")
func findPanelClosesOnSettledEscape() throws {
let (window, editor) = EditorControllerFixture.makeFocusedInWindow(string: "SELECT ")
let (window, editor) = Mock.focusedTextViewController(string: "SELECT ")
let finder = try #require(editor.findViewController)
finder.showFindPanel(animated: false)
defer { finder.hideFindPanel(animated: false) }
_ = window.makeFirstResponder(editor.textView)
let escape = try #require(EditorControllerFixture.keyDown(keyCode: kVK_Escape, characters: "\u{1b}", in: window))
let escape = try #require(Mock.keyDown(keyCode: kVK_Escape, characters: "\u{1b}", in: window))

#expect(finder.findPanel.handleKeyDown(escape) == nil)
#expect(finder.viewModel.isShowingFindPanel == false)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
//
// EditorPeripheralSurfaceTests.swift
// TableProTests
// TableProEditorKitTests
//

import AppKit
@testable import TablePro
@testable import TableProEditorKit
import TableProGrammars
import TableProTextEngine
Expand All @@ -24,14 +23,21 @@ struct EditorPeripheralSurfaceTests {
init(language: CodeLanguage = .default) {
let configuration = SourceEditorConfiguration(
appearance: .init(
theme: EditorControllerFixture.theme,
theme: Mock.theme(),
font: .monospacedSystemFont(ofSize: 12, weight: .regular),
lineHeightMultiple: 1.0,
wrapLines: false,
tabWidth: 4
),
layout: .init(contentInsets: NSEdgeInsets(top: 0, left: 0, bottom: 8, right: 0)),
peripherals: EditorPeripherals.editor(lineNumbers: true, folding: false)
peripherals: .init(
showGutter: true,
showLineNumbers: true,
showFoldingRibbon: false,
showStatementRunControls: false,
gutterFitsContent: false,
showSpecialCharacters: true
)
)
controller = TextViewController(
string: "SELECT * FROM users;\nSELECT * FROM orders;",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//
// SQLEditorLongLineScrollTests.swift
// TableProTests
// TableProEditorKitTests
//

import AppKit
Expand All @@ -17,7 +17,7 @@ struct SQLEditorLongLineScrollTests {

@Test("Undoing a long paste narrows the editor and brings back the start of every line")
func undoingALongPasteReturnsToTheLineStarts() throws {
let controller = EditorControllerFixture.make(string: query)
let controller = Mock.loadedTextViewController(string: query)
let end = (query as NSString).length
let narrowWidth = controller.textView.frame.width

Expand All @@ -36,7 +36,7 @@ struct SQLEditorLongLineScrollTests {

@Test("The start of a line is never left under the gutter")
func lineStartClearsTheGutter() throws {
let controller = EditorControllerFixture.make(string: pastedRow + "\n" + query)
let controller = Mock.loadedTextViewController(string: pastedRow + "\n" + query)
controller.textView.scroll(NSPoint(x: 1_000_000, y: 0))

let lineStart = (pastedRow as NSString).length + 1
Expand All @@ -52,7 +52,7 @@ struct SQLEditorLongLineScrollTests {

@Test("A long line scrolled a few points in stays there when the editor is laid out again (#2841)")
func positionNearTheLineStartSurvivesLayout() throws {
let controller = EditorControllerFixture.make(string: pastedRow + "\n" + query)
let controller = Mock.loadedTextViewController(string: pastedRow + "\n" + query)
controller.textView.layoutManager.layoutLines()
controller.scrollPosition = CGPoint(x: 20, y: 0)
try #require(abs(controller.scrollPosition.x - 20) <= 0.5, "Scrolled to \(controller.scrollPosition.x)")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//
// GutterHighlightTests.swift
// TableProTests
// TableProEditorKitTests
//
// Regression tests for gutter line-number highlighting at end of document.
// Originally the gutter tested membership via `IndexSet.intersects(integersIn: lineRange)`,
Expand All @@ -24,7 +24,7 @@ struct GutterHighlightTests {

@Test("Caret at end of single-line query highlights the only line")
func caretAtEndOfSingleLineHighlightsLine() throws {
let controller = EditorControllerFixture.make()
let controller = Mock.loadedTextViewController()
setText("SELECT * FROM users", on: controller)
let length = controller.textView.length
controller.textView.selectionManager.setSelectedRange(NSRange(location: length, length: 0))
Expand All @@ -36,7 +36,7 @@ struct GutterHighlightTests {

@Test("Caret at end of multi-line query highlights only the last line")
func caretAtEndOfMultiLineHighlightsLastLine() throws {
let controller = EditorControllerFixture.make()
let controller = Mock.loadedTextViewController()
setText("abc\ndef", on: controller)
let length = controller.textView.length
controller.textView.selectionManager.setSelectedRange(NSRange(location: length, length: 0))
Expand All @@ -50,7 +50,7 @@ struct GutterHighlightTests {

@Test("Caret in middle of line highlights that line")
func caretInMiddleOfLineHighlightsThatLine() throws {
let controller = EditorControllerFixture.make()
let controller = Mock.loadedTextViewController()
setText("abc\ndef", on: controller)
controller.textView.selectionManager.setSelectedRange(NSRange(location: 1, length: 0))

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//
// PasteHighlightCancelTests.swift
// TableProTests
// TableProEditorKitTests
//
// A cancelled tree-sitter edit used to invalidate the range as it was BEFORE the edit. That range
// is empty for an insertion at a caret, and `HighlightProviderState.invalidate(_:)` returns without
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//
// QueryEditorLargePasteTests.swift
// TableProTests
// TableProEditorKitTests
//
// Regression tests for issue #2158: pasting a large block into the query editor crashed the app.
// A caret paste over `maxSyncEditLength` routes the tree-sitter edit to the async arm, and that arm
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ struct LineFoldChunkBoundaryTests {
controller.foldProvider = provider
let model = LineFoldModel(controller: controller, foldView: NSView())

// The calculation runs on the main actor, which other suites share, so wait for it to reach the last line
// rather than for a fixed time.
let deadline = ContinuousClock.now + .seconds(5)
// The calculation runs on the main actor, which every other suite in this target shares, so wait for it to
// reach the last line rather than for a fixed time. The deadline only turns a hang into a failure; it is not
// a budget, and five seconds of it was not enough once the editor's own suites moved into this target.
let deadline = ContinuousClock.now + .seconds(60)
while provider.previousDepths[100] == nil, ContinuousClock.now < deadline {
try await Task.sleep(for: .milliseconds(10))
}
Expand Down
86 changes: 86 additions & 0 deletions Packages/TableProEditor/Tests/TableProEditorKitTests/Mock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,92 @@ enum Mock {
)
}

/// A controller whose view is loaded and laid out, for tests that need the editor's real AppKit
/// behaviour rather than a stand-in.
@MainActor
static func loadedTextViewController(
string: String = "",
wrapLines: Bool = false,
coordinators: [TextViewCoordinator] = []
) -> TextViewController {
let controller = TextViewController(
string: string,
language: .default,
configuration: SourceEditorConfiguration(
appearance: .init(
theme: theme(),
font: .monospacedSystemFont(ofSize: 12, weight: .regular),
lineHeightMultiple: 1.0,
wrapLines: wrapLines,
tabWidth: 4
)
),
cursorPositions: [],
highlightProviders: [],
coordinators: coordinators
)
controller.loadView()
controller.view.frame = NSRect(x: 0, y: 0, width: 1_000, height: 1_000)
controller.view.layoutSubtreeIfNeeded()
return controller
}

@MainActor
static func focusedTextViewController(string: String) -> (NSWindow, TextViewController) {
let controller = loadedTextViewController(string: string)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1_000, height: 1_000),
styleMask: [.titled],
backing: .buffered,
defer: false
)
window.isReleasedWhenClosed = false
window.contentView = controller.view
controller.view.layoutSubtreeIfNeeded()
_ = window.makeFirstResponder(controller.textView)
let end = (string as NSString).length
controller.setCursorPositions([CursorPosition(range: NSRange(location: end, length: 0))])
return (window, controller)
}

@MainActor
static func beginComposition(_ markedText: String, in textView: TextView) {
textView.setMarkedText(
markedText,
selectedRange: NSRange(location: (markedText as NSString).length, length: 0),
replacementRange: NSRange(location: NSNotFound, length: 0)
)
}

@MainActor
static func emptyComposition(in textView: TextView) {
textView.setMarkedText(
"",
selectedRange: NSRange(location: 0, length: 0),
replacementRange: NSRange(location: NSNotFound, length: 0)
)
}

static func keyDown(
keyCode: Int,
characters: String,
modifiers: NSEvent.ModifierFlags = [],
in window: NSWindow?
) -> NSEvent? {
NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: modifiers,
timestamp: 0,
windowNumber: window?.windowNumber ?? 0,
context: nil,
characters: characters,
charactersIgnoringModifiers: characters,
isARepeat: false,
keyCode: UInt16(keyCode)
)
}

@MainActor
static func windowedTextViewController(theme: EditorTheme) -> (NSWindow, TextViewController) {
let controller = textViewController(theme: theme)
Expand Down
Loading
Loading