From 5b8e8fbbb0c8123121f0624e7e94f77aed8aff2e Mon Sep 17 00:00:00 2001 From: kenpo Date: Sat, 7 Feb 2026 21:36:03 -1000 Subject: [PATCH 1/2] Fix down arrow key not working after font size change The cached _estimateLineHeight in TextLayoutManager was never invalidated when the font changed. The vertical cursor movement calculation uses this estimate to compute the target y-coordinate, and after enough font size increases the stale (too small) value prevented moveDown: from crossing into the next line. Up arrow was unaffected because subtracting from the line top always lands in the previous line. Fix: re-assign renderDelegate after font/lineHeight changes to trigger its didSet which clears the cached estimate. Also handle arrow keys explicitly in the event monitor and add Ctrl+N/P (moveDown/moveUp) to handleCommand for robustness. Co-Authored-By: Claude Opus 4.6 --- .../TextViewController+Lifecycle.swift | 59 ++++++++++++++++++- ...SourceEditorConfiguration+Appearance.swift | 8 +++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index caca6be0c..c13ec5dee 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -209,8 +209,12 @@ extension TextViewController { switch event.type { case .keyDown: let tabKey: UInt16 = 0x30 + let downArrow: UInt16 = 125 + let upArrow: UInt16 = 126 - if event.keyCode == tabKey { + if event.keyCode == downArrow || event.keyCode == upArrow { + return self.handleArrowKey(event: event, modifierFlags: modifierFlags) + } else if event.keyCode == tabKey { return self.handleTab(event: event, modifierFlags: modifierFlags.rawValue) } else { return self.handleCommand(event: event, modifierFlags: modifierFlags) @@ -276,11 +280,64 @@ 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 with all modifier combinations. + /// Dispatches the appropriate movement method on the text view and consumes the event. + /// + /// - Returns: `nil` to consume the event after dispatching the movement action. + private func handleArrowKey(event: NSEvent, modifierFlags: NSEvent.ModifierFlags) -> NSEvent? { + let isDown = event.keyCode == 125 + 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/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift index 6ca03f2a7..772ae766a 100644 --- a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift @@ -87,6 +87,11 @@ extension SourceEditorConfiguration { controller.textView.font = font controller.textView.typingAttributes = controller.attributesFor(nil) controller.gutterView.font = font.rulerFont + // Force the layout manager to recalculate its cached estimated line height. + // The estimate is cached and not invalidated by font changes, causing vertical + // cursor movement (moveDown:) to use stale values and fail to cross line boundaries. + let renderDelegate = controller.textView.layoutManager.renderDelegate + controller.textView.layoutManager.renderDelegate = renderDelegate needsHighlighterInvalidation = true } @@ -103,6 +108,9 @@ extension SourceEditorConfiguration { if oldConfig?.lineHeightMultiple != lineHeightMultiple { controller.textView.layoutManager.lineHeightMultiplier = lineHeightMultiple + // Also invalidate the cached estimated line height (same issue as font change above). + let renderDelegate = controller.textView.layoutManager.renderDelegate + controller.textView.layoutManager.renderDelegate = renderDelegate } if oldConfig?.wrapLines != wrapLines { From 3335aee69a5198e47827879da6449c73669c6a9b Mon Sep 17 00:00:00 2001 From: kenpo Date: Sun, 8 Feb 2026 12:14:51 -1000 Subject: [PATCH 2/2] Address PR #364 review comments from Copilot - Extract renderDelegate cache invalidation into refreshEstimatedLineHeightCache() helper - Normalize modifier flags to [shift, control, option, command] so Ctrl-N/P work with Caps Lock/Fn - Fix handleArrowKey doc comment to accurately describe supported modifier combinations - Centralize key code constants (tab, downArrow, upArrow) as static properties Co-Authored-By: Claude Opus 4.6 --- .../TextViewController+Lifecycle.swift | 28 +++++++++++++------ .../Controller/TextViewController.swift | 25 +++++++++++++++++ ...SourceEditorConfiguration+Appearance.swift | 10 ++----- 3 files changed, 46 insertions(+), 17 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index c13ec5dee..736ec1b52 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -204,17 +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 - let downArrow: UInt16 = 125 - let upArrow: UInt16 = 126 - - if event.keyCode == downArrow || event.keyCode == upArrow { + if event.keyCode == Self.downArrowKeyCode || event.keyCode == Self.upArrowKeyCode { return self.handleArrowKey(event: event, modifierFlags: modifierFlags) - } else if event.keyCode == tabKey { + } else if event.keyCode == Self.tabKeyCode { return self.handleTab(event: event, modifierFlags: modifierFlags.rawValue) } else { return self.handleCommand(event: event, modifierFlags: modifierFlags) @@ -291,12 +299,14 @@ extension TextViewController { } } - /// Handles up/down arrow key events with all modifier combinations. + /// 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. + /// - 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 == 125 + let isDown = event.keyCode == Self.downArrowKeyCode let shift = modifierFlags.contains(.shift) let option = modifierFlags.contains(.option) let command = modifierFlags.contains(.command) 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 772ae766a..13400245c 100644 --- a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift @@ -87,11 +87,7 @@ extension SourceEditorConfiguration { controller.textView.font = font controller.textView.typingAttributes = controller.attributesFor(nil) controller.gutterView.font = font.rulerFont - // Force the layout manager to recalculate its cached estimated line height. - // The estimate is cached and not invalidated by font changes, causing vertical - // cursor movement (moveDown:) to use stale values and fail to cross line boundaries. - let renderDelegate = controller.textView.layoutManager.renderDelegate - controller.textView.layoutManager.renderDelegate = renderDelegate + controller.refreshEstimatedLineHeightCache() needsHighlighterInvalidation = true } @@ -108,9 +104,7 @@ extension SourceEditorConfiguration { if oldConfig?.lineHeightMultiple != lineHeightMultiple { controller.textView.layoutManager.lineHeightMultiplier = lineHeightMultiple - // Also invalidate the cached estimated line height (same issue as font change above). - let renderDelegate = controller.textView.layoutManager.renderDelegate - controller.textView.layoutManager.renderDelegate = renderDelegate + controller.refreshEstimatedLineHeightCache() } if oldConfig?.wrapLines != wrapLines {