From 525f0817e6154cc33917a66cf568a88e1471918c Mon Sep 17 00:00:00 2001 From: Kyaw Monkey Date: Sun, 14 Jun 2026 23:25:43 +0700 Subject: [PATCH 1/5] Feat: added vim motion env keys --- .../Environment/ViewEnvironmentKeys.swift | 38 +++++++++++++++++++ .../Focus/HorizontalNavigationStyle.swift | 33 ++++++++++++++++ .../Focus/VerticalNavigationStyle.swift | 34 +++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 Sources/TUIkit/Focus/HorizontalNavigationStyle.swift create mode 100644 Sources/TUIkit/Focus/VerticalNavigationStyle.swift diff --git a/Sources/TUIkit/Environment/ViewEnvironmentKeys.swift b/Sources/TUIkit/Environment/ViewEnvironmentKeys.swift index d3b15c74..e608cabb 100644 --- a/Sources/TUIkit/Environment/ViewEnvironmentKeys.swift +++ b/Sources/TUIkit/Environment/ViewEnvironmentKeys.swift @@ -60,3 +60,41 @@ extension EnvironmentValues { set { self[SelectionDisabledKey.self] = newValue } } } + +// MARK: - Vertical Navigation Styles Environment Key + +/// Environment key for vertical (up/down) keyboard navigation styles. +private struct VerticalNavigationStylesKey: EnvironmentKey { + static let defaultValue: Set = [.arrowKey] +} + +extension EnvironmentValues { + /// The active vertical navigation styles for scrollable views. + /// + /// Controls which key bindings drive up/down movement in `List`, `Table`, and `Menu`. + /// Set via `.verticalNavigationStyle(_:)` modifier. + /// Default: `[.arrowKey]` (arrow keys only). + var verticalNavigationStyles: Set { + get { self[VerticalNavigationStylesKey.self] } + set { self[VerticalNavigationStylesKey.self] = newValue } + } +} + +// MARK: - Horizontal Navigation Styles Environment Key + +/// Environment key for horizontal (Tab/section) keyboard navigation styles. +private struct HorizontalNavigationStylesKey: EnvironmentKey { + static let defaultValue: Set = [.tab] +} + +extension EnvironmentValues { + /// The active horizontal navigation styles for cycling between focusable views. + /// + /// Controls which key bindings drive Tab-style focus cycling. + /// Set via `.horizontalNavigationStyle(_:)` modifier. + /// Default: `[.tab]` (Tab / Shift+Tab only). + var horizontalNavigationStyles: Set { + get { self[HorizontalNavigationStylesKey.self] } + set { self[HorizontalNavigationStylesKey.self] = newValue } + } +} diff --git a/Sources/TUIkit/Focus/HorizontalNavigationStyle.swift b/Sources/TUIkit/Focus/HorizontalNavigationStyle.swift new file mode 100644 index 00000000..e6e7d70d --- /dev/null +++ b/Sources/TUIkit/Focus/HorizontalNavigationStyle.swift @@ -0,0 +1,33 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// HorizontalNavigationStyle.swift +// +// Created by LAYERED.work +// License: MIT + +// MARK: - Horizontal Navigation Style + +/// The keyboard scheme for horizontal (Tab/section) navigation between focusable views. +/// +/// Pass one or more styles to `.horizontalNavigationStyle(_:)` to control which +/// key bindings cycle focus between interactive elements and sections. Styles +/// combine freely — passing both enables all keys simultaneously. +/// +/// ```swift +/// // Tab only (default) +/// VStack { … } +/// +/// // Vim keys only (h = previous, l = next) +/// VStack { … } +/// .horizontalNavigationStyle(.vim) +/// +/// // Both active together +/// VStack { … } +/// .horizontalNavigationStyle(.tab, .vim) +/// ``` +public enum HorizontalNavigationStyle: Hashable, Sendable { + /// Standard Tab / Shift+Tab navigation: Tab = next, Shift+Tab = previous. + case tab + + /// Vim-style horizontal keys: l = next (Tab), h = previous (Shift+Tab). + case vim +} diff --git a/Sources/TUIkit/Focus/VerticalNavigationStyle.swift b/Sources/TUIkit/Focus/VerticalNavigationStyle.swift new file mode 100644 index 00000000..98620170 --- /dev/null +++ b/Sources/TUIkit/Focus/VerticalNavigationStyle.swift @@ -0,0 +1,34 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// VerticalNavigationStyle.swift +// +// Created by LAYERED.work +// License: MIT + +// MARK: - Vertical Navigation Style + +/// The keyboard scheme for vertical (up/down) navigation within scrollable views. +/// +/// Pass one or more styles to `.verticalNavigationStyle(_:)` to control which +/// key bindings drive up/down movement inside `List`, `Table`, `Menu`, and +/// any other container that responds to up/down keys. Styles combine freely — +/// passing both enables all keys simultaneously. +/// +/// ```swift +/// // Arrow keys only (default) +/// List("Items", selection: $sel) { … } +/// +/// // Vim keys only +/// List("Items", selection: $sel) { … } +/// .verticalNavigationStyle(.vim) +/// +/// // Both active together +/// List("Items", selection: $sel) { … } +/// .verticalNavigationStyle(.vim, .arrowKey) +/// ``` +public enum VerticalNavigationStyle: Hashable, Sendable { + /// Standard arrow-key navigation: ↑ ↓ Home End PageUp PageDown. + case arrowKey + + /// Vim-style motion keys: j k g G Ctrl+d Ctrl+u Ctrl+f Ctrl+b. + case vim +} From 211ad8761c7ffe4072ba2f9ae2878852149cb02d Mon Sep 17 00:00:00 2001 From: Kyaw Monkey Date: Sun, 14 Jun 2026 23:27:37 +0700 Subject: [PATCH 2/5] Feat: added vim motion modifiers --- .../View+HorizontalNavigationStyle.swift | 36 +++++++++++++++ .../View+VerticalNavigationStyle.swift | 36 +++++++++++++++ .../HorizontalNavigationStyleModifier.swift | 44 +++++++++++++++++++ .../VerticalNavigationStyleModifier.swift | 44 +++++++++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 Sources/TUIkit/Extensions/View+HorizontalNavigationStyle.swift create mode 100644 Sources/TUIkit/Extensions/View+VerticalNavigationStyle.swift create mode 100644 Sources/TUIkit/Modifiers/HorizontalNavigationStyleModifier.swift create mode 100644 Sources/TUIkit/Modifiers/VerticalNavigationStyleModifier.swift diff --git a/Sources/TUIkit/Extensions/View+HorizontalNavigationStyle.swift b/Sources/TUIkit/Extensions/View+HorizontalNavigationStyle.swift new file mode 100644 index 00000000..db324c4f --- /dev/null +++ b/Sources/TUIkit/Extensions/View+HorizontalNavigationStyle.swift @@ -0,0 +1,36 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// View+HorizontalNavigationStyle.swift +// +// Created by LAYERED.work +// License: MIT + +// MARK: - Horizontal Navigation Style Modifier + +extension View { + /// Sets the horizontal (Tab/section) keyboard navigation style for + /// focusable views in this subtree. + /// + /// Pass one or more styles to activate them simultaneously. Calling this + /// modifier replaces the inherited horizontal navigation style entirely. + /// + /// | Style | Keys | + /// |-------|------| + /// | `.tab` | Tab (next), Shift+Tab (previous) | + /// | `.vim` | l (next), h (previous) | + /// + /// ```swift + /// // Vim only — Tab inactive + /// VStack { … } + /// .horizontalNavigationStyle(.vim) + /// + /// // Both — Tab and h/l all active + /// VStack { … } + /// .horizontalNavigationStyle(.tab, .vim) + /// ``` + /// + /// - Parameter styles: The horizontal navigation styles to activate. + /// - Returns: A view with the specified horizontal navigation styles applied. + public func horizontalNavigationStyle(_ styles: HorizontalNavigationStyle...) -> some View { + HorizontalNavigationStyleModifier(content: self, styles: Set(styles)) + } +} diff --git a/Sources/TUIkit/Extensions/View+VerticalNavigationStyle.swift b/Sources/TUIkit/Extensions/View+VerticalNavigationStyle.swift new file mode 100644 index 00000000..224fa334 --- /dev/null +++ b/Sources/TUIkit/Extensions/View+VerticalNavigationStyle.swift @@ -0,0 +1,36 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// View+VerticalNavigationStyle.swift +// +// Created by LAYERED.work +// License: MIT + +// MARK: - Vertical Navigation Style Modifier + +extension View { + /// Sets the vertical (up/down) keyboard navigation style for scrollable + /// views in this subtree. + /// + /// Pass one or more styles to activate them simultaneously. Calling this + /// modifier replaces the inherited vertical navigation style entirely. + /// + /// | Style | Keys | + /// |-------|------| + /// | `.arrowKey` | ↑ ↓ Home End PageUp PageDown | + /// | `.vim` | j k g G Ctrl+d Ctrl+u Ctrl+f Ctrl+b | + /// + /// ```swift + /// // Vim only — arrow keys inactive + /// List("Items", selection: $sel) { … } + /// .verticalNavigationStyle(.vim) + /// + /// // Both — all vertical keys active + /// List("Items", selection: $sel) { … } + /// .verticalNavigationStyle(.vim, .arrowKey) + /// ``` + /// + /// - Parameter styles: The vertical navigation styles to activate. + /// - Returns: A view with the specified vertical navigation styles applied. + public func verticalNavigationStyle(_ styles: VerticalNavigationStyle...) -> some View { + VerticalNavigationStyleModifier(content: self, styles: Set(styles)) + } +} diff --git a/Sources/TUIkit/Modifiers/HorizontalNavigationStyleModifier.swift b/Sources/TUIkit/Modifiers/HorizontalNavigationStyleModifier.swift new file mode 100644 index 00000000..71003c7e --- /dev/null +++ b/Sources/TUIkit/Modifiers/HorizontalNavigationStyleModifier.swift @@ -0,0 +1,44 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// HorizontalNavigationStyleModifier.swift +// +// Created by LAYERED.work +// License: MIT + +/// A modifier that sets the horizontal (Tab/section) keyboard navigation style +/// for focusable views in this subtree. +/// +/// Applied via `.horizontalNavigationStyle(_:)` on any view. +public struct HorizontalNavigationStyleModifier: View { + /// The content to wrap. + let content: Content + + /// The active horizontal navigation styles. + let styles: Set + + public var body: Never { + fatalError("HorizontalNavigationStyleModifier renders via Renderable") + } +} + +// MARK: - Equatable + +extension HorizontalNavigationStyleModifier: @preconcurrency Equatable where Content: Equatable { + public static func == ( + lhs: HorizontalNavigationStyleModifier, + rhs: HorizontalNavigationStyleModifier + ) -> Bool { + lhs.content == rhs.content && lhs.styles == rhs.styles + } +} + +// MARK: - Renderable + +extension HorizontalNavigationStyleModifier: Renderable { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + let modifiedEnvironment = context.environment.setting(\.horizontalNavigationStyles, to: styles) + let modifiedContext = context.withEnvironment(modifiedEnvironment) + // Propagate to FocusManager so Tab and vim h/l are gated correctly. + context.environment.focusManager.horizontalNavigationStyles = styles + return TUIkit.renderToBuffer(content, context: modifiedContext) + } +} diff --git a/Sources/TUIkit/Modifiers/VerticalNavigationStyleModifier.swift b/Sources/TUIkit/Modifiers/VerticalNavigationStyleModifier.swift new file mode 100644 index 00000000..fb89b453 --- /dev/null +++ b/Sources/TUIkit/Modifiers/VerticalNavigationStyleModifier.swift @@ -0,0 +1,44 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// VerticalNavigationStyleModifier.swift +// +// Created by LAYERED.work +// License: MIT + +/// A modifier that sets the vertical (up/down) keyboard navigation style +/// for scrollable views in this subtree. +/// +/// Applied via `.verticalNavigationStyle(_:)` on any view. +public struct VerticalNavigationStyleModifier: View { + /// The content to wrap. + let content: Content + + /// The active vertical navigation styles. + let styles: Set + + public var body: Never { + fatalError("VerticalNavigationStyleModifier renders via Renderable") + } +} + +// MARK: - Equatable + +extension VerticalNavigationStyleModifier: @preconcurrency Equatable where Content: Equatable { + public static func == ( + lhs: VerticalNavigationStyleModifier, + rhs: VerticalNavigationStyleModifier + ) -> Bool { + lhs.content == rhs.content && lhs.styles == rhs.styles + } +} + +// MARK: - Renderable + +extension VerticalNavigationStyleModifier: Renderable { + public func renderToBuffer(context: RenderContext) -> FrameBuffer { + let modifiedEnvironment = context.environment.setting(\.verticalNavigationStyles, to: styles) + let modifiedContext = context.withEnvironment(modifiedEnvironment) + // Propagate to FocusManager for section-level fallback navigation (VStack, etc.). + context.environment.focusManager.verticalNavigationStyles = styles + return TUIkit.renderToBuffer(content, context: modifiedContext) + } +} From c5667e5245ca6209798ef962acb439fb39336f04 Mon Sep 17 00:00:00 2001 From: Kyaw Monkey Date: Sun, 14 Jun 2026 23:28:12 +0700 Subject: [PATCH 3/5] Feat: integrate vim motion to views and handlers --- Sources/TUIkit/Focus/Focus.swift | 41 +++++++-- Sources/TUIkit/Focus/ItemListHandler.swift | 97 +++++++++++++++++----- Sources/TUIkit/Views/Menu.swift | 45 ++++++---- Sources/TUIkit/Views/Table.swift | 1 + Sources/TUIkit/Views/_ListCore.swift | 1 + 5 files changed, 140 insertions(+), 45 deletions(-) diff --git a/Sources/TUIkit/Focus/Focus.swift b/Sources/TUIkit/Focus/Focus.swift index 3c3ca993..3c5d9e25 100644 --- a/Sources/TUIkit/Focus/Focus.swift +++ b/Sources/TUIkit/Focus/Focus.swift @@ -60,6 +60,18 @@ public final class FocusManager: @unchecked Sendable { /// Callback triggered when focus changes (element or section). public var onFocusChange: (() -> Void)? + /// The active vertical (up/down) navigation styles for section-level navigation. + /// + /// Reset to `[.arrowKey]` each render pass, then updated by + /// `VerticalNavigationStyleModifier` during rendering. + var verticalNavigationStyles: Set = [.arrowKey] + + /// The active horizontal (Tab/section) navigation styles. + /// + /// Reset to `[.tab]` each render pass, then updated by + /// `HorizontalNavigationStyleModifier` during rendering. + var horizontalNavigationStyles: Set = [.tab] + /// Creates a new focus manager instance. public init() {} @@ -300,8 +312,11 @@ public extension FocusManager { } } - // Tab navigation: cycle sections (or elements within single section) - if event.key == .tab { + // Horizontal navigation: Tab/Shift+Tab and/or vim h/l cycle sections or elements. + let hasTab = horizontalNavigationStyles.contains(.tab) + let hasVimH = horizontalNavigationStyles.contains(.vim) + + if event.key == .tab && hasTab { if event.shift { focusPrevious() } else { @@ -310,15 +325,23 @@ public extension FocusManager { return true } - // Arrow keys: navigate within the active section (fallback if element didn't handle) - // Up/Left go to previous, Down/Right go to next + // Vertical navigation: arrow keys and/or vim j/k navigate within the active section. + let hasArrow = verticalNavigationStyles.contains(.arrowKey) + let hasVimV = verticalNavigationStyles.contains(.vim) + switch event.key { case .up, .left: - focusPreviousInSection() - return true + if hasArrow { focusPreviousInSection(); return true } case .down, .right: - focusNextInSection() - return true + if hasArrow { focusNextInSection(); return true } + case .character("k"): + if hasVimV { focusPreviousInSection(); return true } + case .character("j"): + if hasVimV { focusNextInSection(); return true } + case .character("h"): + if hasVimH { focusPrevious(); return true } + case .character("l"): + if hasVimH { focusNext(); return true } default: break } @@ -407,6 +430,8 @@ extension FocusManager { /// Call this at the start of each render pass instead of ``clear()``. func beginRenderPass() { sections.removeAll() + verticalNavigationStyles = [.arrowKey] + horizontalNavigationStyles = [.tab] // activeSectionID and focusedID are intentionally preserved. // They will be validated after the render pass re-registers sections. } diff --git a/Sources/TUIkit/Focus/ItemListHandler.swift b/Sources/TUIkit/Focus/ItemListHandler.swift index 43176633..c49dea59 100644 --- a/Sources/TUIkit/Focus/ItemListHandler.swift +++ b/Sources/TUIkit/Focus/ItemListHandler.swift @@ -69,6 +69,9 @@ final class ItemListHandler: Focusable { /// Whether this element can currently receive focus. var canBeFocused: Bool + /// The active keyboard navigation styles. + var verticalNavigationStyles: Set = [.arrowKey] + /// The currently focused item index (keyboard cursor). var focusedIndex: Int = 0 @@ -152,45 +155,65 @@ extension ItemListHandler { func handleKeyEvent(_ event: KeyEvent) -> Bool { guard itemCount > 0 else { return false } + let hasArrow = verticalNavigationStyles.contains(.arrowKey) + let hasVim = verticalNavigationStyles.contains(.vim) + switch event.key { - case .up: + + // Arrow key navigation + case .up where hasArrow: moveFocus(by: -1, wrap: true) return true - case .down: + case .down where hasArrow: moveFocus(by: 1, wrap: true) return true - case .home: - if selectableIndices.isEmpty { - focusedIndex = 0 - } else if let firstSelectable = selectableIndices.min() { - focusedIndex = firstSelectable - } else { - return false - } - ensureFocusedItemVisible() + case .home where hasArrow: + return jumpToFirst() + + case .end where hasArrow: + return jumpToLast() + + case .pageUp where hasArrow: + moveFocus(by: -viewportHeight, wrap: false) return true - case .end: - if selectableIndices.isEmpty { - focusedIndex = itemCount - 1 - } else if let lastSelectable = selectableIndices.max() { - focusedIndex = lastSelectable - } else { - return false - } - ensureFocusedItemVisible() + case .pageDown where hasArrow: + moveFocus(by: viewportHeight, wrap: false) return true - case .pageUp: - moveFocus(by: -viewportHeight, wrap: false) + // Vim motions + case .character("j") where hasVim: + moveFocus(by: 1, wrap: true) + return true + + case .character("k") where hasVim: + moveFocus(by: -1, wrap: true) + return true + + case .character("g") where hasVim: + return jumpToFirst() + + case .character("G") where hasVim: + return jumpToLast() + + case .character("d") where event.ctrl && hasVim: + moveFocus(by: max(1, viewportHeight / 2), wrap: false) + return true + + case .character("u") where event.ctrl && hasVim: + moveFocus(by: -max(1, viewportHeight / 2), wrap: false) return true - case .pageDown: + case .character("f") where event.ctrl && hasVim: moveFocus(by: viewportHeight, wrap: false) return true + case .character("b") where event.ctrl && hasVim: + moveFocus(by: -viewportHeight, wrap: false) + return true + case .enter, .space: toggleSelectionAtFocusedIndex() return true @@ -199,6 +222,34 @@ extension ItemListHandler { return false } } + + // MARK: - Jump Helpers + + @discardableResult + private func jumpToFirst() -> Bool { + if selectableIndices.isEmpty { + focusedIndex = 0 + } else if let firstSelectable = selectableIndices.min() { + focusedIndex = firstSelectable + } else { + return false + } + ensureFocusedItemVisible() + return true + } + + @discardableResult + private func jumpToLast() -> Bool { + if selectableIndices.isEmpty { + focusedIndex = itemCount - 1 + } else if let lastSelectable = selectableIndices.max() { + focusedIndex = lastSelectable + } else { + return false + } + ensureFocusedItemVisible() + return true + } } // MARK: - Navigation Helpers diff --git a/Sources/TUIkit/Views/Menu.swift b/Sources/TUIkit/Views/Menu.swift index 43f1886b..4d11a5a6 100644 --- a/Sources/TUIkit/Views/Menu.swift +++ b/Sources/TUIkit/Views/Menu.swift @@ -293,36 +293,53 @@ private struct _MenuCore: View, Renderable { let itemCount = items.count let menuItems = items let selectCallback = onSelect + let navStyles = context.environment.verticalNavigationStyles + let hasArrow = navStyles.contains(.arrowKey) + let hasVim = navStyles.contains(.vim) context.environment.keyEventDispatcher!.addHandler { event in switch event.key { case .up: - // Move selection up + guard hasArrow else { return false } let current = binding.wrappedValue - if current > 0 { - binding.wrappedValue = current - 1 - } else { - binding.wrappedValue = itemCount - 1 // Wrap to bottom - } + binding.wrappedValue = current > 0 ? current - 1 : itemCount - 1 return true case .down: - // Move selection down + guard hasArrow else { return false } let current = binding.wrappedValue - if current < itemCount - 1 { - binding.wrappedValue = current + 1 - } else { - binding.wrappedValue = 0 // Wrap to top - } + binding.wrappedValue = current < itemCount - 1 ? current + 1 : 0 + return true + + case .character("k"): + guard hasVim else { return false } + let current = binding.wrappedValue + binding.wrappedValue = current > 0 ? current - 1 : itemCount - 1 + return true + + case .character("j"): + guard hasVim else { return false } + let current = binding.wrappedValue + binding.wrappedValue = current < itemCount - 1 ? current + 1 : 0 + return true + + case .character("g"): + guard hasVim else { return false } + binding.wrappedValue = 0 + return true + + case .character("G"): + guard hasVim else { return false } + binding.wrappedValue = itemCount - 1 return true case .enter: - // Select current item selectCallback?(binding.wrappedValue) return true case .character(let character): - // Check for shortcut + // Shortcut jump — vim j/k/g/G are already handled above so they + // won't reach here when vim mode is active. for (index, item) in menuItems.enumerated() { if let shortcut = item.shortcut, shortcut.lowercased() == character.lowercased() diff --git a/Sources/TUIkit/Views/Table.swift b/Sources/TUIkit/Views/Table.swift index 4c344ab6..3fa58eb1 100644 --- a/Sources/TUIkit/Views/Table.swift +++ b/Sources/TUIkit/Views/Table.swift @@ -240,6 +240,7 @@ private struct _TableCore: View, Renderable wher handler.itemCount = data.count handler.viewportHeight = viewportHeight handler.canBeFocused = !isDisabled + handler.verticalNavigationStyles = context.environment.verticalNavigationStyles handler.itemIDs = data.map { $0.id } // Assign selection bindings directly (type-safe, no AnyHashable conversion) diff --git a/Sources/TUIkit/Views/_ListCore.swift b/Sources/TUIkit/Views/_ListCore.swift index 43b9be0c..4b61d094 100644 --- a/Sources/TUIkit/Views/_ListCore.swift +++ b/Sources/TUIkit/Views/_ListCore.swift @@ -69,6 +69,7 @@ struct _ListCore() From 8a494ba72a49fcb57e62d9d95e6e72372c104205 Mon Sep 17 00:00:00 2001 From: Kyaw Monkey Date: Sun, 14 Jun 2026 23:28:42 +0700 Subject: [PATCH 4/5] Chore: update example app to demonstrate vim motion --- Sources/TUIkitExample/main.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/TUIkitExample/main.swift b/Sources/TUIkitExample/main.swift index f9ebd0a2..a9274543 100644 --- a/Sources/TUIkitExample/main.swift +++ b/Sources/TUIkitExample/main.swift @@ -16,6 +16,8 @@ struct ExampleApp: App { var body: some Scene { WindowGroup { ContentView() + .horizontalNavigationStyle(.tab, .vim) + .verticalNavigationStyle(.arrowKey, .vim) } } } From cf201dbb3cbd40f1a341420f57868705a572b524 Mon Sep 17 00:00:00 2001 From: Kyaw Monkey Date: Sun, 14 Jun 2026 23:36:50 +0700 Subject: [PATCH 5/5] Chore: added UTs to cover vim motion --- Tests/TUIkitTests/FocusManagerTests.swift | 266 +++++++++++++++++ Tests/TUIkitTests/ItemListHandlerTests.swift | 295 +++++++++++++++++++ 2 files changed, 561 insertions(+) diff --git a/Tests/TUIkitTests/FocusManagerTests.swift b/Tests/TUIkitTests/FocusManagerTests.swift index e1df1f73..8669d17b 100644 --- a/Tests/TUIkitTests/FocusManagerTests.swift +++ b/Tests/TUIkitTests/FocusManagerTests.swift @@ -336,3 +336,269 @@ struct FocusManagerEnvironmentTests { #expect(manager2.currentFocusedID == "test-2") } } + +// MARK: - Focus Manager Vertical Vim Navigation Tests + +@MainActor +@Suite("FocusManager Vertical Vim Navigation Tests") +struct FocusManagerVerticalVimTests { + + @Test("Default style — j/k are not consumed") + func defaultStyleIgnoresJK() { + let manager = FocusManager() + let e1 = MockFocusable(id: "a") + let e2 = MockFocusable(id: "b") + manager.register(e1) + manager.register(e2) + // verticalNavigationStyles defaults to [.arrowKey] + + let jHandled = manager.dispatchKeyEvent(KeyEvent(key: .character("j"))) + let kHandled = manager.dispatchKeyEvent(KeyEvent(key: .character("k"))) + + #expect(jHandled == false) + #expect(kHandled == false) + #expect(manager.isFocused(e1)) // Focus unchanged + } + + @Test("Vertical vim — j moves focus to next element in section") + func jMovesToNextInSection() { + let manager = FocusManager() + manager.verticalNavigationStyles = [.vim] + + let e1 = MockFocusable(id: "a") + let e2 = MockFocusable(id: "b") + manager.register(e1) + manager.register(e2) + + let handled = manager.dispatchKeyEvent(KeyEvent(key: .character("j"))) + + #expect(handled == true) + #expect(manager.isFocused(e2)) + } + + @Test("Vertical vim — k moves focus to previous element in section") + func kMovesToPreviousInSection() { + let manager = FocusManager() + manager.verticalNavigationStyles = [.vim] + + let e1 = MockFocusable(id: "a") + let e2 = MockFocusable(id: "b") + manager.register(e1) + manager.register(e2) + manager.focus(e2) + + let handled = manager.dispatchKeyEvent(KeyEvent(key: .character("k"))) + + #expect(handled == true) + #expect(manager.isFocused(e1)) + } + + @Test("Vertical vim only — arrow keys are not consumed as section navigation") + func vimOnlyArrowKeysNotConsumedByFallback() { + let manager = FocusManager() + manager.verticalNavigationStyles = [.vim] + + let e1 = MockFocusable(id: "a", shouldConsumeEvents: false) + let e2 = MockFocusable(id: "b") + manager.register(e1) + manager.register(e2) + + // .down should not be handled by the fallback when arrowKey style is absent + let downHandled = manager.dispatchKeyEvent(KeyEvent(key: .down)) + + #expect(downHandled == false) + #expect(manager.isFocused(e1)) // Focus not changed + } + + @Test("Both vertical styles — j and down arrow both navigate") + func bothVerticalStylesWork() { + let manager = FocusManager() + manager.verticalNavigationStyles = [.arrowKey, .vim] + + let e1 = MockFocusable(id: "a") + let e2 = MockFocusable(id: "b") + let e3 = MockFocusable(id: "c") + manager.register(e1) + manager.register(e2) + manager.register(e3) + + let downHandled = manager.dispatchKeyEvent(KeyEvent(key: .down)) + #expect(downHandled == true) + #expect(manager.isFocused(e2)) + + let jHandled = manager.dispatchKeyEvent(KeyEvent(key: .character("j"))) + #expect(jHandled == true) + #expect(manager.isFocused(e3)) + } + + @Test("beginRenderPass resets vertical styles to arrowKey default") + func beginRenderPassResetsVerticalStyles() { + let manager = FocusManager() + manager.verticalNavigationStyles = [.vim] + + manager.beginRenderPass() + + // After reset, vim keys should not be consumed + let e1 = MockFocusable(id: "a") + let e2 = MockFocusable(id: "b") + manager.register(e1) + manager.register(e2) + + let jHandled = manager.dispatchKeyEvent(KeyEvent(key: .character("j"))) + #expect(jHandled == false) + + // Arrow keys should work again + let downHandled = manager.dispatchKeyEvent(KeyEvent(key: .down)) + #expect(downHandled == true) + } +} + +// MARK: - Focus Manager Horizontal Vim Navigation Tests + +@MainActor +@Suite("FocusManager Horizontal Vim Navigation Tests") +struct FocusManagerHorizontalVimTests { + + @Test("Default style — h/l are not consumed") + func defaultStyleIgnoresHL() { + let manager = FocusManager() + let e1 = MockFocusable(id: "a") + let e2 = MockFocusable(id: "b") + manager.register(e1) + manager.register(e2) + // horizontalNavigationStyles defaults to [.tab] + + let lHandled = manager.dispatchKeyEvent(KeyEvent(key: .character("l"))) + let hHandled = manager.dispatchKeyEvent(KeyEvent(key: .character("h"))) + + #expect(lHandled == false) + #expect(hHandled == false) + #expect(manager.isFocused(e1)) // Focus unchanged + } + + @Test("Horizontal vim — l cycles to next focusable") + func lCyclesToNext() { + let manager = FocusManager() + manager.horizontalNavigationStyles = [.vim] + + let e1 = MockFocusable(id: "a") + let e2 = MockFocusable(id: "b") + manager.register(e1) + manager.register(e2) + + let handled = manager.dispatchKeyEvent(KeyEvent(key: .character("l"))) + + #expect(handled == true) + #expect(manager.isFocused(e2)) + } + + @Test("Horizontal vim — h cycles to previous focusable") + func hCyclesToPrevious() { + let manager = FocusManager() + manager.horizontalNavigationStyles = [.vim] + + let e1 = MockFocusable(id: "a") + let e2 = MockFocusable(id: "b") + manager.register(e1) + manager.register(e2) + manager.focus(e2) + + let handled = manager.dispatchKeyEvent(KeyEvent(key: .character("h"))) + + #expect(handled == true) + #expect(manager.isFocused(e1)) + } + + @Test("Horizontal vim only — Tab is not consumed") + func vimOnlyTabNotConsumed() { + let manager = FocusManager() + manager.horizontalNavigationStyles = [.vim] + + let e1 = MockFocusable(id: "a", shouldConsumeEvents: false) + let e2 = MockFocusable(id: "b") + manager.register(e1) + manager.register(e2) + + let tabHandled = manager.dispatchKeyEvent(KeyEvent(key: .tab)) + + #expect(tabHandled == false) + #expect(manager.isFocused(e1)) // Focus not changed by Tab + } + + @Test("Tab only — l/h are not consumed") + func tabOnlyIgnoresHL() { + let manager = FocusManager() + // horizontalNavigationStyles defaults to [.tab] + + let e1 = MockFocusable(id: "a") + let e2 = MockFocusable(id: "b") + manager.register(e1) + manager.register(e2) + + let lHandled = manager.dispatchKeyEvent(KeyEvent(key: .character("l"))) + #expect(lHandled == false) + #expect(manager.isFocused(e1)) + } + + @Test("Tab only — Tab still cycles focus") + func tabOnlyTabWorks() { + let manager = FocusManager() + // horizontalNavigationStyles defaults to [.tab] + + let e1 = MockFocusable(id: "a") + let e2 = MockFocusable(id: "b") + manager.register(e1) + manager.register(e2) + + let handled = manager.dispatchKeyEvent(KeyEvent(key: .tab)) + + #expect(handled == true) + #expect(manager.isFocused(e2)) + } + + @Test("Both horizontal styles — Tab and h/l all cycle focus") + func bothHorizontalStylesWork() { + let manager = FocusManager() + manager.horizontalNavigationStyles = [.tab, .vim] + + let e1 = MockFocusable(id: "a") + let e2 = MockFocusable(id: "b") + let e3 = MockFocusable(id: "c") + manager.register(e1) + manager.register(e2) + manager.register(e3) + + let tabHandled = manager.dispatchKeyEvent(KeyEvent(key: .tab)) + #expect(tabHandled == true) + #expect(manager.isFocused(e2)) + + let lHandled = manager.dispatchKeyEvent(KeyEvent(key: .character("l"))) + #expect(lHandled == true) + #expect(manager.isFocused(e3)) + + let hHandled = manager.dispatchKeyEvent(KeyEvent(key: .character("h"))) + #expect(hHandled == true) + #expect(manager.isFocused(e2)) + } + + @Test("beginRenderPass resets horizontal styles to tab default") + func beginRenderPassResetsHorizontalStyles() { + let manager = FocusManager() + manager.horizontalNavigationStyles = [.vim] + + manager.beginRenderPass() + + // After reset, vim h/l should not be consumed + let e1 = MockFocusable(id: "a") + let e2 = MockFocusable(id: "b") + manager.register(e1) + manager.register(e2) + + let lHandled = manager.dispatchKeyEvent(KeyEvent(key: .character("l"))) + #expect(lHandled == false) + + // Tab should work again + let tabHandled = manager.dispatchKeyEvent(KeyEvent(key: .tab)) + #expect(tabHandled == true) + } +} diff --git a/Tests/TUIkitTests/ItemListHandlerTests.swift b/Tests/TUIkitTests/ItemListHandlerTests.swift index 444737ea..65b6b21d 100644 --- a/Tests/TUIkitTests/ItemListHandlerTests.swift +++ b/Tests/TUIkitTests/ItemListHandlerTests.swift @@ -447,3 +447,298 @@ struct ItemListHandlerScrollTests { #expect(range == 0..<5) } } + +// MARK: - Item List Handler Vim Motion Tests + +@MainActor +@Suite("ItemListHandler Vim Motion Tests") +struct ItemListHandlerVimMotionTests { + + // Helper that creates a handler with vim vertical navigation active. + func makeVimHandler(itemCount: Int, viewportHeight: Int) -> ItemListHandler { + let handler = ItemListHandler( + focusID: "test", + itemCount: itemCount, + viewportHeight: viewportHeight, + selectionMode: .single + ) + handler.verticalNavigationStyles = [.vim] + return handler + } + + // MARK: j / k + + @Test("j moves focus down") + func jMovesDown() { + let handler = makeVimHandler(itemCount: 5, viewportHeight: 5) + + let handled = handler.handleKeyEvent(KeyEvent(key: .character("j"))) + + #expect(handled == true) + #expect(handler.focusedIndex == 1) + } + + @Test("j wraps to start when at last item") + func jWrapsToStart() { + let handler = makeVimHandler(itemCount: 3, viewportHeight: 3) + handler.focusedIndex = 2 + + _ = handler.handleKeyEvent(KeyEvent(key: .character("j"))) + + #expect(handler.focusedIndex == 0) + } + + @Test("k moves focus up") + func kMovesUp() { + let handler = makeVimHandler(itemCount: 5, viewportHeight: 5) + handler.focusedIndex = 3 + + let handled = handler.handleKeyEvent(KeyEvent(key: .character("k"))) + + #expect(handled == true) + #expect(handler.focusedIndex == 2) + } + + @Test("k wraps to end when at first item") + func kWrapsToEnd() { + let handler = makeVimHandler(itemCount: 3, viewportHeight: 3) + handler.focusedIndex = 0 + + _ = handler.handleKeyEvent(KeyEvent(key: .character("k"))) + + #expect(handler.focusedIndex == 2) + } + + // MARK: g / G + + @Test("g jumps to first item") + func gJumpsToFirst() { + let handler = makeVimHandler(itemCount: 10, viewportHeight: 5) + handler.focusedIndex = 7 + + let handled = handler.handleKeyEvent(KeyEvent(key: .character("g"))) + + #expect(handled == true) + #expect(handler.focusedIndex == 0) + } + + @Test("G jumps to last item") + func bigGJumpsToLast() { + let handler = makeVimHandler(itemCount: 10, viewportHeight: 5) + handler.focusedIndex = 2 + + let handled = handler.handleKeyEvent(KeyEvent(key: .character("G"))) + + #expect(handled == true) + #expect(handler.focusedIndex == 9) + } + + @Test("g respects selectableIndices — jumps to first selectable") + func gRespectsSelectableIndices() { + let handler = makeVimHandler(itemCount: 5, viewportHeight: 5) + handler.selectableIndices = [1, 2, 3, 4] // index 0 is a section header + handler.focusedIndex = 3 + + _ = handler.handleKeyEvent(KeyEvent(key: .character("g"))) + + #expect(handler.focusedIndex == 1) + } + + @Test("G respects selectableIndices — jumps to last selectable") + func bigGRespectsSelectableIndices() { + let handler = makeVimHandler(itemCount: 5, viewportHeight: 5) + handler.selectableIndices = [0, 1, 2, 3] // index 4 is a section footer + handler.focusedIndex = 1 + + _ = handler.handleKeyEvent(KeyEvent(key: .character("G"))) + + #expect(handler.focusedIndex == 3) + } + + // MARK: Ctrl+d / Ctrl+u (half page) + + @Test("Ctrl+d moves half a viewport down") + func ctrlDHalfPageDown() { + let handler = makeVimHandler(itemCount: 20, viewportHeight: 6) + handler.focusedIndex = 2 + + let handled = handler.handleKeyEvent(KeyEvent(key: .character("d"), ctrl: true)) + + #expect(handled == true) + #expect(handler.focusedIndex == 5) // 2 + max(1, 6/2) = 2 + 3 + } + + @Test("Ctrl+u moves half a viewport up") + func ctrlUHalfPageUp() { + let handler = makeVimHandler(itemCount: 20, viewportHeight: 6) + handler.focusedIndex = 8 + + let handled = handler.handleKeyEvent(KeyEvent(key: .character("u"), ctrl: true)) + + #expect(handled == true) + #expect(handler.focusedIndex == 5) // 8 - max(1, 6/2) = 8 - 3 + } + + @Test("Ctrl+d clamps at last item") + func ctrlDClampsAtEnd() { + let handler = makeVimHandler(itemCount: 10, viewportHeight: 6) + handler.focusedIndex = 8 // 8 + 3 = 11, clamped to 9 + + _ = handler.handleKeyEvent(KeyEvent(key: .character("d"), ctrl: true)) + + #expect(handler.focusedIndex == 9) + } + + @Test("Ctrl+u clamps at first item") + func ctrlUClampsAtStart() { + let handler = makeVimHandler(itemCount: 10, viewportHeight: 6) + handler.focusedIndex = 1 // 1 - 3 = -2, clamped to 0 + + _ = handler.handleKeyEvent(KeyEvent(key: .character("u"), ctrl: true)) + + #expect(handler.focusedIndex == 0) + } + + @Test("Ctrl+d moves at least 1 step when viewport is 1") + func ctrlDMinimumStep() { + let handler = makeVimHandler(itemCount: 10, viewportHeight: 1) + handler.focusedIndex = 0 + + _ = handler.handleKeyEvent(KeyEvent(key: .character("d"), ctrl: true)) + + #expect(handler.focusedIndex == 1) // max(1, 1/2) = 1 + } + + // MARK: Ctrl+f / Ctrl+b (full page) + + @Test("Ctrl+f moves a full viewport down") + func ctrlFFullPageDown() { + let handler = makeVimHandler(itemCount: 20, viewportHeight: 5) + handler.focusedIndex = 2 + + let handled = handler.handleKeyEvent(KeyEvent(key: .character("f"), ctrl: true)) + + #expect(handled == true) + #expect(handler.focusedIndex == 7) // 2 + 5 + } + + @Test("Ctrl+b moves a full viewport up") + func ctrlBFullPageUp() { + let handler = makeVimHandler(itemCount: 20, viewportHeight: 5) + handler.focusedIndex = 10 + + let handled = handler.handleKeyEvent(KeyEvent(key: .character("b"), ctrl: true)) + + #expect(handled == true) + #expect(handler.focusedIndex == 5) // 10 - 5 + } +} + +// MARK: - Item List Handler Navigation Style Gating Tests + +@MainActor +@Suite("ItemListHandler Navigation Style Gating Tests") +struct ItemListHandlerNavigationStyleGatingTests { + + @Test("Default style is arrowKey — j/k are ignored") + func defaultStyleIgnoresVimKeys() { + let handler = ItemListHandler( + focusID: "test", itemCount: 5, viewportHeight: 5, selectionMode: .single + ) + // verticalNavigationStyles defaults to [.arrowKey] + + let jHandled = handler.handleKeyEvent(KeyEvent(key: .character("j"))) + let kHandled = handler.handleKeyEvent(KeyEvent(key: .character("k"))) + + #expect(jHandled == false) + #expect(kHandled == false) + #expect(handler.focusedIndex == 0) // Unchanged + } + + @Test("Default style is arrowKey — arrow keys still work") + func defaultStyleArrowKeysWork() { + let handler = ItemListHandler( + focusID: "test", itemCount: 5, viewportHeight: 5, selectionMode: .single + ) + + let handled = handler.handleKeyEvent(KeyEvent(key: .down)) + + #expect(handled == true) + #expect(handler.focusedIndex == 1) + } + + @Test("Vim-only style — j moves focus, arrow keys are ignored") + func vimOnlyStyleArrowKeysIgnored() { + let handler = ItemListHandler( + focusID: "test", itemCount: 5, viewportHeight: 5, selectionMode: .single + ) + handler.verticalNavigationStyles = [.vim] + + let downHandled = handler.handleKeyEvent(KeyEvent(key: .down)) + let homeHandled = handler.handleKeyEvent(KeyEvent(key: .home)) + let pageDownHandled = handler.handleKeyEvent(KeyEvent(key: .pageDown)) + + #expect(downHandled == false) + #expect(homeHandled == false) + #expect(pageDownHandled == false) + #expect(handler.focusedIndex == 0) // Unchanged by arrow keys + } + + @Test("Vim-only style — j/k/g/G all work") + func vimOnlyStyleVimKeysWork() { + let handler = ItemListHandler( + focusID: "test", itemCount: 5, viewportHeight: 5, selectionMode: .single + ) + handler.verticalNavigationStyles = [.vim] + + let jHandled = handler.handleKeyEvent(KeyEvent(key: .character("j"))) + #expect(jHandled == true) + #expect(handler.focusedIndex == 1) + + let bigGHandled = handler.handleKeyEvent(KeyEvent(key: .character("G"))) + #expect(bigGHandled == true) + #expect(handler.focusedIndex == 4) + + let gHandled = handler.handleKeyEvent(KeyEvent(key: .character("g"))) + #expect(gHandled == true) + #expect(handler.focusedIndex == 0) + } + + @Test("Both styles — arrow keys and vim keys all work") + func bothStylesAllKeysWork() { + let handler = ItemListHandler( + focusID: "test", itemCount: 10, viewportHeight: 5, selectionMode: .single + ) + handler.verticalNavigationStyles = [.arrowKey, .vim] + + let downHandled = handler.handleKeyEvent(KeyEvent(key: .down)) + #expect(downHandled == true) + #expect(handler.focusedIndex == 1) + + let jHandled = handler.handleKeyEvent(KeyEvent(key: .character("j"))) + #expect(jHandled == true) + #expect(handler.focusedIndex == 2) + + let homeHandled = handler.handleKeyEvent(KeyEvent(key: .home)) + #expect(homeHandled == true) + #expect(handler.focusedIndex == 0) + + let gHandled = handler.handleKeyEvent(KeyEvent(key: .character("G"))) + #expect(gHandled == true) + #expect(handler.focusedIndex == 9) + } + + @Test("Empty style set — all navigation keys ignored") + func emptyStyleIgnoresAll() { + let handler = ItemListHandler( + focusID: "test", itemCount: 5, viewportHeight: 5, selectionMode: .single + ) + handler.verticalNavigationStyles = [] + + #expect(handler.handleKeyEvent(KeyEvent(key: .down)) == false) + #expect(handler.handleKeyEvent(KeyEvent(key: .character("j"))) == false) + #expect(handler.handleKeyEvent(KeyEvent(key: .home)) == false) + #expect(handler.handleKeyEvent(KeyEvent(key: .character("g"))) == false) + #expect(handler.focusedIndex == 0) + } +}