promptMultiSelect<T> static method
Prompts the user to select multiple options using spacebar & arrow keys.
Implementation
static List<T> promptMultiSelect<T>({
required String message,
required List<T> options,
required String Function(T) labelProvider,
List<T>? defaultSelections,
}) {
// If not running in a terminal, fallback to text prompt
if (!stdin.hasTerminal) {
print('\n${Logger.green}?${Logger.reset} ${Logger.bold}$message${Logger.reset} ${Logger.gray}(Comma-separated numbers, e.g. "1, 3")${Logger.reset}');
for (int i = 0; i < options.length; i++) {
final isSelected = defaultSelections?.contains(options[i]) ?? false;
final marker = isSelected
? '${Logger.green}[✓]${Logger.reset}'
: '${Logger.gray}[ ]${Logger.reset}';
final label = labelProvider(options[i]);
final indexStr = '${Logger.gray}[${i + 1}]${Logger.reset}';
print(' $indexStr $marker $label');
}
while (true) {
stdout.write(' ${Logger.bold}Enter options [all / none / numbers]:${Logger.reset} ');
final input = stdin.readLineSync(encoding: utf8)?.trim().toLowerCase() ?? '';
if (input.isEmpty) {
return defaultSelections ?? [];
}
if (input == 'all') {
return List<T>.from(options);
}
if (input == 'none') {
return <T>[];
}
final parts = input.split(RegExp(r'[\s,]+'));
final selections = <T>[];
bool valid = true;
for (final part in parts) {
if (part.isEmpty) continue;
final index = int.tryParse(part);
if (index != null && index >= 1 && index <= options.length) {
selections.add(options[index - 1]);
} else {
Logger.warning('Invalid option "$part".');
valid = false;
break;
}
}
if (valid) {
return selections;
}
}
}
// Hide cursor
stdout.write('\x1B[?25l');
print('${Logger.green}?${Logger.reset} ${Logger.bold}$message${Logger.reset} ${Logger.gray}(Use arrow keys, Space to toggle, Enter to select)${Logger.reset}');
int activeIndex = 0;
final selectedIndices = <int>{};
if (defaultSelections != null) {
for (int i = 0; i < options.length; i++) {
if (defaultSelections.contains(options[i])) {
selectedIndices.add(i);
}
}
}
void drawOptions() {
for (int i = 0; i < options.length; i++) {
final isActive = i == activeIndex;
final isSelected = selectedIndices.contains(i);
final pointer = isActive ? '${Logger.cyan}❯${Logger.reset}' : ' ';
final checkbox = isSelected
? '${Logger.green}[✓]${Logger.reset}'
: '${Logger.gray}[ ]${Logger.reset}';
final label = labelProvider(options[i]);
if (isActive) {
print(' $pointer $checkbox ${Logger.cyan}$label${Logger.reset}');
} else {
print(' $checkbox $label');
}
}
}
drawOptions();
while (true) {
final key = _readKey();
bool changed = false;
if (key == 1) { // UP
if (activeIndex > 0) {
activeIndex--;
} else {
activeIndex = options.length - 1;
}
changed = true;
} else if (key == 2) { // DOWN
if (activeIndex < options.length - 1) {
activeIndex++;
} else {
activeIndex = 0;
}
changed = true;
} else if (key == 32) { // SPACE
if (selectedIndices.contains(activeIndex)) {
selectedIndices.remove(activeIndex);
} else {
selectedIndices.add(activeIndex);
}
changed = true;
} else if (key == 10) { // ENTER
final results = selectedIndices.map((idx) => options[idx]).toList();
final labelSummary = results.isEmpty
? '${Logger.gray}none${Logger.reset}'
: results.map(labelProvider).join(', ');
// Move cursor up to overwrite previous prompt block
stdout.write('\x1B[${options.length + 1}A');
stdout.write('\x1B[J');
// Print clean confirmation line
print('${Logger.green}✔${Logger.reset} ${Logger.bold}$message${Logger.reset} ${Logger.cyan}$labelSummary${Logger.reset}');
// Restore cursor
stdout.write('\x1B[?25h');
return results;
}
if (changed) {
// Move cursor up to redraw options
stdout.write('\x1B[${options.length}A');
stdout.write('\x1B[J');
drawOptions();
}
}
}