Skip to content
Merged
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Custom keyboard shortcuts now work on non-US keyboard layouts, and shifted symbols like Cmd+[ record correctly.
- The Keyboard settings list is grouped by where shortcuts act (Editor, Data Grid, Navigation, Connections), and each changed shortcut has its own reset button.
- Conflict detection now checks live macOS system shortcuts and the editor's built-in commands, and lets the same key serve the editor and the data grid because focus decides which one runs.
- Show Tables and Show Favorites sidebars moved off Control+1 and Control+2, which switch macOS Spaces, to Cmd+Option+1 and Cmd+Option+2.
- Cmd+N now opens a new connection; Manage Connections keeps its File menu item.
- First Page and Last Page now default to Cmd+Option+Up and Cmd+Option+Down.
- Shortcuts can be bound to function keys (F1 through F12), with or without a modifier.

### Fixed

- Custom Copy and Cut shortcuts now take effect in the SQL editor.
- The Delete shortcut in the data grid now follows a custom binding.
- Find Next (Cmd+G) and Find Previous (Cmd+Shift+G) now work in the editor.
- Pagination buttons no longer fire their page shortcut twice.

## [0.48.0] - 2026-06-02

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,12 @@ public extension TextViewController {
_ = textView.resignFirstResponder()
findViewController?.showFindPanel()
}

func findNext() {
findViewController?.viewModel.moveToNextMatch()
}

func findPrevious() {
findViewController?.viewModel.moveToPreviousMatch()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,33 @@ extension TextView {
}

@objc open func cut(_ sender: AnyObject) {
expandEmptySelectionsToCurrentLine()
copy(sender)
deleteBackward(sender)
}

@objc open func delete(_ sender: AnyObject) {
deleteBackward(sender)
}

/// When a selection is empty, a cut removes the whole current line (including
/// its trailing line break), matching Xcode, VS Code, and JetBrains.
private func expandEmptySelectionsToCurrentLine() {
guard !textStorage.string.isEmpty else { return }
let text = textStorage.string as NSString
let ranges = selectionManager.textSelections.map { Self.cutRange(for: $0.range, in: text) }
guard ranges.contains(where: { !$0.isEmpty }) else { return }
selectionManager.setSelectedRanges(ranges)
}

/// The range a cut should remove for a given selection: the selection itself
/// when non-empty, otherwise the line containing the caret.
static func cutRange(for selectionRange: NSRange, in text: NSString) -> NSRange {
guard selectionRange.isEmpty,
selectionRange.location >= 0,
selectionRange.location <= text.length else {
return selectionRange
}
return text.lineRange(for: NSRange(location: selectionRange.location, length: 0))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import AppKit
@testable import CodeEditTextView
import Testing

/// Covers the empty-selection line cut that backs Cmd+X cutting the current
/// line when nothing is selected.
@Suite
struct CutLineTests {
@Test("Empty selection expands to the whole line including the trailing newline")
func emptySelectionExpandsToLine() {
let text = "line1\nline2\nline3" as NSString
let caret = NSRange(location: 8, length: 0)
let range = TextView.cutRange(for: caret, in: text)
#expect(range == text.lineRange(for: caret))
#expect(text.substring(with: range) == "line2\n")
}

@Test("Non-empty selection is returned unchanged")
func nonEmptySelectionUnchanged() {
let text = "line1\nline2" as NSString
let selection = NSRange(location: 0, length: 5)
#expect(TextView.cutRange(for: selection, in: text) == selection)
}

@Test("Multi-line non-empty selection is returned unchanged")
func multiLineSelectionUnchanged() {
let text = "line1\nline2\nline3" as NSString
let selection = NSRange(location: 3, length: 6)
#expect(TextView.cutRange(for: selection, in: text) == selection)
}

@Test("Caret on the first line cuts the line plus its trailing newline")
func firstLineCutsWithNewline() {
let text = "line1\nline2\nline3" as NSString
let range = TextView.cutRange(for: NSRange(location: 2, length: 0), in: text)
#expect(range == NSRange(location: 0, length: 6))
#expect(text.substring(with: range) == "line1\n")
}

@Test("Caret at the start of a middle line cuts that line plus its newline")
func middleLineFromStartCutsWithNewline() {
let text = "line1\nline2\nline3" as NSString
let range = TextView.cutRange(for: NSRange(location: 6, length: 0), in: text)
#expect(range == NSRange(location: 6, length: 6))
#expect(text.substring(with: range) == "line2\n")
}

@Test("Caret on an empty line cuts just the newline")
func emptyLineCutsJustNewline() {
let text = "line1\n\nline3" as NSString
let range = TextView.cutRange(for: NSRange(location: 6, length: 0), in: text)
#expect(text.substring(with: range) == "\n")
}

@Test("Empty text returns an empty range")
func emptyTextReturnsEmptyRange() {
let text = "" as NSString
let range = TextView.cutRange(for: NSRange(location: 0, length: 0), in: text)
#expect(range.length == 0)
}

@Test("Caret on the last line without a trailing newline cuts to the end")
func lastLineWithoutTrailingNewline() {
let text = "a\nb" as NSString
let range = TextView.cutRange(for: NSRange(location: 2, length: 0), in: text)
#expect(text.substring(with: range) == "b")
}

@Test("Out-of-bounds caret is returned unchanged")
func outOfBoundsCaretUnchanged() {
let text = "abc" as NSString
let caret = NSRange(location: 99, length: 0)
#expect(TextView.cutRange(for: caret, in: text) == caret)
}
}
90 changes: 90 additions & 0 deletions TablePro/Core/KeyboardHandling/KeyCode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,20 @@ public enum KeyCode: UInt16 {
case eight = 28
case nine = 25

// MARK: - Symbol Keys (US ANSI positions)

case minus = 27
case equal = 24
case leftBracket = 33
case rightBracket = 30
case semicolon = 41
case quote = 39
case comma = 43
case period = 47
case slash = 44
case backslash = 42
case grave = 50

// MARK: - Function Keys

case f1 = 122
Expand Down Expand Up @@ -172,6 +186,82 @@ public enum KeyCode: UInt16 {
}
}

/// The 1-based function key number (F1 = 1 ... F12 = 12), or nil for non-function keys.
public var functionKeyIndex: Int? {
switch self {
case .f1: return 1
case .f2: return 2
case .f3: return 3
case .f4: return 4
case .f5: return 5
case .f6: return 6
case .f7: return 7
case .f8: return 8
case .f9: return 9
case .f10: return 10
case .f11: return 11
case .f12: return 12
default: return nil
}
}

/// The character this key produces with no modifiers on a US ANSI layout.
/// Used as a fallback when the live keyboard layout cannot be resolved, and
/// to anchor default shortcuts to a semantic character. Returns nil for
/// non-printable keys (arrows, delete, function keys, etc.).
public var usBaseCharacter: Character? {
switch self {
case .a: return "a"
case .b: return "b"
case .c: return "c"
case .d: return "d"
case .e: return "e"
case .f: return "f"
case .g: return "g"
case .h: return "h"
case .i: return "i"
case .j: return "j"
case .k: return "k"
case .l: return "l"
case .m: return "m"
case .n: return "n"
case .o: return "o"
case .p: return "p"
case .q: return "q"
case .r: return "r"
case .s: return "s"
case .t: return "t"
case .u: return "u"
case .v: return "v"
case .w: return "w"
case .x: return "x"
case .y: return "y"
case .z: return "z"
case .zero: return "0"
case .one: return "1"
case .two: return "2"
case .three: return "3"
case .four: return "4"
case .five: return "5"
case .six: return "6"
case .seven: return "7"
case .eight: return "8"
case .nine: return "9"
case .minus: return "-"
case .equal: return "="
case .leftBracket: return "["
case .rightBracket: return "]"
case .semicolon: return ";"
case .quote: return "'"
case .comma: return ","
case .period: return "."
case .slash: return "/"
case .backslash: return "\\"
case .grave: return "`"
default: return nil
}
}

/// Create a KeyCode from an NSEvent
public init?(event: NSEvent) {
self.init(rawValue: event.keyCode)
Expand Down
77 changes: 77 additions & 0 deletions TablePro/Core/KeyboardHandling/KeyboardLayout.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//
// KeyboardLayout.swift
// TablePro
//
// Resolves between hardware key codes and their base (unshifted) characters
// for the active ASCII-capable keyboard layout. Built once and cached.
//
// The cache is a `static let`, so Swift's one-time initializer builds it
// exactly once even under concurrent first access. Every Carbon call here is a
// pure read (`TISCopyCurrentASCIICapableKeyboardLayoutInputSource` copies,
// `UCKeyTranslate` and `LMGetKbdType` only read), so the build is safe off the
// main thread. This matters because Swift Testing exercises it from parallel
// test tasks; pinning it to the main actor would break those.
//

import AppKit
import Carbon.HIToolbox

enum KeyboardLayout {
static func baseCharacter(for keyCode: UInt16) -> Character? {
maps.keyCodeToCharacter[keyCode] ?? KeyCode(rawValue: keyCode)?.usBaseCharacter
}

static func keyCode(for character: Character) -> UInt16? {
let lowered = Character(character.lowercased())
return maps.characterToKeyCode[lowered] ?? usFallback[lowered]
}

private static let maps = buildMaps()

private static let usFallback: [Character: UInt16] = {
var result: [Character: UInt16] = [:]
for raw in UInt16(0)...127 {
guard let character = KeyCode(rawValue: raw)?.usBaseCharacter else { continue }
if result[character] == nil { result[character] = raw }
}
return result
}()

private static func buildMaps() -> (keyCodeToCharacter: [UInt16: Character], characterToKeyCode: [Character: UInt16]) {
guard let source = TISCopyCurrentASCIICapableKeyboardLayoutInputSource()?.takeRetainedValue(),
let layoutPointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else {
return ([:], [:])
}
let layoutData = unsafeBitCast(layoutPointer, to: CFData.self)
guard let bytes = CFDataGetBytePtr(layoutData) else { return ([:], [:]) }
let keyboardLayout = UnsafeRawPointer(bytes).assumingMemoryBound(to: UCKeyboardLayout.self)
let keyboardType = UInt32(LMGetKbdType())

var toCharacter: [UInt16: Character] = [:]
var toKeyCode: [Character: UInt16] = [:]

for keyCode in UInt16(0)...127 {
var deadKeyState: UInt32 = 0
var length = 0
var characters = [UniChar](repeating: 0, count: 4)
let status = UCKeyTranslate(
keyboardLayout,
keyCode,
UInt16(kUCKeyActionDisplay),
0,
keyboardType,
OptionBits(kUCKeyTranslateNoDeadKeysBit),
&deadKeyState,
characters.count,
&length,
&characters
)
guard status == noErr, length == 1 else { continue }
let scalarString = String(utf16CodeUnits: characters, count: length)
guard let character = scalarString.first, !character.isWhitespace, !character.isNewline else { continue }
toCharacter[keyCode] = character
if toKeyCode[character] == nil { toKeyCode[character] = keyCode }
}
return (toCharacter, toKeyCode)
}
}
38 changes: 38 additions & 0 deletions TablePro/Core/KeyboardHandling/ShortcutConflictResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// ShortcutConflictResolver.swift
// TablePro
//
// Resolves what a recorded shortcut would collide with: a macOS system
// shortcut, a built-in editor command, or another customizable action in an
// overlapping context.
//

import Foundation

enum ShortcutConflict: Equatable {
case none
case systemReserved
case reserved(String)
case otherAction(ShortcutAction)
}

@MainActor
enum ShortcutConflictResolver {
static func resolve(_ key: BoundKey, for action: ShortcutAction, in settings: KeyboardSettings) -> ShortcutConflict {
guard !key.isCleared else { return .none }

if SystemHotkeyChecker.shared.isReserved(key) {
return .systemReserved
}

if let name = ShortcutAction.reservedConflict(for: key, context: action.context) {
return .reserved(name)
}

if let other = settings.findConflict(for: key, excluding: action) {
return .otherAction(other)
}

return .none
}
}
Loading
Loading