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
38 changes: 38 additions & 0 deletions Sources/TUIkit/Environment/ViewEnvironmentKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<VerticalNavigationStyle> = [.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<VerticalNavigationStyle> {
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<HorizontalNavigationStyle> = [.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<HorizontalNavigationStyle> {
get { self[HorizontalNavigationStylesKey.self] }
set { self[HorizontalNavigationStylesKey.self] = newValue }
}
}
36 changes: 36 additions & 0 deletions Sources/TUIkit/Extensions/View+HorizontalNavigationStyle.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
36 changes: 36 additions & 0 deletions Sources/TUIkit/Extensions/View+VerticalNavigationStyle.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
41 changes: 33 additions & 8 deletions Sources/TUIkit/Focus/Focus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<VerticalNavigationStyle> = [.arrowKey]

/// The active horizontal (Tab/section) navigation styles.
///
/// Reset to `[.tab]` each render pass, then updated by
/// `HorizontalNavigationStyleModifier` during rendering.
var horizontalNavigationStyles: Set<HorizontalNavigationStyle> = [.tab]

/// Creates a new focus manager instance.
public init() {}

Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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.
}
Expand Down
33 changes: 33 additions & 0 deletions Sources/TUIkit/Focus/HorizontalNavigationStyle.swift
Original file line number Diff line number Diff line change
@@ -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
}
97 changes: 74 additions & 23 deletions Sources/TUIkit/Focus/ItemListHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ final class ItemListHandler<SelectionValue: Hashable>: Focusable {
/// Whether this element can currently receive focus.
var canBeFocused: Bool

/// The active keyboard navigation styles.
var verticalNavigationStyles: Set<VerticalNavigationStyle> = [.arrowKey]

/// The currently focused item index (keyboard cursor).
var focusedIndex: Int = 0

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
34 changes: 34 additions & 0 deletions Sources/TUIkit/Focus/VerticalNavigationStyle.swift
Original file line number Diff line number Diff line change
@@ -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
}
Loading