diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b1ed79..5cfba72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ -# 2.3.0 +## 2.4.0 +- Implement `number` component + +## 2.3.0 - Implement theming components - Add `AskTheme` property in `ask` component - Add `SelectTheme` property in `select` component @@ -17,26 +20,26 @@ - `maxLength` : Check if the value length is lower than a value - `equals` : Check if the value is equals to a value -# 2.2.4 +## 2.2.4 - Make `task` component as windows compatible - Change default placeholder for `swap` component in example - Remove "Tape to search" in `checkbox` component - Reset cursor position in enter `screen` component -# 2.2.3 +## 2.2.3 - Add missing properties `select` in select commander entry - Fix multiple behaviour instead of single behaviour in `checkbox` component - Enhance `info` logger method -# 2.2.2 +## 2.2.2 - Remove `createSpace` method in `ask` component -# 2.2.1 +## 2.2.1 - The ask component disappeared after the validation stage - Calling `createSpace` method in all rendering cases for `ask` component - Remove missing `print` statement in `readKey` function -# 2.2.0 +## 2.2.0 - Add `select` display handler - Fix bad FutureOr execution diff --git a/README.md b/README.md index 85a7ea4..6bf80f4 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,30 @@ Future main() async { } ``` +### Number component + +A simple example of using Commander to create a number component : + +- ✅ Integrated or custom validators +- ✅ Default value +- ✅ Custom rendering +- ✅ `double` or `int` (`num` by default) + +```dart +Future main() async { + final commander = Commander(level: Level.verbose); + + final value = await commander.number('What is your age ?', + interval: 1, + onDisplay: (value) => value?.toStringAsFixed(2), + validate: (validator) => validator + ..greaterThan(18, message: 'You must be at least 18 years old') + ..lowerThan(99, message: 'You must be at most 99 years old')); + + print(value); +} +``` + ### Select component A simple example of using Commander to create an option selection component : diff --git a/example/number.dart b/example/number.dart new file mode 100644 index 0000000..d9f2bce --- /dev/null +++ b/example/number.dart @@ -0,0 +1,14 @@ +import 'package:commander_ui/commander_ui.dart'; + +Future main() async { + final commander = Commander(level: Level.verbose); + + final value = await commander.number('What is your age ?', + interval: 1, + onDisplay: (value) => value?.toStringAsFixed(2), + validate: (validator) => validator + ..lowerThan(18, message: 'You must be at least 18 years old') + ..greaterThan(99, message: 'You must be at most 99 years old')); + + print(value); +} diff --git a/lib/src/application/components/number.dart b/lib/src/application/components/number.dart new file mode 100644 index 0000000..3c9666f --- /dev/null +++ b/lib/src/application/components/number.dart @@ -0,0 +1,179 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:commander_ui/src/application/terminals/terminal.dart'; +import 'package:commander_ui/src/application/themes/default_number_theme.dart'; +import 'package:commander_ui/src/application/utils/terminal_tools.dart'; +import 'package:commander_ui/src/application/validators/chain_validator.dart'; +import 'package:commander_ui/src/domains/models/chain_validator.dart'; +import 'package:commander_ui/src/domains/models/component.dart'; +import 'package:commander_ui/src/domains/themes/number_theme.dart'; +import 'package:commander_ui/src/io.dart'; +import 'package:mansion/mansion.dart'; + +/// A component that asks the user for input. +final class Number + with TerminalTools + implements Component> { + final _completer = Completer(); + + final Terminal _terminal; + final NumberTheme _theme; + + final String _message; + double _value = 0.0; + final double _interval; + final Function(NumberChainValidator)? _validate; + final String? Function(T?) _onDisplay; + + String? normalizeValue(double value) { + return _onDisplay(switch (T) { + int => value.toInt() as T, + _ => value as T, + }); + } + + Number(this._terminal, + {required String message, + T? defaultValue, + T? interval, + Function(NumberChainValidator)? validate, + String? Function(T?)? onDisplay, + NumberTheme? theme}) + : _message = message, + _value = double.parse(defaultValue != null ? '$defaultValue' : '0.0'), + _interval = double.parse(interval != null ? '$interval' : '1.0'), + _validate = validate, + _onDisplay = (onDisplay ?? (value) => value.toString()), + _theme = theme ?? DefaultNumberTheme(); + + @override + Future handle() { + createSpace(_terminal, 1); + stdout.writeAnsiAll([CursorPosition.save, CursorVisibility.hide]); + + _render(); + _waitResponse(); + + return _completer.future; + } + + void _waitResponse() { + final key = readKey(_terminal); + + if (key.controlChar == ControlCharacter.arrowUp || key.char == 'k') { + _value += _interval; + } else if (key.controlChar == ControlCharacter.arrowDown || + key.char == 'j') { + _value -= _interval; + } else if ([ControlCharacter.ctrlJ, ControlCharacter.ctrlM] + .contains(key.controlChar)) { + if (_validate != null) { + final validator = ValidatorChain(); + _validate!(validator); + + final result = validator.execute(_value); + if (result case String error) { + _onError(error); + + return; + } + } + + return _onSuccess(); + } + + _render(); + _waitResponse(); + } + + void _render() { + final buffer = StringBuffer(); + + List askSequence = [ + ..._theme.askPrefixColor, + Print('${_theme.askPrefix} '), + SetStyles.reset, + ]; + + buffer.writeAnsiAll([ + CursorPosition.restore, + Clear.afterCursor, + ...askSequence, + Print(_message), + const CursorPosition.moveRight(1), + ..._theme.inputColor, + Print('${normalizeValue(_value)}'), + ]); + + stdout.write(buffer.toString()); + } + + void _onError(String error) { + final buffer = StringBuffer(); + + List errorSequence = [ + ..._theme.errorPrefixColor, + Print('${_theme.errorSuffix} '), + SetStyles.reset, + ]; + + buffer.writeAnsiAll([ + CursorPosition.restore, + Clear.afterCursor, + ...errorSequence, + Print(_message), + const CursorPosition.moveRight(1), + ]); + + buffer.writeAnsiAll([ + AsciiControl.lineFeed, + ..._theme.validatorColorMessage, + Print(error), + SetStyles.reset, + ]); + + stdout.write(buffer.toString()); + + stdout.writeAnsiAll([ + const CursorPosition.moveUp(1), + CursorPosition.moveToColumn(_message.length + 2), + const CursorPosition.moveRight(2), + ..._theme.inputColor, + Print('${normalizeValue(_value)}'), + ]); + + _waitResponse(); + } + + void _onSuccess() { + final buffer = StringBuffer(); + + List successSequence = [ + ..._theme.successPrefixColor, + Print('${_theme.successSuffix} '), + SetStyles.reset, + ]; + + buffer.writeAnsiAll([ + CursorPosition.restore, + Clear.untilEndOfLine, + Clear.afterCursor, + ...successSequence, + Print(_message), + Print(' '), + ..._theme.inputColor, + Print('${normalizeValue(_value)}'), + SetStyles.reset, + AsciiControl.lineFeed, + CursorVisibility.show, + ]); + + stdout.write(buffer.toString()); + + return switch (T) { + int => _completer.complete(_value.toInt() as T), + _ => _completer.complete(_value as T), + }; + } +} diff --git a/lib/src/application/themes/default_number_theme.dart b/lib/src/application/themes/default_number_theme.dart new file mode 100644 index 0000000..f0c212c --- /dev/null +++ b/lib/src/application/themes/default_number_theme.dart @@ -0,0 +1,86 @@ +import 'package:commander_ui/src/domains/themes/ask_theme.dart'; +import 'package:commander_ui/src/domains/themes/number_theme.dart'; +import 'package:mansion/mansion.dart'; + +final class DefaultNumberTheme implements NumberTheme { + @override + String askPrefix = '?'; + + @override + String errorSuffix = '✘'; + + @override + String successSuffix = '✔'; + + @override + String Function(String? value) defaultValueFormatter = + (String? value) => switch (value) { + String value => ' ($value)', + _ => '', + }; + + @override + String? Function(String? value) inputFormatter = (String? value) => value; + + @override + List successPrefixColor = [ + SetStyles.reset, + SetStyles(Style.foreground(Color.green)) + ]; + + @override + List errorPrefixColor = [ + SetStyles(Style.foreground(Color.brightRed)) + ]; + + @override + List askPrefixColor = [SetStyles(Style.foreground(Color.yellow))]; + + @override + List validatorColorMessage = [ + SetStyles(Style.foreground(Color.brightRed)) + ]; + + @override + List defaultValueColorMessage = [ + SetStyles(Style.foreground(Color.brightBlack)) + ]; + + @override + List inputColor = [SetStyles(Style.foreground(Color.brightBlack))]; + + DefaultNumberTheme(); + + /// Creates a new [AskTheme] with the provided values based on [DefaultAskTheme]. + factory DefaultNumberTheme.copyWith( + {String? askPrefix, + String? errorSuffix, + String? successSuffix, + String Function(String? value)? defaultValueFormatter, + String? Function(String? value)? inputFormatter, + List? successPrefixColor, + List? errorPrefixColor, + List? validatorColorMessage, + List? askPrefixColor, + List? defaultValueColorMessage, + List? inputColor}) { + final theme = DefaultNumberTheme(); + + theme.askPrefix = askPrefix ?? theme.askPrefix; + theme.errorSuffix = errorSuffix ?? theme.errorSuffix; + theme.successSuffix = successSuffix ?? theme.successSuffix; + theme.defaultValueFormatter = + defaultValueFormatter ?? theme.defaultValueFormatter; + theme.inputFormatter = inputFormatter ?? theme.inputFormatter; + theme.successPrefixColor = successPrefixColor ?? theme.successPrefixColor; + theme.errorPrefixColor = errorPrefixColor ?? theme.errorPrefixColor; + theme.validatorColorMessage = + validatorColorMessage ?? theme.validatorColorMessage; + theme.askPrefixColor = askPrefixColor ?? theme.askPrefixColor; + theme.defaultValueColorMessage = + defaultValueColorMessage ?? theme.defaultValueColorMessage; + theme.inputColor = inputColor ?? theme.inputColor; + + return theme; + } +} diff --git a/lib/src/application/validators/chain_validator.dart b/lib/src/application/validators/chain_validator.dart index 11dee64..2cccc52 100644 --- a/lib/src/application/validators/chain_validator.dart +++ b/lib/src/application/validators/chain_validator.dart @@ -1,13 +1,13 @@ import 'package:commander_ui/src/domains/models/chain_validator.dart'; -final class ValidatorChain - implements ChainValidatorExecutor, ChainValidatorContract { - final List _validators = []; +final class ValidatorChain + implements ChainValidatorExecutor, ChainValidatorContract { + final List _validators = []; String? value; @override - void validate(String? Function(String? value) validator) { + void validate(String? Function(T? value) validator) { _validators.add(validator); } @@ -40,7 +40,7 @@ final class ValidatorChain return message; } - return switch (emailRegExp.hasMatch(value)) { + return switch (emailRegExp.hasMatch(value as String)) { false => message, _ => null, }; @@ -48,9 +48,9 @@ final class ValidatorChain } @override - void minLength(int count, {String? message}) { + void minLength(T count, {String? message}) { _validators.add((value) { - if (value case String value when value.length < count) { + if (value case String value when value.length < (count as num)) { return message ?? 'This field should have at least $count characters'; } @@ -59,9 +59,9 @@ final class ValidatorChain } @override - void maxLength(int count, {String? message}) { + void maxLength(T count, {String? message}) { _validators.add((value) { - if (value case String value when value.length > count) { + if (value case String value when value.length > (count as num)) { return message ?? 'This field should have at most $count characters'; } @@ -70,7 +70,7 @@ final class ValidatorChain } @override - void equals(String str, {String? message}) { + void equals(T str, {String? message}) { _validators.add((value) { if (value case String value when value != str) { return message ?? 'This field should be equal to $str'; @@ -81,7 +81,7 @@ final class ValidatorChain } @override - void between(int min, int max, {String? message}) { + void between(T min, T max, {String? message}) { final errorMessage = message ?? 'This field should be between $min and $max characters'; _validators.add((value) { @@ -89,9 +89,7 @@ final class ValidatorChain return errorMessage; } - final length = value.length; - - if (length < min || length > max) { + if ((value as num) < (min as num)) { return errorMessage; } @@ -100,16 +98,14 @@ final class ValidatorChain } @override - void lowerThan(int value, {String? message}) { + void lowerThan(T value, {String? message}) { final errorMessage = message ?? 'This field should be lower than $value'; _validators.add((response) { if (response == null) { return errorMessage; } - final intValue = int.tryParse(response); - - if (intValue == null || intValue >= value) { + if ((response as num) >= (value as num)) { return errorMessage; } @@ -118,16 +114,14 @@ final class ValidatorChain } @override - void greaterThan(int value, {String? message}) { + void greaterThan(T value, {String? message}) { final errorMessage = message ?? 'This field should be greater than $value'; _validators.add((response) { if (response == null) { return errorMessage; } - final intValue = int.tryParse(response); - - if (intValue == null || intValue <= value) { + if ((response as num) <= (value as num)) { return errorMessage; } @@ -136,8 +130,7 @@ final class ValidatorChain } @override - String? execute(String? value) { - print('Executing validators'); + String? execute(T? value) { for (final validator in _validators) { final result = validator(value); if (result != null) { diff --git a/lib/src/commander.dart b/lib/src/commander.dart index ec72346..4c867c5 100644 --- a/lib/src/commander.dart +++ b/lib/src/commander.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:commander_ui/src/application/components/ask.dart'; import 'package:commander_ui/src/application/components/checkbox.dart'; +import 'package:commander_ui/src/application/components/number.dart'; import 'package:commander_ui/src/application/components/screen.dart'; import 'package:commander_ui/src/application/components/select.dart'; import 'package:commander_ui/src/application/components/swap.dart'; @@ -15,6 +16,7 @@ import 'package:commander_ui/src/domains/models/commander_theme.dart'; import 'package:commander_ui/src/domains/models/component_theme.dart'; import 'package:commander_ui/src/domains/themes/ask_theme.dart'; import 'package:commander_ui/src/domains/themes/checkbox_theme.dart'; +import 'package:commander_ui/src/domains/themes/number_theme.dart'; import 'package:commander_ui/src/domains/themes/select_theme.dart'; import 'package:commander_ui/src/domains/themes/swap_theme.dart'; import 'package:commander_ui/src/domains/themes/task_theme.dart'; @@ -81,6 +83,21 @@ class Commander with TerminalTools { theme: theme ?? _componentTheme.askTheme) .handle(); + Future number(String message, + {T? defaultValue, + T? interval, + Function(NumberChainValidator)? validate, + String? Function(T?)? onDisplay, + NumberTheme? theme}) => + Number(_terminal, + message: message, + defaultValue: defaultValue, + interval: interval, + validate: validate, + onDisplay: onDisplay, + theme: theme ?? _componentTheme.numberTheme) + .handle(); + Future select(String message, {T? defaultValue, required List options, diff --git a/lib/src/domains/models/chain_validator.dart b/lib/src/domains/models/chain_validator.dart index 673aae7..049790e 100644 --- a/lib/src/domains/models/chain_validator.dart +++ b/lib/src/domains/models/chain_validator.dart @@ -1,35 +1,37 @@ -abstract interface class ChainValidatorExecutor { - String? execute(String? value); +abstract interface class ChainValidatorExecutor { + String? execute(T? value); } -abstract interface class BaseChainValidator { - void validate(String? Function(String? value) validator); +abstract interface class BaseChainValidator { + void validate(String? Function(T? value) validator); } -abstract interface class TextualChainValidator implements BaseChainValidator { +abstract interface class TextualChainValidator + implements BaseChainValidator { void notEmpty({String? message}); void empty({String? message}); void email({String? message}); - void minLength(int count, {String? message}); + void minLength(T count, {String? message}); - void maxLength(int count, {String? message}); + void maxLength(T count, {String? message}); - void equals(String value, {String? message}); + void equals(T value, {String? message}); } -abstract interface class NumberChainValidator implements BaseChainValidator { - void between(int min, int max, {String? message}); +abstract interface class NumberChainValidator + implements BaseChainValidator { + void between(T min, T max, {String? message}); - void lowerThan(int value, {String? message}); + void lowerThan(T value, {String? message}); - void greaterThan(int value, {String? message}); + void greaterThan(T value, {String? message}); } -abstract interface class ChainValidatorContract +abstract interface class ChainValidatorContract implements - BaseChainValidator, - TextualChainValidator, - NumberChainValidator {} + BaseChainValidator, + TextualChainValidator, + NumberChainValidator {} diff --git a/lib/src/domains/models/component_theme.dart b/lib/src/domains/models/component_theme.dart index 487d3b5..050e7ad 100644 --- a/lib/src/domains/models/component_theme.dart +++ b/lib/src/domains/models/component_theme.dart @@ -1,11 +1,13 @@ import 'package:commander_ui/src/domains/themes/ask_theme.dart'; import 'package:commander_ui/src/domains/themes/checkbox_theme.dart'; +import 'package:commander_ui/src/domains/themes/number_theme.dart'; import 'package:commander_ui/src/domains/themes/select_theme.dart'; import 'package:commander_ui/src/domains/themes/swap_theme.dart'; import 'package:commander_ui/src/domains/themes/task_theme.dart'; final class ComponentTheme { final AskTheme? askTheme; + final NumberTheme? numberTheme; final CheckboxTheme? checkboxTheme; final SwapTheme? switchTheme; final SelectTheme? selectTheme; @@ -13,6 +15,7 @@ final class ComponentTheme { ComponentTheme( {this.askTheme, + this.numberTheme, this.checkboxTheme, this.switchTheme, this.selectTheme, diff --git a/lib/src/domains/themes/number_theme.dart b/lib/src/domains/themes/number_theme.dart new file mode 100644 index 0000000..e54e8f7 --- /dev/null +++ b/lib/src/domains/themes/number_theme.dart @@ -0,0 +1,37 @@ +import 'package:commander_ui/commander_ui.dart'; +import 'package:mansion/mansion.dart'; + +abstract interface class NumberTheme { + /// The prefix that will be displayed before the question. + String get askPrefix; + + /// The suffix that will be displayed after a failed validation. + String get errorSuffix; + + /// The suffix that will be displayed after a successful validation. + String get successSuffix; + + /// A function that will be used to format the default value. + String Function(String? value) get defaultValueFormatter; + + /// A function that will be used to format the input. + String? Function(String? value) get inputFormatter; + + /// The color of the prefix. + List get askPrefixColor; + + /// The color of the prefix when the validation is successful. + List get successPrefixColor; + + /// The color of the prefix when the validation fails. + List get errorPrefixColor; + + /// The color of error messages when `validate` method is provided. + List get validatorColorMessage; + + /// The color of the default value. + List get defaultValueColorMessage; + + /// The color of the user text input. + List get inputColor; +} diff --git a/pubspec.yaml b/pubspec.yaml index 3ca1ec1..429907f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: commander_ui description: Commander is a Dart library for creating user interfaces within the terminal. -version: 2.3.0 +version: 2.4.0 repository: https://github.com/LeadcodeDev/commander topics: