Skip to content

Commit b1311db

Browse files
authored
feat: add Ctrl+HJKL navigation for keyboards without arrow keys (#391)
* feat: add Ctrl+HJKL navigation for keyboards without arrow keys (#388) * fix: keep selection on group collapse so Ctrl+L can re-expand
1 parent 7751d37 commit b1311db

9 files changed

Lines changed: 171 additions & 1 deletion

File tree

CHANGELOG.md

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

1212
- Reusable SSH tunnel profiles: save SSH configurations once and select them across multiple connections
13+
- Ctrl+HJKL navigation as arrow key alternative for keyboards without dedicated arrow keys
1314

1415
### Fixed
1516

TablePro/Views/Connection/OnboardingContentView.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ struct OnboardingContentView: View {
6464
goToPage(currentPage + 1)
6565
return .handled
6666
}
67+
.onKeyPress(characters: .init(charactersIn: "h"), phases: .down) { keyPress in
68+
guard keyPress.modifiers.contains(.control), currentPage > 0 else { return .ignored }
69+
goToPage(currentPage - 1)
70+
return .handled
71+
}
72+
.onKeyPress(characters: .init(charactersIn: "l"), phases: .down) { keyPress in
73+
guard keyPress.modifiers.contains(.control), currentPage < 2 else { return .ignored }
74+
goToPage(currentPage + 1)
75+
return .handled
76+
}
6777
}
6878

6979
private func goToPage(_ page: Int) {

TablePro/Views/Connection/WelcomeWindowView.swift

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ struct WelcomeWindowView: View {
7272
filteredConnections.filter { $0.groupId == group.id }
7373
}
7474

75+
private var flatVisibleConnections: [DatabaseConnection] {
76+
var result = ungroupedConnections
77+
for group in activeGroups where !collapsedGroupIds.contains(group.id) {
78+
result.append(contentsOf: connections(in: group))
79+
}
80+
return result
81+
}
82+
7583
var body: some View {
7684
ZStack {
7785
if showOnboarding {
@@ -318,6 +326,26 @@ struct WelcomeWindowView: View {
318326
}
319327
return .handled
320328
}
329+
.onKeyPress(characters: .init(charactersIn: "j"), phases: .down) { keyPress in
330+
guard keyPress.modifiers.contains(.control) else { return .ignored }
331+
moveToNextConnection()
332+
return .handled
333+
}
334+
.onKeyPress(characters: .init(charactersIn: "k"), phases: .down) { keyPress in
335+
guard keyPress.modifiers.contains(.control) else { return .ignored }
336+
moveToPreviousConnection()
337+
return .handled
338+
}
339+
.onKeyPress(characters: .init(charactersIn: "h"), phases: .down) { keyPress in
340+
guard keyPress.modifiers.contains(.control) else { return .ignored }
341+
collapseSelectedGroup()
342+
return .handled
343+
}
344+
.onKeyPress(characters: .init(charactersIn: "l"), phases: .down) { keyPress in
345+
guard keyPress.modifiers.contains(.control) else { return .ignored }
346+
expandSelectedGroup()
347+
return .handled
348+
}
321349
}
322350

323351
private func connectionRow(for connection: DatabaseConnection) -> some View {
@@ -588,6 +616,60 @@ struct WelcomeWindowView: View {
588616
}
589617
}
590618

619+
private func moveToNextConnection() {
620+
let visible = flatVisibleConnections
621+
guard !visible.isEmpty else { return }
622+
guard let currentId = selectedConnectionId,
623+
let index = visible.firstIndex(where: { $0.id == currentId }) else {
624+
selectedConnectionId = visible.first?.id
625+
return
626+
}
627+
let next = min(index + 1, visible.count - 1)
628+
selectedConnectionId = visible[next].id
629+
}
630+
631+
private func moveToPreviousConnection() {
632+
let visible = flatVisibleConnections
633+
guard !visible.isEmpty else { return }
634+
guard let currentId = selectedConnectionId,
635+
let index = visible.firstIndex(where: { $0.id == currentId }) else {
636+
selectedConnectionId = visible.last?.id
637+
return
638+
}
639+
let prev = max(index - 1, 0)
640+
selectedConnectionId = visible[prev].id
641+
}
642+
643+
private func collapseSelectedGroup() {
644+
guard let id = selectedConnectionId,
645+
let connection = connections.first(where: { $0.id == id }),
646+
let groupId = connection.groupId,
647+
!collapsedGroupIds.contains(groupId) else { return }
648+
withAnimation(.easeInOut(duration: 0.2)) {
649+
collapsedGroupIds.insert(groupId)
650+
// Keep selectedConnectionId so Ctrl+L can derive the groupId to expand.
651+
// The List won't show a highlight for the hidden row.
652+
UserDefaults.standard.set(
653+
Array(collapsedGroupIds.map(\.uuidString)),
654+
forKey: "com.TablePro.collapsedGroupIds"
655+
)
656+
}
657+
}
658+
659+
private func expandSelectedGroup() {
660+
guard let id = selectedConnectionId,
661+
let connection = connections.first(where: { $0.id == id }),
662+
let groupId = connection.groupId,
663+
collapsedGroupIds.contains(groupId) else { return }
664+
withAnimation(.easeInOut(duration: 0.2)) {
665+
collapsedGroupIds.remove(groupId)
666+
UserDefaults.standard.set(
667+
Array(collapsedGroupIds.map(\.uuidString)),
668+
forKey: "com.TablePro.collapsedGroupIds"
669+
)
670+
}
671+
}
672+
591673
private func moveUngroupedConnections(from source: IndexSet, to destination: Int) {
592674
let ungroupedIndices = connections.indices.filter { connections[$0].groupId == nil }
593675

TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,16 @@ struct DatabaseSwitcherSheet: View {
130130
moveSelection(up: false)
131131
return .handled
132132
}
133+
.onKeyPress(characters: .init(charactersIn: "j"), phases: .down) { keyPress in
134+
guard keyPress.modifiers.contains(.control) else { return .ignored }
135+
moveSelection(up: false)
136+
return .handled
137+
}
138+
.onKeyPress(characters: .init(charactersIn: "k"), phases: .down) { keyPress in
139+
guard keyPress.modifiers.contains(.control) else { return .ignored }
140+
moveSelection(up: true)
141+
return .handled
142+
}
133143
}
134144

135145
// MARK: - Toolbar

TablePro/Views/QuickSwitcher/QuickSwitcherView.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@ internal struct QuickSwitcherSheet: View {
7272
viewModel.moveDown()
7373
return .handled
7474
}
75+
.onKeyPress(characters: .init(charactersIn: "j"), phases: .down) { keyPress in
76+
guard keyPress.modifiers.contains(.control) else { return .ignored }
77+
viewModel.moveDown()
78+
return .handled
79+
}
80+
.onKeyPress(characters: .init(charactersIn: "k"), phases: .down) { keyPress in
81+
guard keyPress.modifiers.contains(.control) else { return .ignored }
82+
viewModel.moveUp()
83+
return .handled
84+
}
7585
}
7686

7787
// MARK: - Search Toolbar

TablePro/Views/Results/KeyHandlingTableView.swift

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,28 @@ final class KeyHandlingTableView: NSTableView {
199199

200200
// Handle arrow keys (custom Shift+selection logic)
201201
let row = selectedRow
202-
let isShiftHeld = event.modifierFlags.contains(.shift)
202+
let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
203+
let isShiftHeld = modifiers.contains(.shift)
204+
205+
// Ctrl+HJKL navigation (arrow key alternatives for keyboards without dedicated arrows)
206+
if modifiers.contains(.control) {
207+
switch key {
208+
case .h:
209+
handleLeftArrow(currentRow: row)
210+
return
211+
case .j:
212+
handleDownArrow(currentRow: row, isShiftHeld: isShiftHeld)
213+
return
214+
case .k:
215+
handleUpArrow(currentRow: row, isShiftHeld: isShiftHeld)
216+
return
217+
case .l:
218+
handleRightArrow(currentRow: row)
219+
return
220+
default:
221+
break
222+
}
223+
}
203224

204225
switch key {
205226
case .upArrow:

TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,16 @@ struct ConnectionSwitcherPopover: View {
192192
case KeyCode.escape.rawValue:
193193
onDismiss?()
194194
return nil
195+
case KeyCode.j.rawValue where event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.control):
196+
if selectedIndex < items.count - 1 {
197+
selectedIndex += 1
198+
}
199+
return nil
200+
case KeyCode.k.rawValue where event.modifierFlags.contains(.control):
201+
if selectedIndex > 0 {
202+
selectedIndex -= 1
203+
}
204+
return nil
195205
default:
196206
return event
197207
}

docs/features/keyboard-shortcuts.mdx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,19 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut
177177
| Explain with AI | `Cmd+L` | Send selected SQL (or current query) to AI for explanation |
178178
| Optimize with AI | `Cmd+Option+L` | Send selected SQL (or current query) to AI for optimization |
179179

180+
## Alternative navigation (Ctrl+HJKL)
181+
182+
For keyboards without dedicated arrow keys (e.g., HHKB), Ctrl+HJKL works as arrow key alternatives throughout the app. These work alongside arrow keys, not as replacements.
183+
184+
| Shortcut | Action | Where |
185+
|----------|--------|-------|
186+
| `Ctrl+J` | Move down / Next item | Data grid, connection list, quick switcher, database switcher |
187+
| `Ctrl+K` | Move up / Previous item | Data grid, connection list, quick switcher, database switcher |
188+
| `Ctrl+H` | Move left / Collapse group | Data grid (column left), welcome panel (collapse group), onboarding (previous page) |
189+
| `Ctrl+L` | Move right / Expand group | Data grid (column right), welcome panel (expand group), onboarding (next page) |
190+
| `Ctrl+Shift+J` | Extend selection down | Data grid |
191+
| `Ctrl+Shift+K` | Extend selection up | Data grid |
192+
180193
## Vim Mode Keybindings
181194

182195
When Vim mode is enabled (**Settings** > **Editor** > **Editing** > **Vim mode**), the SQL editor uses modal keybindings. A mode indicator badge appears in the toolbar.

docs/vi/features/keyboard-shortcuts.mdx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,19 @@ TablePro thiên về bàn phím. Hầu hết thao tác đều có phím tắt, v
177177
| Giải thích với AI | `Cmd+L` | Gửi SQL đã chọn (hoặc query hiện tại) cho AI giải thích |
178178
| Tối ưu với AI | `Cmd+Option+L` | Gửi SQL đã chọn (hoặc query hiện tại) cho AI tối ưu |
179179

180+
## Điều hướng thay thế (Ctrl+HJKL)
181+
182+
Dành cho bàn phím không có phím mũi tên riêng (ví dụ: HHKB), Ctrl+HJKL hoạt động như phím mũi tên thay thế trong toàn bộ ứng dụng. Các phím này hoạt động song song với phím mũi tên.
183+
184+
| Phím tắt | Hành động | Nơi áp dụng |
185+
|----------|-----------|-------------|
186+
| `Ctrl+J` | Di chuyển xuống / Mục tiếp theo | Bảng dữ liệu, danh sách kết nối, quick switcher, database switcher |
187+
| `Ctrl+K` | Di chuyển lên / Mục trước | Bảng dữ liệu, danh sách kết nối, quick switcher, database switcher |
188+
| `Ctrl+H` | Di chuyển trái / Thu gọn nhóm | Bảng dữ liệu (cột trái), welcome panel (thu gọn nhóm), onboarding (trang trước) |
189+
| `Ctrl+L` | Di chuyển phải / Mở rộng nhóm | Bảng dữ liệu (cột phải), welcome panel (mở rộng nhóm), onboarding (trang sau) |
190+
| `Ctrl+Shift+J` | Mở rộng vùng chọn xuống | Bảng dữ liệu |
191+
| `Ctrl+Shift+K` | Mở rộng vùng chọn lên | Bảng dữ liệu |
192+
180193
## Phím Tắt Chế Độ Vim
181194

182195
Khi bật chế độ Vim (**Settings** > **Editor** > **Editing** > **Vim mode**), SQL editor dùng phím tắt modal. Badge chỉ báo chế độ hiện trên thanh công cụ.

0 commit comments

Comments
 (0)