Skip to content
Closed
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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.28.1] - 2025-02-27
### Details
#### Fix
- Add .returnKey again to KeystrokeListener

## [0.28.0] - 2025-02-27
### Details
#### Feat
- Add autoSelectSingle option to SingleChoicePrompt

## [0.27.0] - 2025-02-27
### Details
#### Feat
- Allow custom renderer and keystroke listener in Noora

## [0.26.2] - 2025-02-27
### Details
#### Chore
- Update dependency pnpm to v10.5.2

## [0.26.1] - 2025-02-27
### Details
#### Fix
- Add filterMode parameter to Noorable singleChoicePrompt methods

## [0.26.0] - 2025-02-26
### Details
#### Feat
Expand Down Expand Up @@ -532,6 +557,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Update dependency reddavis/asynchrone to from: "0.22.0"
- Rename the project

[0.28.1]: https://github.com/tuist/XcodeGraph/compare/0.28.0..0.28.1
[0.28.0]: https://github.com/tuist/XcodeGraph/compare/0.27.0..0.28.0
[0.27.0]: https://github.com/tuist/XcodeGraph/compare/0.26.2..0.27.0
[0.26.2]: https://github.com/tuist/XcodeGraph/compare/0.26.1..0.26.2
[0.26.1]: https://github.com/tuist/XcodeGraph/compare/0.26.0..0.26.1
[0.26.0]: https://github.com/tuist/XcodeGraph/compare/0.25.0..0.26.0
[0.25.0]: https://github.com/tuist/XcodeGraph/compare/0.24.0..0.25.0
[0.24.0]: https://github.com/tuist/XcodeGraph/compare/0.23.1..0.24.0
Expand Down
6 changes: 6 additions & 0 deletions Sources/Noora/Components/SingleChoicePrompt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ struct SingleChoicePrompt {
let terminal: Terminaling
let collapseOnSelection: Bool
let filterMode: SingleChoicePromptFilterMode
let autoselectSingleChoice: Bool
let renderer: Rendering
let standardPipelines: StandardPipelines
let keyStrokeListener: KeyStrokeListening
Expand All @@ -35,6 +36,11 @@ struct SingleChoicePrompt {
// MARK: - Private

private func run<T: Equatable>(options: [(T, String)]) -> T {
if autoselectSingleChoice, options.count == 1 {
renderResult(selectedOption: options[0])
return options[0].0
}

if !terminal.isInteractive {
fatalError("'\(question)' can't be prompted in a non-interactive session.")
}
Expand Down
70 changes: 49 additions & 21 deletions Sources/Noora/Noora.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,18 @@ public protocol Noorable {
/// - options: The options to show to the user.
/// - description: Use it to add some explanation to what the question is for.
/// - collapseOnSelection: Whether the prompt should collapse after the user selects an option.
/// - filterMode: Whether filtering should be disabled, toggleable, or enabled.
/// - autoselectSingleChoice: Whether the prompt should automatically select the first item when options only contains one
/// item.
/// - Returns: The option selected by the user.
func singleChoicePrompt<T: Equatable & CustomStringConvertible>(
title: TerminalText?,
question: TerminalText,
options: [T],
description: TerminalText?,
collapseOnSelection: Bool
collapseOnSelection: Bool,
filterMode: SingleChoicePromptFilterMode,
autoselectSingleChoice: Bool
) -> T

/// It shows multiple options to the user to select one.
Expand All @@ -82,12 +87,17 @@ public protocol Noorable {
/// - question: The quetion to ask to the user.
/// - description: Use it to add some explanation to what the question is for.
/// - collapseOnSelection: Whether the prompt should collapse after the user selects an option.
/// - filterMode: Whether filtering should be disabled, toggleable, or enabled.
/// - autoselectSingleChoice: Whether the prompt should automatically select the first item when options only contains one
/// item.
/// - Returns: The option selected by the user.
func singleChoicePrompt<T: CaseIterable & CustomStringConvertible & Equatable>(
title: TerminalText?,
question: TerminalText,
description: TerminalText?,
collapseOnSelection: Bool
collapseOnSelection: Bool,
filterMode: SingleChoicePromptFilterMode,
autoselectSingleChoice: Bool
) -> T

/// It shows a component to answer yes or no to a question.
Expand Down Expand Up @@ -182,15 +192,21 @@ public class Noora: Noorable {
let standardPipelines: StandardPipelines
let theme: Theme
let terminal: Terminaling
let renderer: Rendering
let keyStrokeListener: KeyStrokeListening

public init(
theme: Theme = .default,
terminal: Terminaling = Terminal(),
standardPipelines: StandardPipelines = StandardPipelines()
standardPipelines: StandardPipelines = StandardPipelines(),
renderer: Rendering = Renderer(),
keyStrokeListener: KeyStrokeListening = KeyStrokeListener()
) {
self.theme = theme
self.terminal = terminal
self.standardPipelines = standardPipelines
self.renderer = renderer
self.keyStrokeListener = keyStrokeListener
}

public func singleChoicePrompt<T>(
Expand All @@ -199,7 +215,8 @@ public class Noora: Noorable {
options: [T],
description: TerminalText?,
collapseOnSelection: Bool,
filterMode: SingleChoicePromptFilterMode
filterMode: SingleChoicePromptFilterMode,
autoselectSingleChoice: Bool
) -> T where T: CustomStringConvertible, T: Equatable {
let component = SingleChoicePrompt(
title: title,
Expand All @@ -209,9 +226,10 @@ public class Noora: Noorable {
terminal: terminal,
collapseOnSelection: collapseOnSelection,
filterMode: filterMode,
renderer: Renderer(),
standardPipelines: StandardPipelines(),
keyStrokeListener: KeyStrokeListener()
autoselectSingleChoice: autoselectSingleChoice,
renderer: renderer,
standardPipelines: standardPipelines,
keyStrokeListener: keyStrokeListener
)
return component.run(options: options)
}
Expand All @@ -221,7 +239,8 @@ public class Noora: Noorable {
question: TerminalText,
description: TerminalText? = nil,
collapseOnSelection: Bool = true,
filterMode: SingleChoicePromptFilterMode = .disabled
filterMode: SingleChoicePromptFilterMode = .disabled,
autoselectSingleChoice: Bool = true
) -> T {
let component = SingleChoicePrompt(
title: title,
Expand All @@ -231,9 +250,10 @@ public class Noora: Noorable {
terminal: terminal,
collapseOnSelection: collapseOnSelection,
filterMode: filterMode,
renderer: Renderer(),
autoselectSingleChoice: autoselectSingleChoice,
renderer: renderer,
standardPipelines: standardPipelines,
keyStrokeListener: KeyStrokeListener()
keyStrokeListener: keyStrokeListener
)
return component.run()
}
Expand All @@ -251,8 +271,8 @@ public class Noora: Noorable {
theme: theme,
terminal: terminal,
collapseOnAnswer: collapseOnAnswer,
renderer: Renderer(),
standardPipelines: StandardPipelines()
renderer: renderer,
standardPipelines: standardPipelines
)
return component.run()
}
Expand All @@ -271,9 +291,9 @@ public class Noora: Noorable {
theme: theme,
terminal: terminal,
collapseOnSelection: collapseOnSelection,
renderer: Renderer(),
renderer: renderer,
standardPipelines: standardPipelines,
keyStrokeListener: KeyStrokeListener(),
keyStrokeListener: keyStrokeListener,
defaultAnswer: defaultAnswer
).run()
}
Expand Down Expand Up @@ -324,7 +344,7 @@ public class Noora: Noorable {
task: task,
theme: theme,
terminal: terminal,
renderer: Renderer(),
renderer: renderer,
standardPipelines: standardPipelines
)
try await progressStep.run()
Expand All @@ -345,8 +365,8 @@ public class Noora: Noorable {
task: task,
theme: theme,
terminal: terminal,
renderer: Renderer(),
standardPipelines: StandardPipelines()
renderer: renderer,
standardPipelines: standardPipelines
).run()
}

Expand All @@ -361,28 +381,36 @@ extension Noorable {
question: TerminalText,
options: [T],
description: TerminalText? = nil,
collapseOnSelection: Bool = true
collapseOnSelection: Bool = true,
filterMode: SingleChoicePromptFilterMode = .disabled,
autoselectSingleChoice: Bool = true
) -> T {
singleChoicePrompt(
title: title,
question: question,
options: options,
description: description,
collapseOnSelection: collapseOnSelection
collapseOnSelection: collapseOnSelection,
filterMode: filterMode,
autoselectSingleChoice: autoselectSingleChoice
)
}

public func singleChoicePrompt<T: CaseIterable & CustomStringConvertible & Equatable>(
title: TerminalText? = nil,
question: TerminalText,
description: TerminalText? = nil,
collapseOnSelection: Bool = true
collapseOnSelection: Bool = true,
filterMode: SingleChoicePromptFilterMode = .disabled,
autoselectSingleChoice: Bool = true
) -> T {
singleChoicePrompt(
title: title,
question: question,
description: description,
collapseOnSelection: collapseOnSelection
collapseOnSelection: collapseOnSelection,
filterMode: filterMode,
autoselectSingleChoice: autoselectSingleChoice
)
}

Expand Down
52 changes: 50 additions & 2 deletions Sources/Noora/Utilities/KeyStrokeListener.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import Foundation

/// An enum that represents the key strokes supported by the `KeyStrokeListening`
public enum KeyStroke {
public enum KeyStroke: Equatable {
/// It represents the return key.
case returnKey
/// It represents a printable character key
case printable(Character)
/// It represents the up arrow
Expand All @@ -18,6 +20,20 @@ public enum KeyStroke {
case delete
/// It represents the escape key.
case escape
/// It represents a left mouse button press.
case leftMouseDown(position: TerminalPosition)
/// It represents a right mouse button press.
case rightMouseDown(position: TerminalPosition)
/// It represents a left mouse button release.
case leftMouseUp(position: TerminalPosition)
/// It represents a right mouse button release.
case rightMouseUp(position: TerminalPosition)
/// It represents dragging with left mouse button.
case leftMouseDrag(position: TerminalPosition)
/// It represents dragging with right mouse button.
case rightMouseDrag(position: TerminalPosition)
/// It represents mouse movement without any buttons pressed.
case mouseMoved(position: TerminalPosition)
}

/// A result that the caller can use in the onKeyPress callback to instruct the listener on how to
Expand Down Expand Up @@ -61,13 +77,15 @@ public struct KeyStrokeListener: KeyStrokeListening {

let keyStroke: KeyStroke? = switch (char, buffer) {
case let (char, _) where buffer.count == 1 && char.isPrintable: .printable(char)
case let (char, _) where char == "\n": .returnKey
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
case let (_, buf) where buf.hasPrefix("\u{1B}[<"): decodeSGRMouseEvent(from: buf)
default: nil
}

Expand All @@ -78,11 +96,41 @@ public struct KeyStrokeListener: KeyStrokeListening {
case .continue: continue
}
}
if buffer.count > 3 {
if buffer.count > 14 {
buffer = ""
}
}
}

/// Decodes a mouse event in SGR format (ESC[<btn;x;yM or ESC[<btn;x;ym)
private func decodeSGRMouseEvent(from buffer: String) -> KeyStroke? {
guard let endIndex = buffer.firstIndex(where: { $0 == "M" || $0 == "m" }) else {
return nil
}

let isPress = buffer[endIndex] == "M"
let parts = String(buffer.dropFirst(3).prefix(while: { $0 != "M" && $0 != "m" }))
.split(separator: ";")
.compactMap { Int($0) }

guard parts.count == 3 else {
return nil
}

let button = parts[0]
let position = TerminalPosition(row: parts[2], column: parts[1])

return switch (button, isPress) {
case (0, true): .leftMouseDown(position: position)
case (0, false): .leftMouseUp(position: position)
case (2, true): .rightMouseDown(position: position)
case (2, false): .rightMouseUp(position: position)
case (32, _): .leftMouseDrag(position: position)
case (34, _): .rightMouseDrag(position: position)
case (35, _): .mouseMoved(position: position)
default: nil
}
}
}

extension KeyStrokeListening {
Expand Down
2 changes: 1 addition & 1 deletion Sources/Noora/Utilities/Renderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public protocol Rendering: AnyObject {
public class Renderer: Rendering {
private var lastRenderedContent: [String] = []

init() {}
public init() {}

private func eraseLines(_ lines: Int, standardPipeline: StandardPipelining) {
if lines == 0 { return }
Expand Down
Loading