From c8c3950e47f9e98d091445389c731997184b1cc0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 11:52:31 +0700 Subject: [PATCH 1/2] feat(editor): tab stops and fields in saved query snippets --- .../TableProAutocomplete/SQLSnippet.swift | 312 ++++++++++++++++++ .../SQLSnippetTests.swift | 177 ++++++++++ .../TextViewController+Cursor.swift | 2 + .../TextViewController+Lifecycle.swift | 1 + .../TextViewController+TextViewDelegate.swift | 1 + .../Controller/TextViewController.swift | 5 + .../TextView+/TextView+TextFormation.swift | 2 + .../Snippets/EditorSnippet.swift | 24 ++ .../Snippets/SnippetSession.swift | 161 +++++++++ .../TextViewController+Snippets.swift | 211 ++++++++++++ .../Utils/EmphasisGroup.swift | 1 + .../EmphasisManager/EmphasisManager.swift | 2 + .../Snippets/SnippetEditingTests.swift | 250 ++++++++++++++ .../Snippets/SnippetSessionTests.swift | 164 +++++++++ .../EmphasisManagerTests.swift | 15 + .../TablePro/Resources/Localizable.xcstrings | 3 + .../Views/Editor/QueryCompletionAdapter.swift | 20 ++ .../Views/Sidebar/FavoriteEditDialog.swift | 5 +- ...QueryCompletionAdapterLifecycleTests.swift | 44 +++ 19 files changed, 1399 insertions(+), 1 deletion(-) create mode 100644 apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLSnippet.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLSnippetTests.swift create mode 100644 apple/Packages/TableProEditor/Sources/TableProEditorKit/Snippets/EditorSnippet.swift create mode 100644 apple/Packages/TableProEditor/Sources/TableProEditorKit/Snippets/SnippetSession.swift create mode 100644 apple/Packages/TableProEditor/Sources/TableProEditorKit/Snippets/TextViewController+Snippets.swift create mode 100644 apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Snippets/SnippetEditingTests.swift create mode 100644 apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Snippets/SnippetSessionTests.swift diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLSnippet.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLSnippet.swift new file mode 100644 index 0000000000..6b61e93be7 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLSnippet.swift @@ -0,0 +1,312 @@ +import Foundation + +// The TextMate tab stop forms with braces only: a bare `$1`, `$$` or `$tag$` is a PostgreSQL parameter or dollar quote. +public struct SQLSnippet: Equatable, Sendable { + public struct Field: Equatable, Sendable { + public let number: Int + public let ranges: [NSRange] // UTF-16, in document order; a second range mirrors the first + public let choices: [String] + } + + public let text: String + public let fields: [Field] // ascending number, the order Tab visits them + public let finalRange: NSRange? // nil puts the final caret at the end of the text + + public static func parse(_ template: String) -> SQLSnippet? { + var parser = Parser(units: Array(template.utf16)) + guard let nodes = parser.topLevel() else { return nil } + var renderer = Renderer(nodes: nodes) + renderer.render(nodes, enclosing: []) + guard !renderer.exceededWork else { return nil } + + let text = String(decoding: renderer.output, as: UTF16.self) + let fields = renderer.ranges.keys.sorted().map { number in + Field( + number: number, + ranges: renderer.ranges[number, default: []].sorted { $0.location < $1.location }, + choices: renderer.choices[number] ?? [] + ) + } + guard !fields.isEmpty || renderer.finalRange != nil || text != template else { return nil } + return SQLSnippet(text: text, fields: fields, finalRange: renderer.finalRange) + } +} + +private enum Unit { + static let dollar = UInt16(UInt8(ascii: "$")) + static let openBrace = UInt16(UInt8(ascii: "{")) + static let closeBrace = UInt16(UInt8(ascii: "}")) + static let colon = UInt16(UInt8(ascii: ":")) + static let pipe = UInt16(UInt8(ascii: "|")) + static let comma = UInt16(UInt8(ascii: ",")) + static let backslash = UInt16(UInt8(ascii: "\\")) + static let semicolon = UInt16(UInt8(ascii: ";")) + static let zero = UInt16(UInt8(ascii: "0")) + static let nine = UInt16(UInt8(ascii: "9")) +} + +private struct Stop { + let id: Int + let number: Int + let children: [Node] + let choices: [String]? +} + +private enum Node { + case text([UInt16]) + case stop(Stop) + case marker +} + +// Once `${N:` or `${N|` is read the field must close; one left open leaves the whole template as written, so a +// parse never backtracks and stays linear in the template's length. +private struct Parser { + private static let maximumDepth = 16 + private static let maximumNumber = 9_999 + + let units: [UInt16] + private var index = 0 + private var depth = 0 + private var nextID = 0 + private var failed = false + + init(units: [UInt16]) { + self.units = units + } + + mutating func topLevel() -> [Node]? { + let nodes = sequence(nested: false) + return failed ? nil : nodes + } + + private mutating func sequence(nested: Bool) -> [Node]? { + var nodes: [Node] = [] + var literal: [UInt16] = [] + func flush() { + guard !literal.isEmpty else { return } + nodes.append(.text(literal)) + literal = [] + } + + while index < units.count { + let unit = units[index] + if unit == Unit.backslash, let escaped = escape(nested: nested) { + literal.append(contentsOf: escaped) + continue + } + if nested, unit == Unit.closeBrace { + index += 1 + flush() + return nodes + } + if unit == Unit.dollar { + let stop = tabStop() + if failed { return nil } + if let stop { + flush() + nodes.append(.stop(stop)) + continue + } + } + if !nested, unit == Unit.semicolon, at(index + 1) == Unit.semicolon { + index += 2 + flush() + nodes.append(.marker) + continue + } + literal.append(unit) + index += 1 + } + + if nested { + failed = true + return nil + } + flush() + return nodes + } + + // Inside a default `\}`, `\$` and `\\`; at the top level only `\${`, so a backslash in SQL text stays as written. + private mutating func escape(nested: Bool) -> [UInt16]? { + guard let next = at(index + 1) else { return nil } + if nested, next == Unit.closeBrace || next == Unit.dollar || next == Unit.backslash { + index += 2 + return [next] + } + if !nested, next == Unit.dollar, at(index + 2) == Unit.openBrace { + index += 3 + return [Unit.dollar, Unit.openBrace] + } + return nil + } + + // Nil without `failed` means the text is not a tab stop and stays literal. + private mutating func tabStop() -> Stop? { + guard at(index + 1) == Unit.openBrace, let first = at(index + 2), isDigit(first) else { return nil } + + var cursor = index + 2 + var number = 0 + while let unit = at(cursor), isDigit(unit) { + number = number * 10 + Int(unit - Unit.zero) + guard number <= Self.maximumNumber else { return nil } + cursor += 1 + } + + switch at(cursor) { + case .some(Unit.closeBrace): + index = cursor + 1 + return makeStop(number: number, children: [], choices: nil) + case .some(Unit.colon): + guard depth < Self.maximumDepth else { + failed = true + return nil + } + index = cursor + 1 + depth += 1 + defer { depth -= 1 } + return sequence(nested: true).map { makeStop(number: number, children: $0, choices: nil) } + case .some(Unit.pipe): + index = cursor + 1 + return choiceList().map { makeStop(number: number, children: [], choices: $0) } + default: + return nil + } + } + + private mutating func choiceList() -> [String]? { + var choices: [String] = [] + var current: [UInt16] = [] + while let unit = at(index) { + if unit == Unit.backslash, let next = at(index + 1), + next == Unit.comma || next == Unit.pipe || next == Unit.backslash { + current.append(next) + index += 2 + continue + } + if unit == Unit.comma { + choices.append(String(decoding: current, as: UTF16.self)) + current = [] + index += 1 + continue + } + if unit == Unit.pipe { + guard at(index + 1) == Unit.closeBrace else { break } + index += 2 + choices.append(String(decoding: current, as: UTF16.self)) + return choices + } + current.append(unit) + index += 1 + } + failed = true + return nil + } + + private mutating func makeStop(number: Int, children: [Node], choices: [String]?) -> Stop { + nextID += 1 + return Stop(id: nextID, number: number, children: children, choices: choices) + } + + private func at(_ position: Int) -> UInt16? { + position < units.count ? units[position] : nil + } + + private func isDigit(_ unit: UInt16) -> Bool { + unit >= Unit.zero && unit <= Unit.nine + } +} + +private struct Renderer { + // A mirror repeats its definition, nested mirrors included, so a short template can expand without bound. + private static let maximumWork = 1_000_000 + private static let maximumDepth = 64 + + private(set) var output: [UInt16] = [] + private(set) var ranges: [Int: [NSRange]] = [:] + private(set) var choices: [Int: [String]] = [:] + private(set) var finalRange: NSRange? + private(set) var exceededWork = false + + // The first occurrence with text or choices sets a number's text; the others mirror it. + private var definitions: [Int: Stop] = [:] + private let hasExplicitFinal: Bool + private var work = 0 + private var depth = 0 + + init(nodes: [Node]) { + var definitions: [Int: Stop] = [:] + var hasExplicitFinal = false + func collect(_ nodes: [Node]) { + for case let .stop(stop) in nodes { + if stop.number == 0 { hasExplicitFinal = true } + if definitions[stop.number] == nil, !stop.children.isEmpty || stop.choices != nil { + definitions[stop.number] = stop + } + collect(stop.children) + } + } + collect(nodes) + self.definitions = definitions + self.hasExplicitFinal = hasExplicitFinal + } + + mutating func render(_ nodes: [Node], enclosing: Set) { + for node in nodes { + work += 1 + guard work <= Self.maximumWork else { + exceededWork = true + return + } + switch node { + case .text(let units): + append(units) + case .marker: + if !hasExplicitFinal, finalRange == nil { + finalRange = NSRange(location: output.count, length: 0) + } else { + append(Array(SQLSnippetMarker.token.utf16)) + } + case .stop(let stop): + render(stop, enclosing: enclosing) + } + } + } + + // Every occurrence renders its number's definition, so a field nested in a mirrored default is mirrored too. A + // field inside an occurrence of its own number has no range of its own: it would overlap its mirror. + private mutating func render(_ stop: Stop, enclosing: Set) { + guard !enclosing.contains(stop.number) else { return } + depth += 1 + defer { depth -= 1 } + guard depth <= Self.maximumDepth else { + exceededWork = true + return + } + let start = output.count + let definition = definitions[stop.number] ?? stop + if let options = definition.choices { + append(Array((options.first ?? "").utf16)) + } else { + render(definition.children, enclosing: enclosing.union([stop.number])) + } + let range = NSRange(location: start, length: output.count - start) + + if stop.number == 0 { + if finalRange == nil { finalRange = range } + return + } + ranges[stop.number, default: []].append(range) + if let options = definition.choices, choices[stop.number] == nil { + choices[stop.number] = options + } + } + + private mutating func append(_ units: [UInt16]) { + work += units.count + guard work <= Self.maximumWork else { + exceededWork = true + return + } + output.append(contentsOf: units) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLSnippetTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLSnippetTests.swift new file mode 100644 index 0000000000..b48622a147 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLSnippetTests.swift @@ -0,0 +1,177 @@ +import Foundation +@testable import TableProAutocomplete +import Testing + +struct SQLSnippetTests { + private func text(_ snippet: SQLSnippet?, _ range: NSRange) -> String? { + snippet.map { ($0.text as NSString).substring(with: range) } + } + + @Test("Three numbered fields expand to their defaults in Tab order") + func threeFields() throws { + let snippet = try #require(SQLSnippet.parse("UPDATE ${1:users} SET ${2:name} = ${3:value}")) + + #expect(snippet.text == "UPDATE users SET name = value") + #expect(snippet.fields.map { $0.number } == [1, 2, 3]) + #expect(snippet.fields.map { text(snippet, $0.ranges[0]) } == ["users", "name", "value"]) + #expect(snippet.finalRange == nil) + } + + @Test("Fields are visited by number, not by position") + func numberOrder() throws { + let snippet = try #require(SQLSnippet.parse("${2:b} ${1:a} ${10:c}")) + + #expect(snippet.fields.map { $0.number } == [1, 2, 10]) + #expect(snippet.fields[0].ranges == [NSRange(location: 2, length: 1)]) + } + + @Test("The same number twice is one field with two ranges, the first default fills both") + func mirrors() throws { + let snippet = try #require(SQLSnippet.parse("SELECT ${1:id} FROM t WHERE ${1} > 0")) + + #expect(snippet.text == "SELECT id FROM t WHERE id > 0") + #expect(snippet.fields.count == 1) + #expect(snippet.fields[0].ranges == [NSRange(location: 7, length: 2), NSRange(location: 23, length: 2)]) + } + + @Test("A mirror written before its default still takes the default") + func mirrorBeforeDefinition() throws { + let snippet = try #require(SQLSnippet.parse("${1} = ${1:x}")) + + #expect(snippet.text == "x = x") + #expect(snippet.fields[0].ranges.count == 2) + } + + @Test("An empty field and the final stop") + func emptyFieldAndFinal() throws { + let snippet = try #require(SQLSnippet.parse("SELECT ${1} FROM t${0}")) + + #expect(snippet.text == "SELECT FROM t") + #expect(snippet.fields[0].ranges == [NSRange(location: 7, length: 0)]) + #expect(snippet.finalRange == NSRange(location: 14, length: 0)) + } + + @Test("A choice field shows its first option and keeps the list") + func choice() throws { + let snippet = try #require(SQLSnippet.parse("ORDER BY id ${1|ASC,DESC|}")) + + #expect(snippet.text == "ORDER BY id ASC") + #expect(snippet.fields[0].choices == ["ASC", "DESC"]) + #expect(snippet.fields[0].ranges == [NSRange(location: 12, length: 3)]) + } + + @Test("A nested field sits inside its parent") + func nested() throws { + let snippet = try #require(SQLSnippet.parse("SELECT ${1:${2:*} FROM t}")) + + #expect(snippet.text == "SELECT * FROM t") + #expect(snippet.fields[0].ranges == [NSRange(location: 7, length: 8)]) + #expect(snippet.fields[1].ranges == [NSRange(location: 7, length: 1)]) + } + + @Test( + "PostgreSQL parameters, dollar quotes and other dollar text stay literal", + arguments: [ + "SELECT * FROM t WHERE id = $1", + "CREATE FUNCTION f() RETURNS int AS $$ SELECT 1 $$ LANGUAGE sql", + "SELECT $tag$text$tag$", + "SELECT '${name}'", + "SELECT '${1:open'", + "SELECT '${1|a,b}'", + "SELECT ${}", + "SELECT \\d" + ] + ) + func literalDollars(_ template: String) { + #expect(SQLSnippet.parse(template) == nil) + } + + @Test("A bare $1 next to a field stays literal") + func bareParameterBesideField() throws { + let snippet = try #require(SQLSnippet.parse("SELECT * FROM ${1:t} WHERE id = $1")) + + #expect(snippet.text == "SELECT * FROM t WHERE id = $1") + #expect(snippet.fields[0].ranges == [NSRange(location: 14, length: 1)]) + } + + @Test(";; is the final stop, and only the first one counts") + func markerIsFinal() throws { + let snippet = try #require(SQLSnippet.parse("WHERE a = ;; AND b = ;;")) + + #expect(snippet.text == "WHERE a = AND b = ;;") + #expect(snippet.fields.isEmpty) + #expect(snippet.finalRange == NSRange(location: 10, length: 0)) + } + + @Test(";; stays literal when ${0} sets the final stop") + func explicitFinalWins() throws { + let snippet = try #require(SQLSnippet.parse("SELECT ;; ${1:x}${0}")) + + #expect(snippet.text == "SELECT ;; x") + #expect(snippet.finalRange == NSRange(location: 11, length: 0)) + } + + @Test(";; inside a default is text") + func markerInsideDefault() throws { + let snippet = try #require(SQLSnippet.parse("${1:a;;b}")) + + #expect(snippet.text == "a;;b") + #expect(snippet.finalRange == nil) + } + + @Test("Escapes inside a default and a choice, and \\${ at the top level") + func escapes() throws { + let snippet = try #require(SQLSnippet.parse(#"\${1} ${1:a\}b\$c\\d} ${2|x\,y,z\|w|}"#)) + + #expect(snippet.text == #"${1} a}b$c\d x,y"#) + #expect(snippet.fields[1].choices == ["x,y", "z|w"]) + } + + @Test("A field nested in a mirrored default is mirrored too") + func nestedFieldInMirror() throws { + let snippet = try #require(SQLSnippet.parse("${1:${2:x}} ${1}")) + + #expect(snippet.text == "x x") + #expect(snippet.fields[1].ranges == [NSRange(location: 0, length: 1), NSRange(location: 2, length: 1)]) + } + + @Test("Mirrors that expand past the limit leave the template as written") + func expansionLimit() { + let template = (1..<30).map { "${\($0):${\($0 + 1)}${\($0 + 1)}}" }.joined(separator: " ") + " ${30:x}" + + #expect(SQLSnippet.parse(template) == nil) + } + + @Test("A field inside an occurrence of its own number has no range of its own") + func selfNesting() throws { + let snippet = try #require(SQLSnippet.parse("${1:a ${1} b}")) + + #expect(snippet.text == "a b") + #expect(snippet.fields[0].ranges == [NSRange(location: 0, length: 4)]) + } + + @Test("Offsets count UTF-16 units after multibyte text") + func multibyte() throws { + let prefix = "-- ghi chú 🙂\nSELECT " + let snippet = try #require(SQLSnippet.parse(prefix + "${1:x}")) + + #expect(snippet.fields[0].ranges == [NSRange(location: (prefix as NSString).length, length: 1)]) + } + + @Test("Nesting past the limit leaves the template as written") + func depthLimit() { + let template = String(repeating: "${1:", count: 200) + String(repeating: "}", count: 200) + + #expect(SQLSnippet.parse(template) == nil) + } + + @Test("Many unclosed fields fail once instead of backtracking") + func unclosedFieldsStayLinear() { + #expect(SQLSnippet.parse("SELECT ${1:a" + String(repeating: " ${2:b} $3", count: 20_000)) == nil) + } + + @Test("One unclosed field leaves the whole template as written") + func unclosedFieldKeepsTemplate() { + #expect(SQLSnippet.parse("SELECT ${1:a} FROM ${2:t") == nil) + } +} diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Cursor.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Cursor.swift index e50482332a..7b4080d0fa 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Cursor.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Cursor.swift @@ -74,6 +74,8 @@ extension TextViewController { positions.append(CursorPosition(range: selectedRange.range, start: start, end: end)) } + snippetSelectionDidChange() + isPostingCursorNotification = true cursorPositions = positions.sorted(by: { $0.range.location < $1.range.location }) NotificationCenter.default.post(name: Self.cursorPositionUpdatedNotification, object: self) diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift index 4326287d5d..7dc8231335 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift @@ -270,6 +270,7 @@ extension TextViewController { guard !textView.hasMarkedText() else { return handleKeyDownDuringComposition(event: event, modifierFlags: modifierFlags) } + if claimSnippetKey(event, modifierFlags: modifierFlags) { return nil } let tabKey: UInt16 = 0x30 if event.keyCode == tabKey { diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+TextViewDelegate.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+TextViewDelegate.swift index 447a19e03e..522ee1b876 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+TextViewDelegate.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+TextViewDelegate.swift @@ -20,6 +20,7 @@ extension TextViewController: TextViewDelegate { public func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) { gutterView.needsDisplay = true + snippetTextDidReplace(in: range, with: string) for coordinator in self.textCoordinators.values() { if let coordinator = coordinator as? TextViewDelegate { coordinator.textView(textView, didReplaceContentsIn: range, with: string) diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController.swift index ecc69e0a09..0af0e1bf2a 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController.swift @@ -176,6 +176,10 @@ public class TextViewController: NSViewController { var cancellables = Set() + var snippetSession: SnippetSession? + var snippetChoicePresenter: SnippetChoicePresenter = SnippetChoiceMenu.present + var snippetChoiceGeneration = 0 + /// The width of the view floating along the editor's leading edge: the gutter. /// ``SourceEditorScrollView`` reserves it so the text scrolls clear of it. var floatingSubviewInsets: HorizontalEdgeInsets { @@ -262,6 +266,7 @@ public class TextViewController: NSViewController { /// Set the contents of the editor. /// - Parameter text: The new contents of the editor. public func setText(_ text: String) { + endSnippetSession() self.textView.setText(text) self.foldModel?.documentDidReplace() self.setUpHighlighter() diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Extensions/TextView+/TextView+TextFormation.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Extensions/TextView+/TextView+TextFormation.swift index eca9404d67..df8753aef9 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Extensions/TextView+/TextView+TextFormation.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Extensions/TextView+/TextView+TextFormation.swift @@ -46,5 +46,7 @@ extension TextView: @retroactive TextInterface { replacementLength: (mutation.string as NSString).length ) layoutManager.invalidateLayoutForRange(mutation.range) + // A typing filter edits here, past `replaceCharacters` and its delegate calls, so a snippet's fields hear it here. + (delegate as? TextViewController)?.snippetTextDidReplace(in: mutation.range, with: mutation.string) } } diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Snippets/EditorSnippet.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Snippets/EditorSnippet.swift new file mode 100644 index 0000000000..6b74be6931 --- /dev/null +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Snippets/EditorSnippet.swift @@ -0,0 +1,24 @@ +import Foundation + +// Ranges are UTF-16 offsets into `text`. +public struct EditorSnippet: Equatable { + public struct Field: Equatable { + public var ranges: [NSRange] // more than one range mirrors the same text + public var choices: [String] + + public init(ranges: [NSRange], choices: [String] = []) { + self.ranges = ranges + self.choices = choices + } + } + + public var text: String + public var fields: [Field] // in the order Tab visits them + public var finalRange: NSRange? // nil puts the final caret at the end of the text + + public init(text: String, fields: [Field], finalRange: NSRange? = nil) { + self.text = text + self.fields = fields + self.finalRange = finalRange + } +} diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Snippets/SnippetSession.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Snippets/SnippetSession.swift new file mode 100644 index 0000000000..287a1c622d --- /dev/null +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Snippets/SnippetSession.swift @@ -0,0 +1,161 @@ +import Foundation + +struct SnippetSession: Equatable { + struct Field: Equatable { + var ranges: [NSRange] + let choices: [String] + } + + private(set) var fields: [Field] + private(set) var finalRange: NSRange + private(set) var span: NSRange + private(set) var current: Int + // The active field was typed away: `current` names the field that followed it, which the next Tab selects. + private var currentIsGone = false + + init?(snippet: EditorSnippet, at location: Int) { + let length = (snippet.text as NSString).length + let shift = { (range: NSRange) in NSRange(location: range.location + location, length: range.length) } + let fields = snippet.fields + .map { Field(ranges: $0.ranges.map(shift).sorted { $0.location < $1.location }, choices: $0.choices) } + .filter { !$0.ranges.isEmpty } + guard !fields.isEmpty else { return nil } + + self.fields = fields + self.finalRange = shift(snippet.finalRange ?? NSRange(location: length, length: 0)) + self.span = NSRange(location: location, length: length) + self.current = 0 + } + + var currentField: Field? { + !currentIsGone && fields.indices.contains(current) ? fields[current] : nil + } + + // False once Tab has passed the last field: the final stop ends the session. + mutating func advance() -> Bool { + let next = currentIsGone ? current : current + 1 + guard next < fields.count else { return false } + current = next + currentIsGone = false + return true + } + + mutating func retreat() { + current = max(current - 1, 0) + currentIsGone = false + } + + func contains(_ selection: NSRange) -> Bool { + selection.location >= span.location && NSMaxRange(selection) <= NSMaxRange(span) + } + + // False when the edit leaves nothing sound to track: it replaced the whole snippet, as undoing the expansion + // does, crossed a field edge, or took every field. Typing over a field that is the whole snippet keeps it. + mutating func apply(edit range: NSRange, replacementLength: Int, isUndo: Bool = false) -> Bool { + let activeRanges = currentField?.ranges ?? [] + if range.length > 0, range.location <= span.location, NSMaxRange(range) >= NSMaxRange(span), + isUndo || !activeRanges.contains(range) { + return false + } + guard case .range(let newSpan) = Self.adjust( + span, + edit: range, + length: replacementLength, + growsAtEdges: true, + growsAtStart: true + ) else { return false } + + var kept: [Field] = [] + var newCurrent = current + var newCurrentIsGone = currentIsGone + for (index, field) in fields.enumerated() { + let isActive = index == current && !currentIsGone + var ranges: [NSRange] = [] + for fieldRange in field.ranges { + let growsAtEdges = isActive || Self.encloses(fieldRange, anyOf: activeRanges, touching: range.location) + // Typing at the end of one copy that touches the next belongs to the first: the second shifts. + let followsCopy = activeRanges.contains { $0 != fieldRange && NSMaxRange($0) == fieldRange.location } + switch Self.adjust( + fieldRange, + edit: range, + length: replacementLength, + growsAtEdges: growsAtEdges, + growsAtStart: !followsCopy + ) { + case .range(let adjusted): + ranges.append(adjusted) + case .swallowed: + continue + case .crossed: + return false + } + } + if ranges.isEmpty { + if index < current { newCurrent -= 1 } + if isActive { newCurrentIsGone = true } + continue + } + kept.append(Field(ranges: ranges, choices: field.choices)) + } + guard !kept.isEmpty else { return false } + + switch Self.adjust(finalRange, edit: range, length: replacementLength, growsAtEdges: false, growsAtStart: false) { + case .range(let adjusted): + finalRange = adjusted + case .swallowed, .crossed: + finalRange = NSRange(location: range.location + replacementLength, length: 0) + } + fields = kept + current = newCurrent + currentIsGone = newCurrentIsGone + span = newSpan + return true + } + + // A field holding the active one grows with it, so text typed at their shared edge stays inside both. An empty + // field holds nothing: two empty fields at one spot are neighbours. + private static func encloses(_ outer: NSRange, anyOf active: [NSRange], touching point: Int) -> Bool { + outer.length > 0 && active.contains { inner in + inner.location >= outer.location && NSMaxRange(inner) <= NSMaxRange(outer) + && inner.location <= point && point <= NSMaxRange(inner) + } + } + + private enum Adjusted: Equatable { + case range(NSRange) + case swallowed + case crossed + } + + // Typing at an edge grows only the active field, so text typed after an empty field never joins the next one. + private static func adjust( + _ tracked: NSRange, + edit: NSRange, + length: Int, + growsAtEdges: Bool, + growsAtStart: Bool + ) -> Adjusted { + let delta = length - edit.length + let start = tracked.location + let end = NSMaxRange(tracked) + + if edit.length == 0 { + let point = edit.location + if point < start { return .range(NSRange(location: start + delta, length: tracked.length)) } + if point > end { return .range(tracked) } + let growsHere = point == start ? growsAtEdges && growsAtStart : growsAtEdges + if (point > start && point < end) || growsHere { + return .range(NSRange(location: start, length: tracked.length + length)) + } + return point == start ? .range(NSRange(location: start + length, length: tracked.length)) : .range(tracked) + } + + if NSMaxRange(edit) <= start { return .range(NSRange(location: start + delta, length: tracked.length)) } + if edit.location >= end { return .range(tracked) } + if edit.location >= start && NSMaxRange(edit) <= end { + return .range(NSRange(location: start, length: tracked.length + delta)) + } + if edit.location <= start && NSMaxRange(edit) >= end { return .swallowed } + return .crossed + } +} diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Snippets/TextViewController+Snippets.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Snippets/TextViewController+Snippets.swift new file mode 100644 index 0000000000..ed135a46bf --- /dev/null +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Snippets/TextViewController+Snippets.swift @@ -0,0 +1,211 @@ +import AppKit +import Carbon.HIToolbox +import TableProTextEngine + +extension TextViewController { + public var isSnippetSessionActive: Bool { + snippetSession != nil + } + + // One undo group, without the typing filters, so the field offsets match the inserted text. + public func insertSnippet(_ snippet: EditorSnippet, replacing range: NSRange) { + endSnippetSession() + guard textView.isEditable, NSMaxRange(range) <= textView.textStorage.length else { return } + + textView.undoManager?.beginUndoGrouping() + isApplyingUnfilteredEdits = true + textView.replaceCharacters(in: range, with: snippet.text) + isApplyingUnfilteredEdits = false + textView.undoManager?.endUndoGrouping() + + guard let session = SnippetSession(snippet: snippet, at: range.location) else { + let length = (snippet.text as NSString).length + let final = snippet.finalRange ?? NSRange(location: length, length: 0) + selectSnippetRanges([NSRange(location: range.location + final.location, length: final.length)]) + return + } + snippetSession = session + selectCurrentSnippetField() + } + + public func endSnippetSession() { + guard snippetSession != nil else { return } + snippetSession = nil + snippetChoiceGeneration += 1 + emphasisManager?.removeEmphases(for: EmphasisGroup.snippetFields) + } + + // A completion accepted in one copy of a mirrored field goes into every copy, as typing does. False when the edit + // is not inside a copy of the active field or the copies no longer match, and the caller applies it alone. + public func replaceInSnippetMirrors(_ range: NSRange, with text: String, caretOffset: Int) -> Bool { + guard let field = snippetSession?.currentField, field.ranges.count > 1, + let source = field.ranges.first(where: { + $0.location <= range.location && NSMaxRange(range) <= NSMaxRange($0) + }) else { return false } + + let document = textView.string as NSString + let sourceText = document.substring(with: source) + guard field.ranges.allSatisfy({ document.substring(with: $0) == sourceText }) else { return false } + + let offset = range.location - source.location + let copies = field.ranges.sorted { $0.location < $1.location } + textView.undoManager?.beginUndoGrouping() + textView.replaceCharacters(copies.map { + TextReplacement(range: NSRange(location: $0.location + offset, length: range.length), string: text) + }) + textView.undoManager?.endUndoGrouping() + + let delta = (text as NSString).length - range.length + let carets = copies.enumerated().map { index, copy in + NSRange(location: copy.location + index * delta + offset + caretOffset, length: 0) + } + textView.selectionManager.setSelectedRanges(carets) + return true + } + + // Consulted after the coordinators and the completion list, so Tab still accepts a completion or a suggestion. + func claimSnippetKey(_ event: NSEvent, modifierFlags: NSEvent.ModifierFlags) -> Bool { + guard snippetSession != nil else { return false } + switch (Int(event.keyCode), modifierFlags) { + case (kVK_Tab, []): + advanceSnippetField() + case (kVK_Tab, .shift): + retreatSnippetField() + case (kVK_Escape, []): + endSnippetSession() + default: + return false + } + return true + } + + func snippetTextDidReplace(in range: NSRange, with string: String) { + guard var session = snippetSession else { return } + let isUndo = textView.undoManager?.isUndoing == true || textView.undoManager?.isRedoing == true + if session.apply(edit: range, replacementLength: (string as NSString).length, isUndo: isUndo) { + snippetSession = session + } else { + endSnippetSession() + } + } + + func snippetSelectionDidChange() { + guard let session = snippetSession else { return } + let selections = textView.selectionManager.textSelections.map(\.range) + guard !selections.isEmpty, selections.allSatisfy(session.contains) else { + endSnippetSession() + return + } + updateSnippetEmphasis() + } + + private func advanceSnippetField() { + guard var session = snippetSession else { return } + guard session.advance() else { + let final = session.finalRange + endSnippetSession() + selectSnippetRanges([final]) + return + } + snippetSession = session + selectCurrentSnippetField() + } + + private func retreatSnippetField() { + guard var session = snippetSession else { return } + session.retreat() + snippetSession = session + selectCurrentSnippetField() + } + + // Every move bumps the generation, so a menu still pending for the field just left cannot fill this one. + private func selectCurrentSnippetField() { + snippetChoiceGeneration += 1 + guard let field = snippetSession?.currentField else { return } + selectSnippetRanges(field.ranges) + updateSnippetEmphasis() + guard !field.choices.isEmpty, let anchor = field.ranges.first else { return } + + let generation = snippetChoiceGeneration + snippetChoicePresenter(field.choices, anchor, textView) { [weak self] choice in + self?.applySnippetChoice(choice, generation: generation) + } + } + + // A pick arriving after the session moved on, or ended, is dropped. + private func applySnippetChoice(_ choice: String, generation: Int) { + guard generation == snippetChoiceGeneration, let field = snippetSession?.currentField else { return } + + textView.undoManager?.beginUndoGrouping() + isApplyingUnfilteredEdits = true + textView.replaceCharacters(in: field.ranges, with: choice) + isApplyingUnfilteredEdits = false + textView.undoManager?.endUndoGrouping() + + guard let updated = snippetSession?.currentField else { return } + selectSnippetRanges(updated.ranges) + } + + private func selectSnippetRanges(_ ranges: [NSRange]) { + textView.selectionManager.setSelectedRanges(ranges) + textView.scrollSelectionToVisible() + } + + private func updateSnippetEmphasis() { + guard let session = snippetSession else { return } + let emphases = session.fields.flatMap(\.ranges).map { + Emphasis(range: $0, style: .outline(color: .secondaryLabelColor)) + } + emphasisManager?.replaceEmphases(emphases, for: EmphasisGroup.snippetFields) + } +} + +typealias SnippetChoicePresenter = @MainActor ( + _ choices: [String], + _ range: NSRange, + _ textView: TextView, + _ onPick: @escaping @MainActor (String) -> Void +) -> Void + +enum SnippetChoiceMenu { + @MainActor + static func present( + _ choices: [String], + at range: NSRange, + in textView: TextView, + onPick: @escaping @MainActor (String) -> Void + ) { + // After the key event that selected the field returns: the menu runs its own tracking loop. + DispatchQueue.main.async { [weak textView] in + guard let textView, textView.window != nil, + let caret = textView.layoutManager.rectForOffset(range.location) else { return } + + let target = ChoiceTarget(onPick: onPick) + let menu = NSMenu() + menu.autoenablesItems = false + for choice in choices { + let item = NSMenuItem(title: choice, action: #selector(ChoiceTarget.pick(_:)), keyEquivalent: "") + item.target = target + item.representedObject = choice + menu.addItem(item) + } + let point = NSPoint(x: caret.minX, y: textView.isFlipped ? caret.maxY : caret.minY) + withExtendedLifetime(target) { + _ = menu.popUp(positioning: nil, at: point, in: textView) + } + } + } + + private final class ChoiceTarget: NSObject { + let onPick: @MainActor (String) -> Void + + init(onPick: @escaping @MainActor (String) -> Void) { + self.onPick = onPick + } + + @MainActor @objc func pick(_ sender: NSMenuItem) { + guard let choice = sender.representedObject as? String else { return } + onPick(choice) + } + } +} diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Utils/EmphasisGroup.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Utils/EmphasisGroup.swift index 16614a4783..abf996814d 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Utils/EmphasisGroup.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Utils/EmphasisGroup.swift @@ -8,4 +8,5 @@ enum EmphasisGroup { static let brackets = "codeedit.bracketPairs" static let find = "codeedit.find" + static let snippetFields = "tablepro.snippetFields" } diff --git a/apple/Packages/TableProEditor/Sources/TableProTextEngine/EmphasisManager/EmphasisManager.swift b/apple/Packages/TableProEditor/Sources/TableProTextEngine/EmphasisManager/EmphasisManager.swift index cd0a43a960..bbf3828d16 100644 --- a/apple/Packages/TableProEditor/Sources/TableProTextEngine/EmphasisManager/EmphasisManager.swift +++ b/apple/Packages/TableProEditor/Sources/TableProTextEngine/EmphasisManager/EmphasisManager.swift @@ -470,7 +470,9 @@ public final class EmphasisManager { return color.resolved(in: textView.effectiveAppearance) } + // An unfilled outline leaves its text showing, and a copy in the highlight's text color would hide the syntax colors. private func createTextLayer(for emphasis: Emphasis) -> CATextLayer? { + if case .outline(_, false) = emphasis.style { return nil } guard let textView = textView, let layoutManager = textView.layoutManager, let textStorage = textView.textStorage, diff --git a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Snippets/SnippetEditingTests.swift b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Snippets/SnippetEditingTests.swift new file mode 100644 index 0000000000..2deb247711 --- /dev/null +++ b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Snippets/SnippetEditingTests.swift @@ -0,0 +1,250 @@ +import AppKit +import Carbon.HIToolbox +@testable import TableProEditorKit +import TableProTextEngine +import Testing + +@MainActor +struct SnippetEditingTests { + // UPDATE SET = WHERE IS NULL, with mirrored. + private static let update = EditorSnippet( + text: "UPDATE users SET name = 1 WHERE name IS NULL", + fields: [ + EditorSnippet.Field(ranges: [NSRange(location: 7, length: 5)]), + EditorSnippet.Field(ranges: [NSRange(location: 17, length: 4), NSRange(location: 32, length: 4)]), + EditorSnippet.Field(ranges: [NSRange(location: 24, length: 1)]) + ] + ) + + private func editor(_ text: String = "") -> (NSWindow, TextViewController) { + let (window, editor) = Mock.focusedTextViewController(string: text) + editor.snippetChoicePresenter = { _, _, _, _ in } + return (window, editor) + } + + private func press( + _ keyCode: Int, + _ characters: String, + _ modifiers: NSEvent.ModifierFlags = [], + in pair: (NSWindow, TextViewController) + ) throws -> Bool { + let event = try #require(Mock.keyDown(keyCode: keyCode, characters: characters, modifiers: modifiers, in: pair.0)) + return pair.1.claimKeyDown(event, textViewHasFocus: true, findPanelHasFocus: false) == nil + } + + private func selections(_ editor: TextViewController) -> [String] { + let text = editor.textView.string as NSString + return editor.textView.selectionManager.textSelections.map { text.substring(with: $0.range) } + } + + @Test("Expanding selects the first field, and Tab visits each field in order, then the end") + func tabVisitsFieldsInOrder() throws { + let pair = editor("-- ") + pair.1.insertSnippet(Self.update, replacing: NSRange(location: 3, length: 0)) + + #expect(pair.1.textView.string == "-- UPDATE users SET name = 1 WHERE name IS NULL") + #expect(selections(pair.1) == ["users"]) + #expect(try press(kVK_Tab, "\t", in: pair)) + #expect(selections(pair.1) == ["name", "name"]) + #expect(try press(kVK_Tab, "\t", in: pair)) + #expect(selections(pair.1) == ["1"]) + #expect(try press(kVK_Tab, "\t", in: pair)) + #expect(pair.1.isSnippetSessionActive == false) + #expect(pair.1.textView.selectionManager.textSelections.map { $0.range } == [NSRange(location: 47, length: 0)]) + } + + @Test("Shift-Tab goes back a field") + func shiftTabGoesBack() throws { + let pair = editor() + pair.1.insertSnippet(Self.update, replacing: NSRange(location: 0, length: 0)) + + #expect(try press(kVK_Tab, "\t", in: pair)) + #expect(try press(kVK_Tab, "\u{19}", .shift, in: pair)) + #expect(selections(pair.1) == ["users"]) + } + + @Test("Typing into a mirrored field changes every copy") + func mirrorFollowsTyping() throws { + let pair = editor() + pair.1.insertSnippet(Self.update, replacing: NSRange(location: 0, length: 0)) + #expect(try press(kVK_Tab, "\t", in: pair)) + + pair.1.textView.insertText("email", replacementRange: NSRange(location: NSNotFound, length: 0)) + + #expect(pair.1.textView.string == "UPDATE users SET email = 1 WHERE email IS NULL") + #expect(try press(kVK_Tab, "\t", in: pair)) + #expect(selections(pair.1) == ["1"]) + } + + @Test("A completion accepted in a mirrored field goes into every copy") + func completionFillsEveryMirror() throws { + let pair = editor() + pair.1.insertSnippet(Self.update, replacing: NSRange(location: 0, length: 0)) + #expect(try press(kVK_Tab, "\t", in: pair)) + pair.1.textView.insertText("em", replacementRange: NSRange(location: NSNotFound, length: 0)) + + let applied = pair.1.replaceInSnippetMirrors(NSRange(location: 17, length: 2), with: "email", caretOffset: 5) + + #expect(applied) + #expect(pair.1.textView.string == "UPDATE users SET email = 1 WHERE email IS NULL") + #expect(pair.1.textView.selectionManager.textSelections.map { $0.range } == [ + NSRange(location: 22, length: 0), NSRange(location: 38, length: 0) + ]) + #expect(try press(kVK_Tab, "\t", in: pair)) + #expect(selections(pair.1) == ["1"]) + } + + @Test("An edit a typing filter makes keeps the later fields in place") + func filterEditKeepsFields() throws { + let pair = editor() + let snippet = EditorSnippet( + text: "SELECT x FROM t", + fields: [ + EditorSnippet.Field(ranges: [NSRange(location: 7, length: 1)]), + EditorSnippet.Field(ranges: [NSRange(location: 14, length: 1)]) + ] + ) + pair.1.insertSnippet(snippet, replacing: NSRange(location: 0, length: 0)) + + pair.1.textView.insertText("(", replacementRange: NSRange(location: NSNotFound, length: 0)) + + #expect(pair.1.textView.string.hasPrefix("SELECT (")) + #expect(try press(kVK_Tab, "\t", in: pair)) + #expect(selections(pair.1) == ["t"]) + } + + @Test("Escape ends the session and leaves the text") + func escapeEnds() throws { + let pair = editor() + pair.1.insertSnippet(Self.update, replacing: NSRange(location: 0, length: 0)) + + #expect(try press(kVK_Escape, "\u{1b}", in: pair)) + #expect(pair.1.isSnippetSessionActive == false) + #expect(pair.1.textView.string == Self.update.text) + #expect(pair.1.textView.emphasisManager?.getEmphases(for: EmphasisGroup.snippetFields).isEmpty == true) + } + + @Test("A selection outside the snippet ends the session, and Tab is the editor's again") + func selectionOutsideEnds() throws { + let pair = editor("SELECT 1;\n") + pair.1.insertSnippet(Self.update, replacing: NSRange(location: 10, length: 0)) + #expect(pair.1.isSnippetSessionActive) + + pair.1.setCursorPositions([CursorPosition(range: NSRange(location: 2, length: 0))]) + + #expect(pair.1.isSnippetSessionActive == false) + #expect(try press(kVK_Tab, "\t", in: pair) == false) + } + + @Test("Fields carry an outline while the session is open") + func fieldsAreOutlined() { + let pair = editor() + pair.1.insertSnippet(Self.update, replacing: NSRange(location: 0, length: 0)) + + let emphases = pair.1.textView.emphasisManager?.getEmphases(for: EmphasisGroup.snippetFields) ?? [] + #expect(emphases.map { $0.range }.count == 4) + } + + @Test("A choice field asks for its options, and a pick replaces the field") + func choiceField() throws { + let pair = editor() + var offered: [String] = [] + var pick: (@MainActor (String) -> Void)? + pair.1.snippetChoicePresenter = { choices, _, _, onPick in + offered = choices + pick = onPick + } + let snippet = EditorSnippet( + text: "ORDER BY id ASC", + fields: [EditorSnippet.Field(ranges: [NSRange(location: 12, length: 3)], choices: ["ASC", "DESC"])] + ) + + pair.1.insertSnippet(snippet, replacing: NSRange(location: 0, length: 0)) + #expect(offered == ["ASC", "DESC"]) + let apply = try #require(pick) + apply("DESC") + + #expect(pair.1.textView.string == "ORDER BY id DESC") + #expect(selections(pair.1) == ["DESC"]) + } + + @Test("A pick that arrives after the session ended is dropped") + func latePickIsDropped() throws { + let pair = editor() + var pick: (@MainActor (String) -> Void)? + pair.1.snippetChoicePresenter = { _, _, _, onPick in pick = onPick } + let snippet = EditorSnippet( + text: "ORDER BY id ASC", + fields: [EditorSnippet.Field(ranges: [NSRange(location: 12, length: 3)], choices: ["ASC", "DESC"])] + ) + pair.1.insertSnippet(snippet, replacing: NSRange(location: 0, length: 0)) + + #expect(try press(kVK_Escape, "\u{1b}", in: pair)) + let apply = try #require(pick) + apply("DESC") + + #expect(pair.1.textView.string == "ORDER BY id ASC") + } + + @Test("A snippet with only a final stop places the caret and starts no session") + func finalStopOnly() { + let pair = editor() + pair.1.insertSnippet( + EditorSnippet(text: "WHERE id = ", fields: [], finalRange: NSRange(location: 6, length: 0)), + replacing: NSRange(location: 0, length: 0) + ) + + #expect(pair.1.isSnippetSessionActive == false) + #expect(pair.1.textView.selectionManager.textSelections.map { $0.range } == [NSRange(location: 6, length: 0)]) + } + + @Test("One undo removes the whole expansion and ends the session") + func undoRemovesExpansion() { + let pair = editor("-- ") + pair.1.insertSnippet(Self.update, replacing: NSRange(location: 3, length: 0)) + + pair.1.textView.undoManager?.undo() + + #expect(pair.1.textView.string == "-- ") + #expect(pair.1.isSnippetSessionActive == false) + } + + @Test("Undoing the expansion of a snippet that is one field ends the session") + func undoWholeFieldExpansion() { + let pair = editor("kw") + pair.1.insertSnippet( + EditorSnippet(text: "users", fields: [EditorSnippet.Field(ranges: [NSRange(location: 0, length: 5)])]), + replacing: NSRange(location: 0, length: 2) + ) + #expect(pair.1.isSnippetSessionActive) + + pair.1.textView.undoManager?.undo() + + #expect(pair.1.textView.string == "kw") + #expect(pair.1.isSnippetSessionActive == false) + } + + @Test("One undo puts back every copy a mirrored completion changed") + func undoMirroredCompletion() throws { + let pair = editor() + pair.1.insertSnippet(Self.update, replacing: NSRange(location: 0, length: 0)) + #expect(try press(kVK_Tab, "\t", in: pair)) + let before = pair.1.textView.string + + let applied = pair.1.replaceInSnippetMirrors(NSRange(location: 17, length: 4), with: "email", caretOffset: 5) + pair.1.textView.undoManager?.undo() + + #expect(applied) + #expect(pair.1.textView.string == before) + } + + @Test("Replacing the document ends the session") + func setTextEnds() { + let pair = editor() + pair.1.insertSnippet(Self.update, replacing: NSRange(location: 0, length: 0)) + + pair.1.setText("SELECT 1") + + #expect(pair.1.isSnippetSessionActive == false) + } +} diff --git a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Snippets/SnippetSessionTests.swift b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Snippets/SnippetSessionTests.swift new file mode 100644 index 0000000000..7b5f911fb4 --- /dev/null +++ b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Snippets/SnippetSessionTests.swift @@ -0,0 +1,164 @@ +import Foundation +@testable import TableProEditorKit +import Testing + +struct SnippetSessionTests { + // "SELECT a FROM b": field 1 at 7..<8, field 2 at 14..<15, inserted at 10. + private func session() throws -> SnippetSession { + let snippet = EditorSnippet( + text: "SELECT a FROM b", + fields: [ + EditorSnippet.Field(ranges: [NSRange(location: 7, length: 1)]), + EditorSnippet.Field(ranges: [NSRange(location: 14, length: 1)]) + ] + ) + return try #require(SnippetSession(snippet: snippet, at: 10)) + } + + @Test("Ranges move to the insertion point and the final stop defaults to the end") + func shiftsToInsertion() throws { + let session = try session() + + #expect(session.fields.map { $0.ranges } == [[NSRange(location: 17, length: 1)], [NSRange(location: 24, length: 1)]]) + #expect(session.finalRange == NSRange(location: 25, length: 0)) + #expect(session.span == NSRange(location: 10, length: 15)) + } + + @Test("Typing over the active field resizes it and moves what follows") + func typingOverActiveField() throws { + var session = try session() + + let kept = session.apply(edit: NSRange(location: 17, length: 1), replacementLength: 5) + #expect(kept) + #expect(session.fields[0].ranges == [NSRange(location: 17, length: 5)]) + #expect(session.fields[1].ranges == [NSRange(location: 28, length: 1)]) + #expect(session.span == NSRange(location: 10, length: 19)) + } + + @Test("Typing at the edge of an empty active field grows it, not the next field") + func edgeGrowsActiveOnly() throws { + var session = try #require(SnippetSession( + snippet: EditorSnippet( + text: "", + fields: [ + EditorSnippet.Field(ranges: [NSRange(location: 0, length: 0)]), + EditorSnippet.Field(ranges: [NSRange(location: 0, length: 0)]) + ] + ), + at: 0 + )) + + let kept = session.apply(edit: NSRange(location: 0, length: 0), replacementLength: 2) + #expect(kept) + #expect(session.fields[0].ranges == [NSRange(location: 0, length: 2)]) + #expect(session.fields[1].ranges == [NSRange(location: 2, length: 0)]) + } + + @Test("Typing at the end of an active nested field grows the field around it too") + func enclosingFieldGrowsWithChild() throws { + let snippet = EditorSnippet( + text: "abcx", + fields: [ + EditorSnippet.Field(ranges: [NSRange(location: 0, length: 4)]), + EditorSnippet.Field(ranges: [NSRange(location: 3, length: 1)]) + ] + ) + var session = try #require(SnippetSession(snippet: snippet, at: 0)) + let moved = session.advance() + #expect(moved) + + let replaced = session.apply(edit: NSRange(location: 3, length: 1), replacementLength: 1) + let appended = session.apply(edit: NSRange(location: 4, length: 0), replacementLength: 1) + + #expect(replaced && appended) + #expect(session.fields[0].ranges == [NSRange(location: 0, length: 5)]) + #expect(session.fields[1].ranges == [NSRange(location: 3, length: 2)]) + } + + @Test("Typing at the end of one copy that touches the next grows the first and moves the second") + func adjacentCopies() throws { + let snippet = EditorSnippet( + text: "xx", + fields: [EditorSnippet.Field(ranges: [NSRange(location: 0, length: 1), NSRange(location: 1, length: 1)])] + ) + var session = try #require(SnippetSession(snippet: snippet, at: 0)) + + let second = session.apply(edit: NSRange(location: 2, length: 0), replacementLength: 1) + let first = session.apply(edit: NSRange(location: 1, length: 0), replacementLength: 1) + + #expect(second && first) + #expect(session.fields[0].ranges == [NSRange(location: 0, length: 2), NSRange(location: 2, length: 2)]) + } + + @Test("Undoing the expansion ends the session even when an empty field sits at its end") + func undoingExpansionEnds() throws { + let snippet = EditorSnippet( + text: "SELECT ", + fields: [EditorSnippet.Field(ranges: [NSRange(location: 7, length: 0)])] + ) + var session = try #require(SnippetSession(snippet: snippet, at: 4)) + + let kept = session.apply(edit: NSRange(location: 4, length: 7), replacementLength: 3) + + #expect(kept == false) + } + + @Test("A field an edit swallows is dropped and Tab lands on the one after it") + func swallowedFieldIsDropped() throws { + let snippet = EditorSnippet( + text: "SELECT * FROM t", + fields: [ + EditorSnippet.Field(ranges: [NSRange(location: 7, length: 8)]), + EditorSnippet.Field(ranges: [NSRange(location: 7, length: 1)]), + EditorSnippet.Field(ranges: [NSRange(location: 14, length: 1)]) + ] + ) + var session = try #require(SnippetSession(snippet: snippet, at: 0)) + let movedToSecond = session.advance() + #expect(movedToSecond) + + let kept = session.apply(edit: NSRange(location: 7, length: 2), replacementLength: 1) + #expect(kept) + #expect(session.fields.count == 2) + #expect(session.fields[0].ranges == [NSRange(location: 7, length: 7)]) + #expect(session.currentField == nil) + let movedOn = session.advance() + #expect(movedOn) + #expect(session.currentField?.ranges == [NSRange(location: 13, length: 1)]) + } + + @Test("An edit crossing a field edge ends the session") + func crossingEditEnds() throws { + let snippet = EditorSnippet(text: "SELECT users", fields: [EditorSnippet.Field(ranges: [NSRange(location: 7, length: 5)])]) + var session = try #require(SnippetSession(snippet: snippet, at: 0)) + + let kept = session.apply(edit: NSRange(location: 5, length: 4), replacementLength: 0) + #expect(kept == false) + } + + @Test("An edit that takes the whole snippet ends the session") + func swallowedSpanEnds() throws { + var session = try session() + + let kept = session.apply(edit: NSRange(location: 5, length: 30), replacementLength: 0) + #expect(kept == false) + } + + @Test("Advance stops after the last field, retreat stops at the first") + func navigationBounds() throws { + var session = try session() + + session.retreat() + #expect(session.current == 0) + let movedToSecond = session.advance() + #expect(movedToSecond) + let movedPastLast = session.advance() + #expect(movedPastLast == false) + #expect(session.current == 1) + } + + @Test("A snippet with no fields starts no session") + func noFieldsNoSession() { + #expect(SnippetSession(snippet: EditorSnippet(text: "SELECT 1", fields: []), at: 0) == nil) + } +} diff --git a/apple/Packages/TableProEditor/Tests/TableProTextEngineTests/EmphasisManagerTests.swift b/apple/Packages/TableProEditor/Tests/TableProTextEngineTests/EmphasisManagerTests.swift index 3d567d1734..184aa82c30 100644 --- a/apple/Packages/TableProEditor/Tests/TableProTextEngineTests/EmphasisManagerTests.swift +++ b/apple/Packages/TableProEditor/Tests/TableProTextEngineTests/EmphasisManagerTests.swift @@ -64,6 +64,21 @@ struct EmphasisManagerTests { #expect(textView.layer?.sublayers?.count == 2) } + // The copy is drawn in the find highlight's black, over text that keeps its own colors under a bare outline. + @Test() + @MainActor + func anUnfilledOutlineDrawsNoCopyOfItsText() { + let textView = makeLaidOutTextView() + + textView.emphasisManager?.addEmphasis( + Emphasis(range: NSRange(location: 0, length: 5), style: .outline(color: .red)), + for: "e" + ) + + #expect(textView.layer?.sublayers?.count == 1) + #expect(textView.layer?.sublayers?.contains { $0 is CATextLayer } == false) + } + @Test() @MainActor func testEmphasisOutlivingTheTextItMarksSurvivesRedraw() { diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 31aad13037..f32ae35ac1 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -20590,6 +20590,9 @@ } } } + }, + "After keyword expansion, Tab moves through fields such as %1$@, and %2$@ marks where the cursor ends up." : { + }, "Agent" : { "localizations" : { diff --git a/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift b/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift index 96f8fe6f7a..3efb794220 100644 --- a/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift +++ b/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift @@ -169,9 +169,19 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { let text = textView.textView.textStorage?.string as NSString? let cursor = cursorPosition?.range.location + + if entry.item.kind == .favorite, let snippet = SQLSnippet.parse(entry.item.insertText) { + let range = resolvedReplacementRange(in: text, cursor: cursor, fallback: session.replacementRange) + textView.insertSnippet(EditorSnippet(snippet), replacing: range) + return + } + let edit = quotedNameEdit(for: entry.item, in: text, cursor: cursor, session: session) ?? tokenEdit(for: entry.item, in: text, cursor: cursor, fallback: session.replacementRange) + if textView.replaceInSnippetMirrors(edit.range, with: edit.text, caretOffset: edit.caret - edit.range.location) { + return + } textView.textView.replaceCharacters(in: [edit.range], with: edit.text) textView.setCursorPositions([CursorPosition(range: NSRange(location: edit.caret, length: 0))]) } @@ -222,6 +232,16 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { } } +extension EditorSnippet { + init(_ snippet: SQLSnippet) { + self.init( + text: snippet.text, + fields: snippet.fields.map { Field(ranges: $0.ranges, choices: $0.choices) }, + finalRange: snippet.finalRange + ) + } +} + // MARK: - SQLSuggestionEntry final class SQLSuggestionEntry: CodeSuggestionEntry { diff --git a/apple/TablePro/Views/Sidebar/FavoriteEditDialog.swift b/apple/TablePro/Views/Sidebar/FavoriteEditDialog.swift index b1f6786c97..cafa646f52 100644 --- a/apple/TablePro/Views/Sidebar/FavoriteEditDialog.swift +++ b/apple/TablePro/Views/Sidebar/FavoriteEditDialog.swift @@ -197,7 +197,10 @@ internal struct FavoriteEditDialog: View { Text("Query") } footer: { Text(String( - format: String(localized: "Type %@ in the query to set where the cursor lands after keyword expansion."), + format: String( + localized: "After keyword expansion, Tab moves through fields such as %1$@, and %2$@ marks where the cursor ends up." + ), + "${1:name}", SQLSnippetMarker.token )) .font(.caption) diff --git a/apple/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift b/apple/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift index 342d580ed7..7898dec386 100644 --- a/apple/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift +++ b/apple/TableProTests/Views/Editor/QueryCompletionAdapterLifecycleTests.swift @@ -404,6 +404,50 @@ extension GlobalStateSuites { #expect(controller.textView.string == "SELECT o.total FROM orders o, customers c") } + // MARK: - Saved query templates + + @MainActor + @Test("accepting a saved query with fields expands it, selects the first field and keeps $1") + func favoriteWithFieldsStartsASnippet() async throws { + let controller = EditorControllerFixture.make(string: "upd") + let adapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: .postgresql) + adapter.updateFavoriteKeywords(["upd": (name: "Update", query: "UPDATE ${1:t} SET ${2:c} = $1;;")]) + let caret = CursorPosition(range: NSRange(location: 3, length: 0)) + + let response = await adapter.completionSuggestionsRequested( + textView: controller, + cursorPosition: caret, + isManualTrigger: true + ) + let item = try #require(response?.items.first { $0.label == "upd" }) + adapter.completionWindowApplyCompletion(item: item, textView: controller, cursorPosition: caret) + + #expect(controller.textView.string == "UPDATE t SET c = $1") + #expect(controller.isSnippetSessionActive) + #expect(controller.textView.selectionManager.textSelections.map { $0.range } == [NSRange(location: 7, length: 1)]) + } + + @MainActor + @Test("accepting a saved query with only ;; places the caret as before") + func favoriteWithMarkerPlacesTheCaret() async throws { + let controller = EditorControllerFixture.make(string: "cnt") + let adapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: .postgresql) + adapter.updateFavoriteKeywords(["cnt": (name: "Count", query: "SELECT COUNT(*) FROM ;; WHERE 1")]) + let caret = CursorPosition(range: NSRange(location: 3, length: 0)) + + let response = await adapter.completionSuggestionsRequested( + textView: controller, + cursorPosition: caret, + isManualTrigger: true + ) + let item = try #require(response?.items.first { $0.label == "cnt" }) + adapter.completionWindowApplyCompletion(item: item, textView: controller, cursorPosition: caret) + + #expect(controller.textView.string == "SELECT COUNT(*) FROM WHERE 1") + #expect(controller.isSnippetSessionActive == false) + #expect(controller.textView.selectionManager.textSelections.map { $0.range } == [NSRange(location: 21, length: 0)]) + } + // MARK: - Helpers @MainActor From dc61f33af87f4d95dd8abcb87e7cbd1884cf2cfc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 11:53:11 +0700 Subject: [PATCH 2/2] docs(changelog): note saved query fields --- apple/changelog.d/3741.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 apple/changelog.d/3741.added.md diff --git a/apple/changelog.d/3741.added.md b/apple/changelog.d/3741.added.md new file mode 100644 index 0000000000..2b6629f0ca --- /dev/null +++ b/apple/changelog.d/3741.added.md @@ -0,0 +1 @@ +Saved query keywords expand into fields that Tab moves between, with default text, mirrored names and choices.