diff --git a/CHANGELOG.md b/CHANGELOG.md index 58fbc83f..1e2282a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `NativeTextViewWrapper.onUnhandledCommand` lets embedders handle Escape, + Tab, and Shift-Tab after inline previews and list editing decline them. - **Directive seam (parsing)**: opt-in named inline commands with typed arguments, for constructs that need a name and parameters rather than delimiters. A `MarkdownDirective` declares a name, a form — self-contained diff --git a/Demo/MarkdownEngineDemo/ContentView.swift b/Demo/MarkdownEngineDemo/ContentView.swift index eeaa34c9..5cb732db 100644 --- a/Demo/MarkdownEngineDemo/ContentView.swift +++ b/Demo/MarkdownEngineDemo/ContentView.swift @@ -26,6 +26,7 @@ struct ContentView: View { @State private var isReadOnly = false @State private var showRawSource = false @State private var useReadingColumn = false + @State private var lastHostCommand = "None" /// Registers/unregisters BOTH opt-in seams at once. The document is written /// so that flipping this off is the whole explanation of what is core @@ -46,6 +47,14 @@ struct ContentView: View { configuration: configuration, fontSize: fontSize, isEditable: !isReadOnly, + onUnhandledCommand: { command in + switch command { + case .escape: lastHostCommand = "Escape" + case .tab: lastHostCommand = "Tab" + case .backtab: lastHostCommand = "Shift-Tab" + } + return command == .escape + }, placeholder: NSAttributedString( string: "Empty document — start typing, markdown styles live…", attributes: [ @@ -63,6 +72,8 @@ struct ContentView: View { .id(useReadingColumn) .toolbar { ToolbarItemGroup { + Text("Host command: \(lastHostCommand)") + Toggle(isOn: $isReadOnly) { Label("Read-only", systemImage: isReadOnly ? "lock" : "lock.open") } diff --git a/Sources/MarkdownEngine/Input/MarkdownInputHandler.swift b/Sources/MarkdownEngine/Input/MarkdownInputHandler.swift index f4666c7b..734a336c 100644 --- a/Sources/MarkdownEngine/Input/MarkdownInputHandler.swift +++ b/Sources/MarkdownEngine/Input/MarkdownInputHandler.swift @@ -22,6 +22,17 @@ enum MarkdownInputHandler { replacementString: replacementString, isInsideCodeBlock: isInsideCodeBlock) } + /// Handles an AppKit Tab command before it becomes a proposed text edit. + /// Returns `true` only when list editing consumed the command. + static func handleTabCommand(textView: NSTextView, codeTokens: [MarkdownToken]? = nil) -> Bool { + guard textView.isEditable else { return false } + let location = textView.selectedRange().location + let isInsideCodeBlock = codeTokens.map { + MarkdownDetection.isInsideCodeBlock(location: location, codeTokens: $0) + } ?? MarkdownDetection.isInsideCodeBlock(location: location, in: textView.string) + return MarkdownLists.handleTab(textView: textView, isInsideCodeBlock: isInsideCodeBlock) + } + // MARK: - Block LaTeX Auto-Wrap private static func insertTextProgrammatically(_ textView: NSTextView, text: String, at range: NSRange, cursorAfter: Int) { diff --git a/Sources/MarkdownEngine/Input/MarkdownListHandler.swift b/Sources/MarkdownEngine/Input/MarkdownListHandler.swift index 80d2dc65..25099feb 100644 --- a/Sources/MarkdownEngine/Input/MarkdownListHandler.swift +++ b/Sources/MarkdownEngine/Input/MarkdownListHandler.swift @@ -175,37 +175,10 @@ struct MarkdownLists { return insertAutoPair(open: replacementString, close: closeChar) } - // TAB: indent list items (skip in code blocks) - if replacementString == "\t" && !isInCodeBlock { - guard listsEnabled else { return true } - let nsText = textView.string as NSString - let insertionLocation = affectedCharRange.location - let safeLocTAB = min(affectedCharRange.location, nsText.length) - let currentLineRange = nsText.lineRange(for: NSRange(location: safeLocTAB, length: 0)) - let currentLine = nsText.substring(with: currentLineRange) - if MarkdownLists.listRegex.firstMatch(in: currentLine, range: NSRange(location: 0, length: currentLine.utf16.count)) != nil { - if let wsMatch = MarkdownLists.leadingWhitespaceRegex.firstMatch(in: currentLine, range: NSRange(location: 0, length: currentLine.utf16.count)) { - let ws = (currentLine as NSString).substring(with: wsMatch.range) - let level = MarkdownLists.indentLevel(from: ws) - if level >= MarkdownEditorConfiguration.default.lists.maximumNestingLevel { - return false - } - } - MarkdownLists.performEdit(textView, replace: NSRange(location: currentLineRange.location, length: 0), with: "\t") - textView.setSelectedRange(NSRange(location: insertionLocation + 1, length: 0)) - return false - } - if MarkdownLists.dashNoSpaceRegex.firstMatch(in: currentLine, range: NSRange(location: 0, length: currentLine.utf16.count)) != nil { - if let wsMatch = MarkdownLists.leadingWhitespaceRegex.firstMatch(in: currentLine, range: NSRange(location: 0, length: currentLine.utf16.count)) { - let ws = (currentLine as NSString).substring(with: wsMatch.range) - let level = MarkdownLists.indentLevel(from: ws) - if level >= MarkdownEditorConfiguration.default.lists.maximumNestingLevel { return false } - } - MarkdownLists.performEdit(textView, replace: NSRange(location: currentLineRange.location, length: 0), with: "\t") - textView.setSelectedRange(NSRange(location: insertionLocation + 1, length: 0)) - return false - } - return true + // AppKit may still offer Tab as a text insertion when doCommandBy + // declines it. Share the same list ownership decision in both paths. + if replacementString == "\t" { + return !handleTab(textView: textView, isInsideCodeBlock: isInCodeBlock) } // ENTER: list continuation/outdent @@ -324,4 +297,46 @@ struct MarkdownLists { return true } + + /// Indents the current list item and reports whether the engine consumed + /// Tab. Maximum-depth list items remain consumed without changing text, + /// matching the previous input-handler behavior. + static func handleTab(textView: NSTextView, isInsideCodeBlock: Bool) -> Bool { + guard !isInsideCodeBlock else { return false } + let lists = (textView as? NativeTextView)?.configuration.lists + ?? MarkdownEditorConfiguration.default.lists + guard lists.helpersEnabled else { return false } + + let nsText = textView.string as NSString + let insertionLocation = min(textView.selectedRange().location, nsText.length) + let currentLineRange = nsText.lineRange(for: NSRange(location: insertionLocation, length: 0)) + let currentLine = nsText.substring(with: currentLineRange) + let isList = listRegex.firstMatch( + in: currentLine, + range: NSRange(location: 0, length: currentLine.utf16.count) + ) != nil + let isIncompleteList = dashNoSpaceRegex.firstMatch( + in: currentLine, + range: NSRange(location: 0, length: currentLine.utf16.count) + ) != nil + guard isList || isIncompleteList else { return false } + + if let whitespace = leadingWhitespaceRegex.firstMatch( + in: currentLine, + range: NSRange(location: 0, length: currentLine.utf16.count) + ) { + let prefix = (currentLine as NSString).substring(with: whitespace.range) + if indentLevel(from: prefix) >= lists.maximumNestingLevel { + return true + } + } + + performEdit( + textView, + replace: NSRange(location: currentLineRange.location, length: 0), + with: "\t" + ) + textView.setSelectedRange(NSRange(location: insertionLocation + 1, length: 0)) + return true + } } diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index 00157876..11a78d25 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -941,25 +941,42 @@ extension NativeTextViewCoordinator { } public func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - // Raw mode: default key handling (no ⇧⇥ outdent, no preview routing). - if configuration.rawSourceMode { return false } - if commandSelector == #selector(NSResponder.insertBacktab(_:)) { - return handleBacktab(textView) - } - // While an inline [[…]] / ![[…]] preview is open, route ↑/↓/Enter/Esc to the embedder's - // autocomplete list (it returns true to consume the key; false → normal editor handling). - if (isWikiLinkActive || isImageEmbedActive), let handler = onInlinePreviewKey { - let key: InlinePreviewKey? - switch commandSelector { - case #selector(NSResponder.moveUp(_:)): key = .moveUp - case #selector(NSResponder.moveDown(_:)): key = .moveDown - case #selector(NSResponder.insertNewline(_:)): key = .confirm // ⌘↵ → handled in performKeyEquivalent - case #selector(NSResponder.cancelOperation(_:)): key = .cancel - default: key = nil + if !configuration.rawSourceMode { + // While an inline [[…]] / ![[…]] preview is open, route ↑/↓/Enter/Esc to the embedder's + // autocomplete list (it returns true to consume the key; false → normal editor handling). + if (isWikiLinkActive || isImageEmbedActive), let handler = onInlinePreviewKey { + let key: InlinePreviewKey? + switch commandSelector { + case #selector(NSResponder.moveUp(_:)): key = .moveUp + case #selector(NSResponder.moveDown(_:)): key = .moveDown + case #selector(NSResponder.insertNewline(_:)): key = .confirm // ⌘↵ → handled in performKeyEquivalent + case #selector(NSResponder.cancelOperation(_:)): key = .cancel + default: key = nil + } + if let key, handler(key) { return true } + } + + if commandSelector == #selector(NSResponder.insertTab(_:)) { + let parsed = parsedDocument(for: textView.string) + if MarkdownInputHandler.handleTabCommand(textView: textView, codeTokens: parsed.codeTokens) { + return true + } + } else if commandSelector == #selector(NSResponder.insertBacktab(_:)), + textView.isEditable, + handleBacktab(textView) { + return true } - if let key, handler(key) { return true } } - return false + + let command: MarkdownEditorCommand? + switch commandSelector { + case #selector(NSResponder.cancelOperation(_:)): command = .escape + case #selector(NSResponder.insertTab(_:)): command = .tab + case #selector(NSResponder.insertBacktab(_:)): command = .backtab + default: command = nil + } + guard let command else { return false } + return onUnhandledCommand?(command) ?? false } public func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { @@ -1077,7 +1094,7 @@ extension NativeTextViewCoordinator { let isLegacyBulletGlyph = markerString.first == "•" let minDepth = isLegacyBulletGlyph ? 1 : 0 if depth <= minDepth { - return true + return false } if wsRangeLocal.length > 0 { diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index aa30264b..ffed8ca6 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -84,6 +84,7 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { var onBuildContextMenu: ((NSMenu, NSRange) -> NSMenu)? var onInlineSelectionChange: ((InlineSelectionState?) -> Void)? var onInlinePreviewKey: ((InlinePreviewKey) -> Bool)? + var onUnhandledCommand: ((MarkdownEditorCommand) -> Bool)? var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)? var didInitialFormatting: Bool = false /// One-shot guard so `updateCodeBlockSelection` only forces a full-document layout once per document. diff --git a/Sources/MarkdownEngine/TextView/MarkdownEditorCommand.swift b/Sources/MarkdownEngine/TextView/MarkdownEditorCommand.swift new file mode 100644 index 00000000..09819ada --- /dev/null +++ b/Sources/MarkdownEngine/TextView/MarkdownEditorCommand.swift @@ -0,0 +1,14 @@ +// +// MarkdownEditorCommand.swift +// MarkdownEngine +// + +/// An editor command the engine did not consume and is offering to its host. +public enum MarkdownEditorCommand: Sendable, Equatable { + /// The standard AppKit cancel operation, normally produced by Escape. + case escape + /// Forward tab traversal after editor-owned list indentation declines it. + case tab + /// Backward tab traversal after editor-owned list outdentation declines it. + case backtab +} diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index e081003e..f23cc427 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -96,6 +96,10 @@ public struct NativeTextViewWrapper: NSViewRepresentable { /// Fires on ↑/↓/Enter/Esc while an inline `[[…]]` preview is open, so the /// embedder can drive its autocomplete list. Return `true` to consume the key. public var onInlinePreviewKey: ((InlinePreviewKey) -> Bool)? + /// Receives Escape, Tab, or Shift-Tab only after the engine declines the + /// command. Return `true` when the host consumed it; `false` preserves the + /// normal AppKit fallback. Inline previews and list editing take priority. + public var onUnhandledCommand: ((MarkdownEditorCommand) -> Bool)? /// Fires when the set of visible code blocks changes, so embedders can /// overlay copy buttons (see ``CodeBlockButton``). public var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)? @@ -157,6 +161,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable { onBuildContextMenu: ((NSMenu, NSRange) -> NSMenu)? = nil, onInlineSelectionChange: ((InlineSelectionState?) -> Void)? = nil, onInlinePreviewKey: ((InlinePreviewKey) -> Bool)? = nil, + onUnhandledCommand: ((MarkdownEditorCommand) -> Bool)? = nil, onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)? = nil, onSpellCheckingPolicyChanged: ((SpellCheckingPolicy) -> Void)? = nil, placeholder: NSAttributedString? = nil, @@ -183,6 +188,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable { self.onBuildContextMenu = onBuildContextMenu self.onInlineSelectionChange = onInlineSelectionChange self.onInlinePreviewKey = onInlinePreviewKey + self.onUnhandledCommand = onUnhandledCommand self.onCodeBlockSelectionChange = onCodeBlockSelectionChange self.onSpellCheckingPolicyChanged = onSpellCheckingPolicyChanged self.placeholder = placeholder @@ -333,6 +339,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable { context.coordinator.onBuildContextMenu = onBuildContextMenu context.coordinator.onInlineSelectionChange = onInlineSelectionChange context.coordinator.onInlinePreviewKey = onInlinePreviewKey + context.coordinator.onUnhandledCommand = onUnhandledCommand context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange textView.recalcOverscroll(for: scrollView) @@ -701,6 +708,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable { context.coordinator.onBuildContextMenu = onBuildContextMenu context.coordinator.onInlineSelectionChange = onInlineSelectionChange context.coordinator.onInlinePreviewKey = onInlinePreviewKey + context.coordinator.onUnhandledCommand = onUnhandledCommand context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange context.coordinator.didInitialFormatting = true } diff --git a/Tests/MarkdownEngineTests/UnhandledCommandTests.swift b/Tests/MarkdownEngineTests/UnhandledCommandTests.swift new file mode 100644 index 00000000..9245185a --- /dev/null +++ b/Tests/MarkdownEngineTests/UnhandledCommandTests.swift @@ -0,0 +1,232 @@ +// +// UnhandledCommandTests.swift +// MarkdownEngineTests +// + +import AppKit +import SwiftUI +import Testing +@testable import MarkdownEngine + +@MainActor +@Suite("Unhandled editor commands") +struct UnhandledCommandTests { + @Test("Preview consumption wins over the host callback") + func previewWins() { + let (coordinator, textView) = makeEditor("[[link]]") + coordinator.isImageEmbedActive = true + var previewCalls = 0 + var hostCalls = 0 + coordinator.onInlinePreviewKey = { key in + previewCalls += 1 + return key == .cancel + } + coordinator.onUnhandledCommand = { _ in hostCalls += 1; return true } + + let consumed = coordinator.textView(textView, doCommandBy: #selector(NSResponder.cancelOperation(_:))) + + #expect(consumed) + #expect(previewCalls == 1) + #expect(hostCalls == 0) + } + + @Test("A declined preview offers Escape to the host exactly once") + func declinedPreviewFallsBackOnce() { + let (coordinator, textView) = makeEditor("[[link]]") + coordinator.isImageEmbedActive = true + var received: [MarkdownEditorCommand] = [] + coordinator.onInlinePreviewKey = { _ in false } + coordinator.onUnhandledCommand = { received.append($0); return true } + + let consumed = coordinator.textView(textView, doCommandBy: #selector(NSResponder.cancelOperation(_:))) + + #expect(consumed) + #expect(received == [.escape]) + } + + @Test("A list consumes Tab before the host") + func listTabWins() { + let (coordinator, textView) = makeEditor("- item") + textView.setSelectedRange(NSRange(location: 3, length: 0)) + var hostCalls = 0 + coordinator.onUnhandledCommand = { _ in hostCalls += 1; return true } + + let consumed = coordinator.textView(textView, doCommandBy: #selector(NSResponder.insertTab(_:))) + + #expect(consumed) + #expect(textView.string == "\t- item") + #expect(hostCalls == 0) + } + + @Test("Maximum list depth stays consumed without forwarding") + func maximumDepthStaysConsumed() { + let (coordinator, textView) = makeEditor("\t\t\t- item") + textView.setSelectedRange(NSRange(location: 6, length: 0)) + var hostCalls = 0 + coordinator.onUnhandledCommand = { _ in hostCalls += 1; return true } + + let consumed = coordinator.textView(textView, doCommandBy: #selector(NSResponder.insertTab(_:))) + + #expect(consumed) + #expect(textView.string == "\t\t\t- item") + #expect(hostCalls == 0) + } + + @Test("A nested list consumes Backtab before the host") + func listBacktabWins() { + let (coordinator, textView) = makeEditor("\t- item") + textView.setSelectedRange(NSRange(location: 4, length: 0)) + var hostCalls = 0 + coordinator.onUnhandledCommand = { _ in hostCalls += 1; return true } + + let consumed = coordinator.textView(textView, doCommandBy: #selector(NSResponder.insertBacktab(_:))) + + #expect(consumed) + #expect(textView.string == "- item") + #expect(hostCalls == 0) + } + + @Test("Backtab on a top-level list item reaches the host") + func topLevelListBacktabFallsBack() { + let (coordinator, textView) = makeEditor("- item") + textView.setSelectedRange(NSRange(location: 3, length: 0)) + var received: [MarkdownEditorCommand] = [] + coordinator.onUnhandledCommand = { received.append($0); return true } + + let consumed = coordinator.textView(textView, doCommandBy: #selector(NSResponder.insertBacktab(_:))) + + #expect(consumed) + #expect(textView.string == "- item") + #expect(received == [.backtab]) + } + + @Test("Host Bool result controls Tab fallback", arguments: [true, false]) + func hostResult(result: Bool) { + let (coordinator, textView) = makeEditor("plain") + var received: [MarkdownEditorCommand] = [] + coordinator.onUnhandledCommand = { received.append($0); return result } + + let consumed = coordinator.textView(textView, doCommandBy: #selector(NSResponder.insertTab(_:))) + + #expect(consumed == result) + #expect(received == [.tab]) + #expect(textView.string == "plain") + } + + @Test("Backtab outside a list reaches the host") + func backtabFallsBack() { + let (coordinator, textView) = makeEditor("plain") + var received: [MarkdownEditorCommand] = [] + coordinator.onUnhandledCommand = { received.append($0); return true } + + let consumed = coordinator.textView(textView, doCommandBy: #selector(NSResponder.insertBacktab(_:))) + + #expect(consumed) + #expect(received == [.backtab]) + } + + @Test("Raw mode skips list handling but still offers Tab to the host") + func rawModeFallsBack() { + let (coordinator, textView) = makeEditor("- item", rawSourceMode: true) + textView.setSelectedRange(NSRange(location: 3, length: 0)) + var received: [MarkdownEditorCommand] = [] + coordinator.onUnhandledCommand = { received.append($0); return true } + + let consumed = coordinator.textView(textView, doCommandBy: #selector(NSResponder.insertTab(_:))) + + #expect(consumed) + #expect(received == [.tab]) + #expect(textView.string == "- item") + } + + @Test("Raw mode skips list outdent but still offers Backtab to the host") + func rawBacktabFallsBack() { + let (coordinator, textView) = makeEditor("\t- item", rawSourceMode: true) + textView.setSelectedRange(NSRange(location: 4, length: 0)) + var received: [MarkdownEditorCommand] = [] + coordinator.onUnhandledCommand = { received.append($0); return true } + + let consumed = coordinator.textView(textView, doCommandBy: #selector(NSResponder.insertBacktab(_:))) + + #expect(consumed) + #expect(received == [.backtab]) + #expect(textView.string == "\t- item") + } + + @Test("Tab inside a fenced code block reaches the host") + func codeBlockTabFallsBack() { + let (coordinator, textView) = makeEditor("```\ncode\n```") + textView.setSelectedRange(NSRange(location: 6, length: 0)) + var received: [MarkdownEditorCommand] = [] + coordinator.onUnhandledCommand = { received.append($0); return true } + + let consumed = coordinator.textView(textView, doCommandBy: #selector(NSResponder.insertTab(_:))) + + #expect(consumed) + #expect(received == [.tab]) + #expect(textView.string == "```\ncode\n```") + } + + @Test("Disabled list helpers leave Tab to the host") + func disabledListHelpersFallBack() { + let (coordinator, textView) = makeEditor("- item", listHelpersEnabled: false) + textView.setSelectedRange(NSRange(location: 3, length: 0)) + var received: [MarkdownEditorCommand] = [] + coordinator.onUnhandledCommand = { received.append($0); return true } + + let consumed = coordinator.textView(textView, doCommandBy: #selector(NSResponder.insertTab(_:))) + + #expect(consumed) + #expect(received == [.tab]) + #expect(textView.string == "- item") + } + + @Test("Unrelated selectors are ignored") + func unrelatedSelector() { + let (coordinator, textView) = makeEditor("plain") + var hostCalls = 0 + coordinator.onUnhandledCommand = { _ in hostCalls += 1; return true } + + let consumed = coordinator.textView(textView, doCommandBy: #selector(NSResponder.moveLeft(_:))) + + #expect(consumed == false) + #expect(hostCalls == 0) + } + + @Test("Omitting the callback preserves AppKit fallback") + func omittedCallback() { + let wrapper = NativeTextViewWrapper(text: .constant("")) + #expect(wrapper.onUnhandledCommand == nil) + + let (coordinator, textView) = makeEditor("plain") + let consumed = coordinator.textView(textView, doCommandBy: #selector(NSResponder.cancelOperation(_:))) + #expect(consumed == false) + } + + private func makeEditor( + _ text: String, + rawSourceMode: Bool = false, + listHelpersEnabled: Bool = true + ) -> (NativeTextViewCoordinator, NativeTextView) { + let textView = NativeTextView(frame: NSRect(x: 0, y: 0, width: 300, height: 120)) + textView.isEditable = true + textView.string = text + var configuration = MarkdownEditorConfiguration.default + configuration.rawSourceMode = rawSourceMode + configuration.lists.helpersEnabled = listHelpersEnabled + textView.configuration = configuration + + let coordinator = NativeTextViewCoordinator( + text: .constant(text), + fontName: "SF Pro Text", + fontSize: 14, + isWikiLinkActive: .constant(false), + onLinkClick: nil, + onInlineSelectionChange: nil + ) + coordinator.configuration = configuration + coordinator.textView = textView + textView.delegate = coordinator + return (coordinator, textView) + } +}