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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Comment on lines +291 to +296
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new Ctrl-N / Ctrl-P shortcuts match only when modifierFlags equals exactly .control. Because modifierFlags is built from .deviceIndependentFlagsMask, it can include extra flags (e.g. Caps Lock / Fn / numericPad), which would prevent these bindings from firing. Consider normalizing the flags to only [.shift, .control, .option, .command] before switching, or using .contains(.control) with explicit exclusions for other modifiers as needed.

Copilot uses AI. Check for mistakes.
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.
Expand Down
25 changes: 25 additions & 0 deletions Sources/CodeEditSourceEditor/Controller/TextViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -103,6 +104,7 @@ extension SourceEditorConfiguration {

if oldConfig?.lineHeightMultiple != lineHeightMultiple {
controller.textView.layoutManager.lineHeightMultiplier = lineHeightMultiple
controller.refreshEstimatedLineHeightCache()
}

if oldConfig?.wrapLines != wrapLines {
Expand Down