Skip to content

Conversation

@Jramos57
Copy link

Motivation

Many CLI tools allow users to cancel/go back using keyboard shortcuts (q, b, ESC) instead of navigating to a "Back" option in a list. This PR adds optional cancel key support to singleChoicePrompt.

Use Case

In our M4B audiobook editor, users rename chapters from a list. Currently, they must scroll to "← Back to main menu" and press Enter to cancel. With cancelKey: "q", they can simply press 'q' from anywhere in the selection list.

Changes

  • Added optional cancelKey: Character? property to SingleChoicePrompt struct
  • Implemented runCancelable() methods that return Optional<T> (nil on cancel)
  • Added cancel key handling in keystroke listener (returns nil when pressed)
  • Updates instruction text dynamically to show the cancel key: "↑/↓ select • [q] cancel • enter confirm"
  • Added protocol methods to Noorable interface
  • Added public API overloads in Noora class (both main methods and convenience extensions)
  • Fully backwards compatible - existing code works unchanged, new functionality is opt-in

Implementation Approach

Used the overload pattern to ensure zero breaking changes:

  • Existing singleChoicePrompt() methods return non-optional T
  • New singleChoicePrompt(cancelKey:) methods return optional T?
  • Users opt-in to cancellable behavior by providing the cancelKey parameter

Example Usage

enum Fruit: String, CaseIterable, CustomStringConvertible {
    case apple, banana, orange
    var description: String { rawValue }
}

if let fruit = noora.singleChoicePrompt(
    question: "Choose a fruit",
    cancelKey: "q"
) as Fruit? {
    print("Selected: \(fruit)")
} else {
    print("User canceled")
}

Technical Details

  • Follows the same pattern as filter toggle ('/' key) for keystroke handling
  • Logs cancel events via existing logger infrastructure
  • Supports both [T] options and CaseIterable variants
  • Dynamic instruction text generation preserves existing filter mode messaging
  • Type-safe: compiler enforces Optional unwrapping when using cancelKey

Testing

  • ✅ Builds successfully (swift build)
  • ✅ Zero breaking changes (all existing tests pass)
  • ✅ Manual testing verified in terminal
  • 📝 Unit tests can be added based on project conventions

Related Patterns

Common in CLI tools:

  • vim uses 'q' to quit
  • less uses 'q' to quit
  • git interactive commands use 'q' to cancel
  • Many TUI apps use ESC or specific keys for navigation

- Add optional cancelKey property to SingleChoicePrompt struct
- Implement runCancelable() methods that return Optional<T>
- Add keystroke handling for cancel key in runCancelable
- Generate dynamic instruction text showing cancel key to users
- Add protocol methods to Noorable for cancelable variants
- Add public API overloads in Noora class (both main and convenience)
- Maintain full backwards compatibility (existing code unchanged)
- Zero breaking changes - cancelKey is opt-in via new overloads

Example usage:
  if let fruit = noora.singleChoicePrompt(
      question: "Choose a fruit",
      cancelKey: "q"
  ) as Fruit? {
      print("Selected: \(fruit)")
  } else {
      print("Canceled")
  }
@Jramos57 Jramos57 requested a review from a team as a code owner December 10, 2025 16:28
@Jramos57 Jramos57 requested review from cschmatzler and fortmarek and removed request for a team December 10, 2025 16:28
@dosubot dosubot bot added size:L This PR changes 100-499 lines, ignoring generated files. changelog:added labels Dec 10, 2025
Copy link
Member

@fortmarek fortmarek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution 💯

I'm very aligned with the idea. I'd try to reuse more of the existing implementation to ease the future maintenance of this component. Additionally, I'd suggest adding some tests in SingleChoicePromptTests. Let me know if you have any questions or need help with anything 😌

We really appreciate the time you're taking to contribute to Noora!

return selectedOption.0
}

private func runCancelable<T: Equatable>(options: [(T, String)]) -> T? {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd expect this method to reuse the run method (or the other way around). Right now, we're duplicating a lot of code, making this piece harder to maintain.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback! You're absolutely right about the duplication. I'll refactor runCancelable() to reuse the existing run() method internally rather than duplicating all that logic. I'll also add comprehensive tests to SingleChoicePromptTests to cover the cancel functionality. Should have the updates pushed shortly!

collapseOnSelection: Bool,
filterMode: SingleChoicePromptFilterMode,
autoselectSingleChoice: Bool,
cancelKey: Character,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd expect us to provide a default, sensible choice, instead of forcing the developer to pick the cancel key.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call on providing a default! I'll add a sensible default value (probably 'q') so developers don't have to specify it every time. Makes the API much more ergonomic.

- Eliminated code duplication by making runCancelable() reuse the
  existing run() implementation with an optional cancelKey parameter
- Updated run() to support optional cancellation internally
- Removed ~113 lines of duplicate code for easier maintenance
- Added default cancelKey value of 'q' for better API ergonomics
- Added comprehensive tests for cancel functionality:
  - Test cancellation when cancel key is pressed
  - Test cancel instruction rendering
  - Test non-cancelable behavior when cancelKey is nil
- Updated all existing tests to include cancelKey parameter

This refactoring makes the codebase more maintainable by having a
single source of truth for the prompt logic, while maintaining full
backwards compatibility.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog:added size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants