diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index caca6be0c..736ec1b52 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -204,13 +204,25 @@ extension TextViewController { } } + // MARK: - Key Codes + + static let tabKeyCode: UInt16 = 0x30 + static let downArrowKeyCode: UInt16 = 125 + static let upArrowKeyCode: UInt16 = 126 + + /// The set of modifier flags relevant to key binding matching. + /// Masks out transient flags like Caps Lock, Fn, and numeric pad that would + /// prevent exact-match comparisons from succeeding. + private static let relevantModifiers: NSEvent.ModifierFlags = [.shift, .control, .option, .command] + func handleEvent(event: NSEvent) -> NSEvent? { let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + .intersection(Self.relevantModifiers) switch event.type { case .keyDown: - let tabKey: UInt16 = 0x30 - - if event.keyCode == tabKey { + if event.keyCode == Self.downArrowKeyCode || event.keyCode == Self.upArrowKeyCode { + return self.handleArrowKey(event: event, modifierFlags: modifierFlags) + } else if event.keyCode == Self.tabKeyCode { return self.handleTab(event: event, modifierFlags: modifierFlags.rawValue) } else { return self.handleCommand(event: event, modifierFlags: modifierFlags) @@ -276,11 +288,66 @@ extension TextViewController { } jumpToDefinitionModel.performJump(at: cursor.range) return nil + case (controlKey, "n"): + self.textView.moveDown(nil) + return nil + case (controlKey, "p"): + self.textView.moveUp(nil) + return nil case (_, _): return event } } + /// Handles up/down arrow key events for plain, Shift, Option, Command, + /// and their combinations among these modifiers. + /// Dispatches the appropriate movement method on the text view and consumes the event. + /// + /// - Returns: `nil` to consume the event after dispatching the movement action, + /// or the original event for unsupported modifier combinations. + private func handleArrowKey(event: NSEvent, modifierFlags: NSEvent.ModifierFlags) -> NSEvent? { + let isDown = event.keyCode == Self.downArrowKeyCode + let shift = modifierFlags.contains(.shift) + let option = modifierFlags.contains(.option) + let command = modifierFlags.contains(.command) + + switch (isDown, shift, option, command) { + // Plain arrow + case (true, false, false, false): + self.textView.moveDown(nil) + case (false, false, false, false): + self.textView.moveUp(nil) + // Shift+Arrow (extend selection) + case (true, true, false, false): + self.textView.moveDownAndModifySelection(nil) + case (false, true, false, false): + self.textView.moveUpAndModifySelection(nil) + // Option+Arrow (paragraph) + case (true, false, true, false): + self.textView.moveToEndOfParagraph(nil) + case (false, false, true, false): + self.textView.moveToBeginningOfParagraph(nil) + // Cmd+Arrow (document) + case (true, false, false, true): + self.textView.moveToEndOfDocument(nil) + case (false, false, false, true): + self.textView.moveToBeginningOfDocument(nil) + // Shift+Option+Arrow (extend selection to paragraph) + case (true, true, true, false): + self.textView.moveToEndOfParagraphAndModifySelection(nil) + case (false, true, true, false): + self.textView.moveToBeginningOfParagraphAndModifySelection(nil) + // Shift+Cmd+Arrow (extend selection to document) + case (true, true, false, true): + self.textView.moveToEndOfDocumentAndModifySelection(nil) + case (false, true, false, true): + self.textView.moveToBeginningOfDocumentAndModifySelection(nil) + default: + return event + } + return nil + } + /// Handles the tab key event. /// If the Shift key is pressed, it handles unindenting. If no modifier key is pressed, it checks if multiple lines /// are highlighted and handles indenting accordingly. diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index fb95c81c1..b80f0e437 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -163,6 +163,21 @@ public class TextViewController: NSViewController { /// Toggle the visibility of the gutter view in the editor. public var showGutter: Bool { configuration.peripherals.showGutter } + /// Optional provider for custom gutter decorations. + public var gutterDecorationProvider: (any GutterDecorationProviding)? { + didSet { + gutterView.decorationProvider = gutterDecorationProvider + gutterView.needsDisplay = true + } + } + + /// Optional interaction delegate for gutter decorations. + public var gutterDecorationInteractionDelegate: (any GutterDecorationInteractionDelegate)? { + didSet { + gutterView.decorationInteractionDelegate = gutterDecorationInteractionDelegate + } + } + /// Toggle the visibility of the minimap view in the editor. public var showMinimap: Bool { configuration.peripherals.showMinimap } @@ -290,6 +305,16 @@ public class TextViewController: NSViewController { self.gutterView.setNeedsDisplay(self.gutterView.frame) } + /// Force the layout manager to recalculate its cached estimated line height. + /// + /// The estimated line height is cached and not invalidated when the font or line height multiplier changes, + /// causing vertical cursor movement (`moveDown:`/`moveUp:`) to use stale values and fail to cross line + /// boundaries. Re-assigning `renderDelegate` triggers the cache to refresh. + func refreshEstimatedLineHeightCache() { + let renderDelegate = textView.layoutManager.renderDelegate + textView.layoutManager.renderDelegate = renderDelegate + } + deinit { if let highlighter { textView.removeStorageDelegate(highlighter) diff --git a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift index 6ca03f2a7..13400245c 100644 --- a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift @@ -87,6 +87,7 @@ extension SourceEditorConfiguration { controller.textView.font = font controller.textView.typingAttributes = controller.attributesFor(nil) controller.gutterView.font = font.rulerFont + controller.refreshEstimatedLineHeightCache() needsHighlighterInvalidation = true } @@ -103,6 +104,7 @@ extension SourceEditorConfiguration { if oldConfig?.lineHeightMultiple != lineHeightMultiple { controller.textView.layoutManager.lineHeightMultiplier = lineHeightMultiple + controller.refreshEstimatedLineHeightCache() } if oldConfig?.wrapLines != wrapLines {