Skip to content

Commit

Permalink
feat: add select and multiselect prompt components (#13)
Browse files Browse the repository at this point in the history
* feat: add select and multiselect prompt components

* test: add tests and helper for new features

* fix: ignore one byte control characters

* refactor: decouple state from parsing and rendering

* test: reorder and simplify tests

* test: add Option domain type

* refactor: move test util

* docs: add doc comments

* fix: rename variable and reuse it
  • Loading branch information
hampuslavin authored Jan 17, 2025
1 parent 5b75dcd commit b6377fa
Show file tree
Hide file tree
Showing 11 changed files with 799 additions and 11 deletions.
3 changes: 3 additions & 0 deletions lib/src/prompts/confirm.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import 'dart:io';

import 'package:cli_tools/cli_tools.dart';

/// Prompts the user to confirm an action.
/// Returns `true` if the user confirms, `false` otherwise.
/// If [defaultValue] is provided, the user can skip the prompt by pressing Enter.
Future<bool> confirm(
String message, {
bool? defaultValue,
Expand Down
11 changes: 11 additions & 0 deletions lib/src/prompts/key_codes.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/// ANSI key codes for the terminal.
abstract final class KeyCodes {
static var escapeSequenceStart = 27;
static var controlSequenceIntroducer = 91;
static var arrowUp = 65;
static var arrowDown = 66;
static var space = 32;
static var enterCR = 13;
static var enterLF = 10;
static var q = 113;
}
1 change: 1 addition & 0 deletions lib/src/prompts/prompts.dart
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export 'confirm.dart';
export 'select.dart' hide underline;
224 changes: 224 additions & 0 deletions lib/src/prompts/select.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import 'dart:io';

import 'package:cli_tools/cli_tools.dart';
import 'package:cli_tools/src/prompts/key_codes.dart';

/// Object that represents an option in a select prompt.
class Option {
/// The name of the option that will be displayed to the user.
final String name;

/// Creates an [Option] with the given [name].
Option(this.name);
}

/// Prompts the user to select an option from a list of [options].
Future<Option> select(
String prompt, {
required List<Option> options,
required Logger logger,
}) async {
return (await _interactiveSelect(
prompt,
options: options,
logger: logger,
))
.first;
}

/// Prompts the user to select multiple options from a list of [options].
/// If no options are selected the returned list will be empty.
Future<List<Option>> multiselect(
String prompt, {
required List<Option> options,
required Logger logger,
}) {
return _interactiveSelect(
prompt,
options: options,
multiple: true,
logger: logger,
);
}

Future<List<Option>> _interactiveSelect(
String message, {
required List<Option> options,
required Logger logger,
bool multiple = false,
}) async {
if (options.isEmpty) {
throw ArgumentError('Options cannot be empty.');
}

_SelectState state = _SelectState(
options: options,
selectedIndex: 0,
selectedOptions: <int>{},
multiple: multiple,
);

_renderState(
state: state,
logger: logger,
promptMessage: message,
);

var originalEchoMode = stdin.echoMode;
var originalLineMode = stdin.lineMode;
stdin.echoMode = false;
stdin.lineMode = false;

try {
while (true) {
var keyCode = stdin.readByteSync();

var confirmSelection =
keyCode == KeyCodes.enterCR || keyCode == KeyCodes.enterLF;
if (confirmSelection) {
return state.toList();
}

var quit = keyCode == KeyCodes.q;
if (quit) {
throw ExitException();
}

if (keyCode == KeyCodes.escapeSequenceStart) {
var nextByte = stdin.readByteSync();
if (nextByte == KeyCodes.controlSequenceIntroducer) {
nextByte = stdin.readByteSync();
if (nextByte == KeyCodes.arrowUp) {
state = state.prev();
} else if (nextByte == KeyCodes.arrowDown) {
state = state.next();
}
}
} else if (keyCode == KeyCodes.space && multiple) {
state = state.toggleCurrent();
}

_renderState(state: state, logger: logger, promptMessage: message);
}
} finally {
// Restore terminal settings
stdin.echoMode = originalEchoMode;
stdin.lineMode = originalLineMode;
}
}

void _renderState({
required _SelectState state,
required Logger logger,
required String promptMessage,
}) {
_clearTerminal();

logger.write(
promptMessage,
LogLevel.info,
newLine: true,
);

for (int i = 0; i < state.options.length; i++) {
var radioButton = state.currentOrContains(i) ? '(●)' : '(○)';
var optionText = '$radioButton ${state.options[i].name}';

logger.write(
i == state.selectedIndex ? underline(optionText) : optionText,
LogLevel.info,
newLine: true,
);
}

logger.write(
state.multiple
? 'Press [Space] to toggle selection, [Enter] to confirm.'
: 'Press [Enter] to confirm.',
LogLevel.info,
newParagraph: true,
);
}

class _SelectState {
final int selectedIndex;
final Set<int> selectedOptions;
final List<Option> options;
final bool multiple;

_SelectState({
required this.options,
required this.selectedIndex,
required this.selectedOptions,
required this.multiple,
});

_SelectState prev() {
return _SelectState(
options: options,
selectedIndex: (selectedIndex - 1 + options.length) % options.length,
selectedOptions: selectedOptions,
multiple: multiple,
);
}

_SelectState next() {
return _SelectState(
options: options,
selectedIndex: (selectedIndex + 1) % options.length,
selectedOptions: selectedOptions,
multiple: multiple,
);
}

_SelectState toggleCurrent() {
return _SelectState(
options: options,
selectedIndex: selectedIndex,
selectedOptions: selectedOptions.contains(selectedIndex)
? (selectedOptions..remove(selectedIndex))
: (selectedOptions..add(selectedIndex)),
multiple: multiple);
}

bool currentOrContains(int index) {
return multiple ? selectedOptions.contains(index) : selectedIndex == index;
}

List<Option> toList() {
return multiple
? selectedOptions.map((index) => options[index]).toList()
: [options[selectedIndex]];
}
}

// The clear terminal command \x1B[2J\x1B[H has the following format:
// ESC CSI n J ESC CSI H
// The first part is the Erase in Display (ED) escape sequence, where:
// ESC (Escape character, starts escape sequence) = \x1B
// CSI (Control Sequence Introducer) = [
// n = 2, which clears entire screen (and moves cursor to upper left on DOS ANSI.SYS).
// J indicates the Erase Display operation
//
// The second part is the Cursor Position escape sequence, where:
// H indicates the Cursor Position operation. It takes two parameters,
// defaulting to 1,1, which is the upper left corner of the terminal.
// See https://en.wikipedia.org/wiki/ANSI_escape_code for further info.
const _eraseInDisplayControlSequence = '\x1B[2J\x1B[H';
void _clearTerminal() {
stdout.write(_eraseInDisplayControlSequence);
}

// The control sequence CSI n m, named Select Graphic Rendition (SGR), sets display attributes.
// n is the SGR parameter for the attribute to set, where 4 is for underline and 0 is for reset.
// See https://en.wikipedia.org/wiki/ANSI_escape_code for further info.
const _underlineSelectGraphicRenditionControlSequence = '\x1B[4m';
const _resetSelectGraphicRenditionControlSequence = '\x1B[0m';
String underline(
String text,
) =>
[
_underlineSelectGraphicRenditionControlSequence,
text,
_resetSelectGraphicRenditionControlSequence
].join('');
Loading

0 comments on commit b6377fa

Please sign in to comment.