Skip to content

Commit

Permalink
Add filtering to SingleChoicePrompt
Browse files Browse the repository at this point in the history
  • Loading branch information
finnvoor committed Feb 23, 2025
1 parent a5a6114 commit d2c1fcc
Show file tree
Hide file tree
Showing 7 changed files with 262 additions and 55 deletions.
138 changes: 119 additions & 19 deletions Sources/Noora/Components/SingleChoicePrompt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,104 @@ struct SingleChoicePrompt {
fatalError("'\(question)' can't be prompted in a non-interactive session.")
}
var selectedOption: (T, String)! = options.first
var isFiltered = false
var filter = ""

func getFilteredOptions() -> [(T, String)] {
if isFiltered, !filter.isEmpty {
return options.filter { $0.1.localizedCaseInsensitiveContains(filter) }
}
return options
}

terminal.inRawMode {
renderOptions(selectedOption: selectedOption, options: options)
renderOptions(
selectedOption: selectedOption,
options: options,
isFiltered: isFiltered,
filter: filter
)
keyStrokeListener.listen(terminal: terminal) { keyStroke in
switch keyStroke {
case .returnKey:
case let .printable(character) where character.isNewline:
return .abort
case .kKey, .upArrowKey:
let currentIndex = options.firstIndex(where: { $0 == selectedOption })!
selectedOption = options[(currentIndex - 1 + options.count) % options.count]
renderOptions(selectedOption: selectedOption, options: options)
case let .printable(character) where isFiltered:
filter.append(character)
let filteredOptions = getFilteredOptions()
if !filteredOptions.isEmpty {
selectedOption = filteredOptions.first!
}
renderOptions(
selectedOption: selectedOption,
options: options,
isFiltered: isFiltered,
filter: filter
)
return .continue
case .backspace where isFiltered, .delete where isFiltered:
if !filter.isEmpty {
filter.removeLast()
let filteredOptions = getFilteredOptions()
if !filteredOptions.isEmpty, !filteredOptions.contains(where: { $0 == selectedOption }) {
selectedOption = filteredOptions.first!
}
renderOptions(
selectedOption: selectedOption,
options: options,
isFiltered: isFiltered,
filter: filter
)
}
return .continue
case let .printable(character) where character == "k":
fallthrough
case .upArrowKey:
let filteredOptions = getFilteredOptions()
if !filteredOptions.isEmpty {
let currentIndex = filteredOptions.firstIndex(where: { $0 == selectedOption })!
selectedOption = filteredOptions[(currentIndex - 1 + filteredOptions.count) % filteredOptions.count]
renderOptions(
selectedOption: selectedOption,
options: options,
isFiltered: isFiltered,
filter: filter
)
}
return .continue
case let .printable(character) where character == "j":
fallthrough
case .downArrowKey:
let filteredOptions = getFilteredOptions()
if !filteredOptions.isEmpty {
let currentIndex = filteredOptions.firstIndex(where: { $0 == selectedOption })!
selectedOption = filteredOptions[(currentIndex + 1 + filteredOptions.count) % filteredOptions.count]
renderOptions(
selectedOption: selectedOption,
options: options,
isFiltered: isFiltered,
filter: filter
)
}
return .continue
case let .printable(character) where character == "/":
isFiltered = true
filter = ""
renderOptions(
selectedOption: selectedOption,
options: options,
isFiltered: isFiltered,
filter: filter
)
return .continue
case .jKey, .downArrowKey:
let currentIndex = options.firstIndex(where: { $0 == selectedOption })!
selectedOption = options[(currentIndex + 1 + options.count) % options.count]
renderOptions(selectedOption: selectedOption, options: options)
case .escape where isFiltered:
isFiltered = false
filter = ""
renderOptions(
selectedOption: selectedOption,
options: options,
isFiltered: isFiltered,
filter: filter
)
return .continue
default:
return .continue
Expand Down Expand Up @@ -74,7 +156,12 @@ struct SingleChoicePrompt {
)
}

private func renderOptions<T: Equatable>(selectedOption: (T, String), options: [(T, String)]) {
private func renderOptions<T: Equatable>(
selectedOption: (T, String),
options: [(T, String)],
isFiltered: Bool,
filter: String
) {
let titleOffset = title != nil ? " " : ""

// Header
Expand All @@ -86,14 +173,21 @@ struct SingleChoicePrompt {
}

header += "\(title != nil ? "\n" : "")\(titleOffset)\(question.formatted(theme: theme, terminal: terminal))"
if let description {
if isFiltered {
header +=
"\n\(titleOffset)\("Filter:".hexIfColoredTerminal(theme.muted, terminal)) \(filter.hexIfColoredTerminal(theme.primary, terminal))"
} else if let description {
header +=
"\n\(titleOffset)\(description.formatted(theme: theme, terminal: terminal).hexIfColoredTerminal(theme.muted, terminal))"
}

// Footer

let footer = "\n\(titleOffset)\("↑/↓/k/j up/down • enter confirm".hexIfColoredTerminal(theme.muted, terminal))"
let footer = if isFiltered {
"\n\(titleOffset)\("↑/↓ up/down • esc clear filter • enter confirm".hexIfColoredTerminal(theme.muted, terminal))"
} else {
"\n\(titleOffset)\("↑/↓/k/j up/down • / filter • enter confirm".hexIfColoredTerminal(theme.muted, terminal))"
}

func numberOfLines(for text: String) -> Int {
guard let terminalWidth = terminal.size?.columns else { return 1 }
Expand All @@ -107,26 +201,32 @@ struct SingleChoicePrompt {
let headerLines = numberOfLines(for: header)
let footerLines = numberOfLines(for: footer) + 1 /// `Renderer.render` adds a newline at the end

let filteredOptions = if isFiltered, !filter.isEmpty {
options.filter { $0.1.lowercased().contains(filter.lowercased()) }
} else {
options
}

let maxVisibleOptions = if let terminalSize = terminal.size {
max(1, terminalSize.rows - headerLines - footerLines)
} else {
options.count
filteredOptions.count
}

let currentIndex = options.firstIndex(where: { $0 == selectedOption })!
let currentIndex = filteredOptions.firstIndex(where: { $0 == selectedOption }) ?? 0
let middleIndex = maxVisibleOptions / 2

var startIndex = max(0, currentIndex - middleIndex)
if startIndex + maxVisibleOptions > options.count {
startIndex = max(0, options.count - maxVisibleOptions)
if startIndex + maxVisibleOptions > filteredOptions.count {
startIndex = max(0, filteredOptions.count - maxVisibleOptions)
}

let endIndex = min(options.count, startIndex + maxVisibleOptions)
let endIndex = min(filteredOptions.count, startIndex + maxVisibleOptions)

// Questions

var visibleOptions = [String]()
for (index, option) in options.enumerated() {
for (index, option) in filteredOptions.enumerated() {
if (startIndex ..< endIndex) ~= index {
if option == selectedOption {
visibleOptions.append("\(titleOffset) \("".hex(theme.primary)) \(option.1)")
Expand Down
12 changes: 8 additions & 4 deletions Sources/Noora/Components/YesOrNoChoicePrompt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,21 @@ struct YesOrNoChoicePrompt {
renderOptions(answer: answer)
keyStrokeListener.listen(terminal: terminal) { keyStroke in
switch keyStroke {
case .yKey:
case let .printable(character) where character == "y":
answer = true
return .abort
case .nKey:
case let .printable(character) where character == "n":
answer = false
return .abort
case .leftArrowKey, .rightArrowKey, .lKey, .hKey:
case let .printable(character) where character == "l":
fallthrough
case let .printable(character) where character == "h":
fallthrough
case .leftArrowKey, .rightArrowKey:
answer = !answer
renderOptions(answer: answer)
return .continue
case .returnKey:
case let .printable(character) where character.isNewline:
return .abort
default:
return .continue
Expand Down
5 changes: 5 additions & 0 deletions Sources/Noora/Extensions/Character+isPrintable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
extension Character {
var isPrintable: Bool {
isLetter || isNumber || isPunctuation || isSymbol || isWhitespace
}
}
43 changes: 19 additions & 24 deletions Sources/Noora/Utilities/KeyStrokeListener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,8 @@ import Foundation

/// An enum that represents the key strokes supported by the `KeyStrokeListening`
public enum KeyStroke {
/// It represents the return key.
case returnKey
/// It represents the q key
case qKey
/// It represents the k key
case kKey
/// It represents the j key
case jKey
/// It represents the y key
case yKey
/// It represents the n key
case nKey
/// It represents the l key
case lKey
/// It represents the h key
case hKey
/// It represents a printable character key
case printable(Character)
/// It represents the up arrow
case upArrowKey
/// It represents the down arrow.
Expand All @@ -26,6 +12,12 @@ public enum KeyStroke {
case leftArrowKey
/// It represents the right arrow.
case rightArrowKey
/// It represents the backspace key.
case backspace
/// It represents the delete key.
case delete
/// It represents the escape key.
case escape
}

/// A result that the caller can use in the onKeyPress callback to instruct the listener on how to
Expand Down Expand Up @@ -58,19 +50,22 @@ public struct KeyStrokeListener: KeyStrokeListening {
loop: while let char = terminal.readCharacter() {
buffer.append(char)

// Handle escape sequences
if buffer == "\u{1B}",
let nextChar = terminal.readCharacterNonBlocking()
{
buffer.append(nextChar)
}

let keyStroke: KeyStroke? = switch (char, buffer) {
case ("q", _): .qKey
case ("\n", _): .returnKey
case ("k", _): .kKey
case ("j", _): .jKey
case ("y", _): .yKey
case ("n", _): .nKey
case ("h", _): .hKey
case ("l", _): .lKey
case let (char, _) where buffer.count == 1 && char.isPrintable: .printable(char)
case (_, "\u{1B}[A"): .upArrowKey
case (_, "\u{1B}[B"): .downArrowKey
case (_, "\u{1B}[C"): .rightArrowKey
case (_, "\u{1B}[D"): .leftArrowKey
case ("\u{08}", _): .backspace
case ("\u{7F}", _): .delete
case (_, "\u{1B}"): .escape
default: nil
}

Expand Down
20 changes: 20 additions & 0 deletions Sources/Noora/Utilities/Terminal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public protocol Terminaling {
func withoutCursor(_ body: () throws -> Void) rethrows
func inRawMode(_ body: @escaping () throws -> Void) rethrows
func readCharacter() -> Character?
func readCharacterNonBlocking() -> Character?
}

public struct Terminal: Terminaling {
Expand Down Expand Up @@ -89,6 +90,25 @@ public struct Terminal: Terminaling {
return char != EOF ? Character(UnicodeScalar(UInt8(char))) : nil
}

public func readCharacterNonBlocking() -> Character? {
var term = termios()
tcgetattr(fileno(stdin), &term) // Get terminal attributes
var original = term

let flags = fcntl(fileno(stdin), F_GETFL)
_ = fcntl(fileno(stdin), F_SETFL, flags | O_NONBLOCK) // Set non-blocking mode

term.c_lflag &= ~tcflag_t(ECHO | ICANON) // Disable echo & canonical mode
tcsetattr(fileno(stdin), TCSANOW, &term) // Apply changes

let char = getchar() // Read single character

_ = fcntl(fileno(stdin), F_SETFL, flags)
tcsetattr(fileno(stdin), TCSANOW, &original) // Restore original settings

return char != EOF ? Character(UnicodeScalar(UInt8(char))) : nil
}

/// The function returns true when the terminal is interactive and false otherwise.
public static func isInteractive() -> Bool {
if ProcessInfo.processInfo.environment["NO_TTY"] != nil {
Expand Down
Loading

0 comments on commit d2c1fcc

Please sign in to comment.