Skip to content

Commit b4e7fce

Browse files
committed
fix: add Home/End keys, duplicate/delete/move line shortcuts in SQL editor (#448)
1 parent e3d6d34 commit b4e7fce

File tree

8 files changed

+233
-45
lines changed

8 files changed

+233
-45
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919

2020
- SQL editor not auto-focused on new tab and cursor missing after tab switch
2121
- Long lines not scrollable horizontally in the SQL editor
22+
- Home and End keys not moving cursor in the SQL editor (#448)
2223
- SSH profile lost after app restart when iCloud Sync enabled
2324
- MariaDB JSON columns showing as hex dumps instead of JSON text
2425
- MongoDB Atlas TLS certificate verification failure

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
// Created by Khan Winter on 10/14/23.
66
//
77

8-
import CodeEditTextView
98
import AppKit
9+
import Carbon.HIToolbox
10+
import CodeEditTextView
1011

1112
extension TextViewController {
1213
override public func viewWillAppear() {
@@ -262,6 +263,12 @@ extension TextViewController {
262263
_ = self.textView.resignFirstResponder()
263264
self.findViewController?.showFindPanel()
264265
return nil
266+
case ([commandKey, .shift], "D"):
267+
duplicateLine()
268+
return nil
269+
case ([commandKey, .shift], "K"):
270+
deleteLine()
271+
return nil
265272
case (.init(rawValue: 0), "\u{1b}"): // Escape key
266273
if findViewController?.viewModel.isShowingFindPanel == true {
267274
self.findViewController?.hideFindPanel()
@@ -278,6 +285,23 @@ extension TextViewController {
278285
jumpToDefinitionModel.performJump(at: cursor.range)
279286
return nil
280287
case (_, _):
288+
// Handle key-code-based shortcuts (arrow keys don't have stable characters)
289+
return handleKeyCodeCommand(event: event, modifierFlags: modifierFlags)
290+
}
291+
}
292+
293+
private func handleKeyCodeCommand(event: NSEvent, modifierFlags: NSEvent.ModifierFlags) -> NSEvent? {
294+
// Strip .numericPad — arrow keys include it on macOS
295+
let flags = modifierFlags.subtracting(.numericPad)
296+
297+
switch (flags, Int(event.keyCode)) {
298+
case (.option, kVK_UpArrow):
299+
moveLinesUp()
300+
return nil
301+
case (.option, kVK_DownArrow):
302+
moveLinesDown()
303+
return nil
304+
default:
281305
return event
282306
}
283307
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
//
2+
// TextViewController+LineOperations.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Line-level editing operations: duplicate, delete.
6+
//
7+
8+
import AppKit
9+
import CodeEditTextView
10+
11+
extension TextViewController {
12+
/// Duplicates the current line(s) below the selection (Cmd+D).
13+
func duplicateLine() {
14+
guard let selection = textView.selectionManager.textSelections.first else { return }
15+
guard let lineIndexes = getOverlappingLines(for: selection.range),
16+
let firstLine = textView.layoutManager.textLineForIndex(lineIndexes.lowerBound),
17+
let lastLine = textView.layoutManager.textLineForIndex(lineIndexes.upperBound) else {
18+
return
19+
}
20+
21+
let fullRange = NSRange(
22+
location: firstLine.range.location,
23+
length: lastLine.range.upperBound - firstLine.range.location
24+
)
25+
guard let text = textView.textStorage.substring(from: fullRange) else { return }
26+
27+
textView.undoManager?.beginUndoGrouping()
28+
29+
// If line includes trailing \n, insert the text as-is after the line.
30+
// If no trailing \n (last line), prepend \n before inserting.
31+
let insertText = text.hasSuffix("\n") ? text : "\n" + text
32+
let insertionPoint = fullRange.upperBound
33+
textView.replaceCharacters(in: NSRange(location: insertionPoint, length: 0), with: insertText)
34+
35+
let offset = selection.range.location - fullRange.location
36+
let newLocation = insertionPoint + (text.hasSuffix("\n") ? 0 : 1) + offset
37+
setCursorPositions([CursorPosition(range: NSRange(location: newLocation, length: 0))])
38+
39+
textView.undoManager?.endUndoGrouping()
40+
}
41+
42+
/// Deletes the current line(s) (Cmd+Shift+K).
43+
func deleteLine() {
44+
guard let selection = textView.selectionManager.textSelections.first else { return }
45+
guard let lineIndexes = getOverlappingLines(for: selection.range),
46+
let firstLine = textView.layoutManager.textLineForIndex(lineIndexes.lowerBound),
47+
let lastLine = textView.layoutManager.textLineForIndex(lineIndexes.upperBound) else {
48+
return
49+
}
50+
51+
let fullRange = NSRange(
52+
location: firstLine.range.location,
53+
length: lastLine.range.upperBound - firstLine.range.location
54+
)
55+
56+
textView.undoManager?.beginUndoGrouping()
57+
58+
textView.replaceCharacters(in: fullRange, with: "")
59+
let newLocation = min(fullRange.location, textView.textStorage.length)
60+
setCursorPositions([CursorPosition(range: NSRange(location: newLocation, length: 0))])
61+
62+
textView.undoManager?.endUndoGrouping()
63+
}
64+
}

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+MoveLines.swift

Lines changed: 76 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,61 +11,100 @@ extension TextViewController {
1111
/// Moves the selected lines up by one line.
1212
public func moveLinesUp() {
1313
guard !cursorPositions.isEmpty else { return }
14+
guard let selection = textView.selectionManager.textSelections.first,
15+
let lineIndexes = getOverlappingLines(for: selection.range) else { return }
16+
let firstIndex = lineIndexes.lowerBound
17+
guard firstIndex > 0 else { return }
1418

15-
textView.undoManager?.beginUndoGrouping()
16-
17-
textView.editSelections { textView, selection in
18-
guard let lineIndexes = getOverlappingLines(for: selection.range) else { return }
19-
let lowerBound = lineIndexes.lowerBound
20-
guard lowerBound > .zero,
21-
let prevLineInfo = textView.layoutManager.textLineForIndex(lowerBound - 1),
22-
let prevString = textView.textStorage.substring(from: prevLineInfo.range),
23-
let lastSelectedString = textView.layoutManager.textLineForIndex(lineIndexes.upperBound) else {
24-
return
25-
}
19+
guard let prevLine = textView.layoutManager.textLineForIndex(firstIndex - 1),
20+
let firstSelectedLine = textView.layoutManager.textLineForIndex(firstIndex),
21+
let lastSelectedLine = textView.layoutManager.textLineForIndex(lineIndexes.upperBound) else {
22+
return
23+
}
2624

27-
textView.insertString(prevString, at: lastSelectedString.range.upperBound)
28-
textView.replaceCharacters(in: [prevLineInfo.range], with: String())
25+
// Combined range: previous line + selected lines
26+
let combinedRange = NSRange(
27+
location: prevLine.range.location,
28+
length: lastSelectedLine.range.upperBound - prevLine.range.location
29+
)
30+
guard let combinedText = textView.textStorage.substring(from: combinedRange) else { return }
2931

30-
let rangeToSelect = NSRange(
31-
start: prevLineInfo.range.lowerBound,
32-
end: lastSelectedString.range.location - prevLineInfo.range.length + lastSelectedString.range.length
33-
)
32+
// Split into previous line text and selected lines text (UTF-16 safe)
33+
let selectedStart = firstSelectedLine.range.location - prevLine.range.location
34+
let nsCombined = combinedText as NSString
35+
let prevText = nsCombined.substring(to: selectedStart)
36+
let selectedText = nsCombined.substring(from: selectedStart)
3437

35-
setCursorPositions([CursorPosition(range: rangeToSelect)], scrollToVisible: true)
38+
// Ensure both parts have proper newline handling
39+
let newText: String
40+
if selectedText.hasSuffix("\n") {
41+
newText = selectedText + prevText
42+
} else if prevText.hasSuffix("\n") {
43+
// Selected text is last line (no trailing \n), prev has \n
44+
newText = selectedText + "\n" + String(prevText.dropLast())
45+
} else {
46+
newText = selectedText + "\n" + prevText
3647
}
3748

49+
textView.undoManager?.beginUndoGrouping()
50+
textView.replaceCharacters(in: combinedRange, with: newText)
51+
52+
// Place cursor at the start of the moved line
53+
setCursorPositions(
54+
[CursorPosition(range: NSRange(location: prevLine.range.location, length: 0))],
55+
scrollToVisible: true
56+
)
3857
textView.undoManager?.endUndoGrouping()
3958
}
4059

4160
/// Moves the selected lines down by one line.
4261
public func moveLinesDown() {
4362
guard !cursorPositions.isEmpty else { return }
63+
guard let selection = textView.selectionManager.textSelections.first,
64+
let lineIndexes = getOverlappingLines(for: selection.range) else { return }
65+
let lastIndex = lineIndexes.upperBound
66+
guard lastIndex + 1 < textView.layoutManager.lineCount else { return }
4467

45-
textView.undoManager?.beginUndoGrouping()
46-
47-
textView.editSelections { textView, selection in
48-
guard let lineIndexes = getOverlappingLines(for: selection.range) else { return }
49-
let totalLines = textView.layoutManager.lineCount
50-
let upperBound = lineIndexes.upperBound
51-
guard upperBound + 1 < totalLines,
52-
let nextLineInfo = textView.layoutManager.textLineForIndex(upperBound + 1),
53-
let nextString = textView.textStorage.substring(from: nextLineInfo.range),
54-
let firstSelectedString = textView.layoutManager.textLineForIndex(lineIndexes.lowerBound) else {
55-
return
56-
}
68+
guard let firstSelectedLine = textView.layoutManager.textLineForIndex(lineIndexes.lowerBound),
69+
let lastSelectedLine = textView.layoutManager.textLineForIndex(lastIndex),
70+
let nextLine = textView.layoutManager.textLineForIndex(lastIndex + 1),
71+
nextLine.range.length > 0 else {
72+
return
73+
}
5774

58-
textView.replaceCharacters(in: [nextLineInfo.range], with: String())
59-
textView.insertString(nextString, at: firstSelectedString.range.lowerBound)
75+
// Combined range: selected lines + next line
76+
let combinedRange = NSRange(
77+
location: firstSelectedLine.range.location,
78+
length: nextLine.range.upperBound - firstSelectedLine.range.location
79+
)
80+
guard let combinedText = textView.textStorage.substring(from: combinedRange) else { return }
6081

61-
let rangeToSelect = NSRange(
62-
start: firstSelectedString.range.location + nextLineInfo.range.length,
63-
end: nextLineInfo.range.upperBound
64-
)
82+
// Split into selected lines text and next line text (UTF-16 safe)
83+
let selectedLength = lastSelectedLine.range.upperBound - firstSelectedLine.range.location
84+
let nsCombined = combinedText as NSString
85+
let selectedText = nsCombined.substring(to: selectedLength)
86+
let nextText = nsCombined.substring(from: selectedLength)
6587

66-
setCursorPositions([CursorPosition(range: rangeToSelect)], scrollToVisible: true)
88+
// Ensure both parts have proper newline handling
89+
let newText: String
90+
if nextText.hasSuffix("\n") {
91+
newText = nextText + selectedText
92+
} else if selectedText.hasSuffix("\n") {
93+
newText = nextText + "\n" + String(selectedText.dropLast())
94+
} else {
95+
newText = nextText + "\n" + selectedText
6796
}
6897

98+
textView.undoManager?.beginUndoGrouping()
99+
textView.replaceCharacters(in: combinedRange, with: newText)
100+
101+
// Place cursor at the start of the moved line in its new position
102+
let nextLen = (nextText as NSString).length + (nextText.hasSuffix("\n") ? 0 : 1)
103+
let newLocation = firstSelectedLine.range.location + nextLen
104+
setCursorPositions(
105+
[CursorPosition(range: NSRange(location: newLocation, length: 0))],
106+
scrollToVisible: true
107+
)
69108
textView.undoManager?.endUndoGrouping()
70109
}
71110
}

LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ extension TextView {
1717

1818
NSCursor.setHiddenUntilMouseMoves(true)
1919

20+
// Handle Home/End explicitly — AppKit's default key bindings map these
21+
// to scrollToBeginningOfDocument:/scrollToEndOfDocument: which only
22+
// scroll without moving the cursor. We redirect to move actions instead.
23+
if handleHomeEndKey(event) {
24+
return
25+
}
26+
2027
if !(inputContext?.handleEvent(event) ?? false) {
2128
interpretKeyEvents([event])
2229
} else {
@@ -25,6 +32,33 @@ extension TextView {
2532
}
2633
}
2734

35+
/// Handles Home and End key combinations.
36+
/// - Returns: `true` if the event was handled.
37+
private func handleHomeEndKey(_ event: NSEvent) -> Bool {
38+
let keyCode = Int(event.keyCode)
39+
guard keyCode == kVK_Home || keyCode == kVK_End else { return false }
40+
41+
let shift = event.modifierFlags.contains(.shift)
42+
let cmd = event.modifierFlags.contains(.command)
43+
44+
if keyCode == kVK_Home {
45+
switch (cmd, shift) {
46+
case (true, true): moveToBeginningOfDocumentAndModifySelection(self)
47+
case (true, false): moveToBeginningOfDocument(self)
48+
case (false, true): moveToLeftEndOfLineAndModifySelection(self)
49+
case (false, false): moveToLeftEndOfLine(self)
50+
}
51+
} else {
52+
switch (cmd, shift) {
53+
case (true, true): moveToEndOfDocumentAndModifySelection(self)
54+
case (true, false): moveToEndOfDocument(self)
55+
case (false, true): moveToRightEndOfLineAndModifySelection(self)
56+
case (false, false): moveToRightEndOfLine(self)
57+
}
58+
}
59+
return true
60+
}
61+
2862
override public func performKeyEquivalent(with event: NSEvent) -> Bool {
2963
guard isEditable else {
3064
return super.performKeyEquivalent(with: event)

LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+Move.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,32 @@ extension TextView {
159159
updateAfterMove()
160160
}
161161

162+
override public func moveToBeginningOfLine(_ sender: Any?) {
163+
moveToLeftEndOfLine(sender)
164+
}
165+
166+
override public func moveToEndOfLine(_ sender: Any?) {
167+
moveToRightEndOfLine(sender)
168+
}
169+
170+
override public func moveToBeginningOfLineAndModifySelection(_ sender: Any?) {
171+
moveToLeftEndOfLineAndModifySelection(sender)
172+
}
173+
174+
override public func moveToEndOfLineAndModifySelection(_ sender: Any?) {
175+
moveToRightEndOfLineAndModifySelection(sender)
176+
}
177+
178+
override public func centerSelectionInVisibleArea(_ sender: Any?) {
179+
guard let scrollView,
180+
let selection = selectionManager.textSelections.first,
181+
let rect = layoutManager.rectForOffset(selection.range.location) else { return }
182+
let visibleHeight = scrollView.contentView.bounds.height
183+
let targetY = max(rect.midY - visibleHeight / 2, 0)
184+
scrollView.contentView.scroll(to: NSPoint(x: scrollView.contentView.bounds.origin.x, y: targetY))
185+
scrollView.reflectScrolledClipView(scrollView.contentView)
186+
}
187+
162188
override public func pageUp(_ sender: Any?) {
163189
enclosingScrollView?.pageUp(sender)
164190
}

TablePro/Views/Editor/SQLEditorCoordinator.swift

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -328,11 +328,8 @@ final class SQLEditorCoordinator: TextViewCoordinator {
328328
queue: .main
329329
) { [weak self, weak controller] _ in
330330
guard let self, let controller else { return }
331-
DispatchQueue.main.async { [weak self, weak controller] in
332-
guard let self, let controller else { return }
333-
self.handleVimSettingsChange(controller: controller)
334-
self.vimCursorManager?.updatePosition()
335-
}
331+
self.handleVimSettingsChange(controller: controller)
332+
self.vimCursorManager?.updatePosition()
336333
}
337334
}
338335

docs/features/keyboard-shortcuts.mdx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,13 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut
5252

5353
| Action | Shortcut |
5454
|--------|----------|
55-
| Go to beginning | `Cmd+Up` |
56-
| Go to end | `Cmd+Down` |
55+
| Start of line | `Home` or `Cmd+Left` |
56+
| End of line | `End` or `Cmd+Right` |
57+
| Start of document | `Cmd+Home` or `Cmd+Up` |
58+
| End of document | `Cmd+End` or `Cmd+Down` |
5759
| Move line up | `Option+Up` |
5860
| Move line down | `Option+Down` |
61+
| Center cursor in view | `Ctrl+L` |
5962

6063
### Find and Replace
6164

0 commit comments

Comments
 (0)