Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions Demo/MarkdownEngineDemo/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: [
Expand All @@ -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")
}
Expand Down
11 changes: 11 additions & 0 deletions Sources/MarkdownEngine/Input/MarkdownInputHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
77 changes: 46 additions & 31 deletions Sources/MarkdownEngine/Input/MarkdownListHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions Sources/MarkdownEngine/TextView/MarkdownEditorCommand.swift
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 8 additions & 0 deletions Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
Loading