diff --git a/CHANGELOG.md b/CHANGELOG.md index 6317e79..d90dc37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.9.0 + +- **FEAT**: Added submit button to multi screen form. +- **FEAT**: Added `ServerpodTuiText` to render multi line texts without leading and trailing whitespace after the first line. +- **FIX**: Removed leading and trailing whitespace from multi line form description text. + ## 0.8.0 - **FEAT**: `AlertLine` copy/dismiss hints are now clickable. diff --git a/lib/components.dart b/lib/components.dart index 2bce057..53b3aae 100644 --- a/lib/components.dart +++ b/lib/components.dart @@ -14,6 +14,7 @@ export 'src/components/radio_button.dart'; export 'src/components/shimmer.dart'; export 'src/components/spinner.dart'; export 'src/components/tab_bar.dart'; +export 'src/components/text.dart'; export 'src/components/tip.dart'; export 'src/components/unconstrained_box.dart'; export 'src/components/wrap.dart'; diff --git a/lib/src/components/form/configuration.dart b/lib/src/components/form/configuration.dart index 4a41040..5b9c296 100644 --- a/lib/src/components/form/configuration.dart +++ b/lib/src/components/form/configuration.dart @@ -1,6 +1,7 @@ import 'package:nocterm/nocterm.dart'; import 'package:serverpod_tui/src/components/checkbox.dart'; import 'package:serverpod_tui/src/components/radio_button.dart'; +import 'package:serverpod_tui/src/components/text.dart'; import 'package:serverpod_tui/src/components/wrap.dart'; import 'package:serverpod_tui/src/form/config.dart'; import 'package:serverpod_tui/src/form/config_option.dart'; @@ -34,7 +35,7 @@ class FormConfigurationLayout extends StatelessComponent { child, if (config.description case final FormDescription description) ...[ SizedBox(height: description.spacing), - Text( + ServerpodTuiText( description.label, style: const TextStyle( color: Color.defaultColor, diff --git a/lib/src/components/form/form.dart b/lib/src/components/form/form.dart index 0f756f8..83d1e73 100644 --- a/lib/src/components/form/form.dart +++ b/lib/src/components/form/form.dart @@ -30,6 +30,7 @@ class Form extends StatelessComponent { LogicalKey nextButtonActivationKey, VoidCallback? onSubmit, String? summaryDescription, + String? submitButtonLabel, }) = MultiScreenForm; final FormState state; diff --git a/lib/src/components/form/multi_screen_form.dart b/lib/src/components/form/multi_screen_form.dart index 83db418..749b1cf 100644 --- a/lib/src/components/form/multi_screen_form.dart +++ b/lib/src/components/form/multi_screen_form.dart @@ -22,6 +22,7 @@ class MultiScreenForm extends Form { this.nextButtonActivationKey = LogicalKey.space, super.onSubmit, this.summaryDescription, + this.submitButtonLabel, }) : super(state: state); /// State for multi screen form. @@ -36,6 +37,10 @@ class MultiScreenForm extends Form { /// Optional description for the summary screen. final String? summaryDescription; + /// Optional label for the summary screen action button. + /// Defaults to 'Submit' when not provided. + final String? submitButtonLabel; + @override Component build(BuildContext context) { if (state.isSummary) { @@ -45,12 +50,14 @@ class MultiScreenForm extends Form { padding: padding, description: summaryDescription, scrollController: scrollController, + onSubmit: onSubmit, + backButtonActivationKey: backButtonActivationKey, + nextButtonActivationKey: nextButtonActivationKey, + submitButtonLabel: submitButtonLabel, ); } final config = state.configurations[state.currentScreenIndex]; - final showMultiScreenNavigationButtons = - !state.isSummary && !state.hasSingleScreen; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -82,13 +89,14 @@ class MultiScreenForm extends Form { ), ), ), - if (showMultiScreenNavigationButtons) - _MultiScreenNavigationButtons( - state: state, - rebuild: rebuild, - backButtonActivationKey: backButtonActivationKey, - nextButtonActivationKey: nextButtonActivationKey, - ), + _MultiScreenNavigationButtons( + state: state, + rebuild: rebuild, + backButtonActivationKey: backButtonActivationKey, + nextButtonActivationKey: nextButtonActivationKey, + onSubmit: onSubmit, + submitButtonLabel: submitButtonLabel, + ), ], ); } @@ -100,38 +108,51 @@ class _MultiScreenNavigationButtons extends StatelessComponent { required this.rebuild, required this.backButtonActivationKey, required this.nextButtonActivationKey, + this.onSubmit, + this.submitButtonLabel, }); final MultiScreenFormState state; final VoidCallback rebuild; final LogicalKey backButtonActivationKey; final LogicalKey nextButtonActivationKey; + final VoidCallback? onSubmit; + + /// Label for the button to submit the form. + /// Defaults to 'Submit'. + final String? submitButtonLabel; @override Component build(BuildContext context) { final isFirstScreen = state.currentScreenIndex == 0; + final onLastScreen = state.hasSingleScreen || state.isSummary; return Align( alignment: Alignment.bottomRight, child: Row( mainAxisSize: MainAxisSize.min, children: [ + if (!isFirstScreen) ...[ + TextButton( + name: 'Back', + activationKeys: [backButtonActivationKey], + onActivate: (_) { + state.previousScreen(); + rebuild(); + }, + focused: state.focusOnButton && state.focusedButtonIndex == 0, + ), + const SizedBox(width: 1), + ], TextButton( - name: 'Back', - activationKeys: [backButtonActivationKey], - onActivate: (_) { - state.previousScreen(); - rebuild(); - }, - enabled: !isFirstScreen, - focused: state.focusOnButton && state.focusedButtonIndex == 0, - ), - const SizedBox(width: 1), - TextButton( - name: 'Next', + name: onLastScreen ? submitButtonLabel ?? 'Submit' : 'Next', activationKeys: [nextButtonActivationKey], onActivate: (_) { - state.nextScreen(); + if (onLastScreen) { + onSubmit?.call(); + } else { + state.nextScreen(); + } rebuild(); }, focused: state.focusOnButton && state.focusedButtonIndex == 1, @@ -150,6 +171,10 @@ class _SummaryScreen extends StatelessComponent { required this.padding, required this.scrollController, this.description, + this.onSubmit, + this.backButtonActivationKey = LogicalKey.space, + this.nextButtonActivationKey = LogicalKey.space, + this.submitButtonLabel, }); final MultiScreenFormState state; @@ -157,6 +182,10 @@ class _SummaryScreen extends StatelessComponent { final EdgeInsets padding; final ScrollController scrollController; final String? description; + final VoidCallback? onSubmit; + final LogicalKey backButtonActivationKey; + final LogicalKey nextButtonActivationKey; + final String? submitButtonLabel; @override Component build(BuildContext context) { @@ -194,6 +223,14 @@ class _SummaryScreen extends StatelessComponent { ), ), ), + _MultiScreenNavigationButtons( + state: state, + rebuild: rebuild, + backButtonActivationKey: backButtonActivationKey, + nextButtonActivationKey: nextButtonActivationKey, + onSubmit: onSubmit, + submitButtonLabel: submitButtonLabel, + ), ], ); } diff --git a/lib/src/components/text.dart b/lib/src/components/text.dart new file mode 100644 index 0000000..0efc06d --- /dev/null +++ b/lib/src/components/text.dart @@ -0,0 +1,226 @@ +import 'package:nocterm/nocterm.dart' hide TextAlign; +import 'package:nocterm/src/framework/terminal_canvas.dart'; + +import 'package:nocterm/src/text/text_layout_engine.dart'; + +/// A run of text with a single style with +/// leading and trailing whitespaces removed for lines after the first line. +class ServerpodTuiText extends SingleChildRenderObjectComponent { + const ServerpodTuiText( + this.data, { + super.key, + this.style, + this.softWrap = true, + this.overflow = TextOverflow.clip, + this.textAlign = TextAlign.left, + this.maxLines, + }); + + final String data; + final TextStyle? style; + final bool softWrap; + final TextOverflow overflow; + final TextAlign textAlign; + final int? maxLines; + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderText( + text: data, + style: style, + softWrap: softWrap, + overflow: overflow, + textAlign: textAlign, + maxLines: maxLines, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderText renderObject) { + renderObject + ..text = data + ..style = style + ..softWrap = softWrap + ..overflow = overflow + ..textAlign = textAlign + ..maxLines = maxLines; + } +} + +/// Render object for displaying text +class RenderText extends RenderObject with Selectable { + RenderText({ + required String text, + TextStyle? style, + bool softWrap = true, + TextOverflow overflow = TextOverflow.clip, + TextAlign textAlign = TextAlign.left, + int? maxLines, + }) : _text = text, + _style = style, + _softWrap = softWrap, + _overflow = overflow, + _textAlign = textAlign, + _maxLines = maxLines; + + String _text; + String get text => _text; + set text(String value) { + if (_text == value) return; + _text = value; + markNeedsLayout(); + } + + TextStyle? _style; + TextStyle? get style => _style; + set style(TextStyle? value) { + if (_style == value) return; + _style = value; + markNeedsPaint(); + } + + bool _softWrap; + bool get softWrap => _softWrap; + set softWrap(bool value) { + if (_softWrap == value) return; + _softWrap = value; + markNeedsLayout(); + } + + TextOverflow _overflow; + TextOverflow get overflow => _overflow; + set overflow(TextOverflow value) { + if (_overflow == value) return; + _overflow = value; + markNeedsLayout(); + } + + TextAlign _textAlign; + TextAlign get textAlign => _textAlign; + set textAlign(TextAlign value) { + if (_textAlign == value) return; + _textAlign = value; + markNeedsPaint(); + } + + int? _maxLines; + int? get maxLines => _maxLines; + set maxLines(int? value) { + if (_maxLines == value) return; + _maxLines = value; + markNeedsLayout(); + } + + TextLayoutResult? _layoutResult; + + @override + String get selectableText => _text; + + @override + TextLayoutResult? get selectableLayout => _layoutResult; + + @override + bool hitTestSelf(Offset position) => true; + + @override + void performLayout() { + // For text alignment to work properly, we need to use the actual constraint width + // When in a Column without stretch, we get infinite width, so we use intrinsic width + final maxWidth = constraints.maxWidth.isFinite + ? constraints.maxWidth.toInt() + : double.maxFinite.toInt(); + + // Debug: print constraint info + // print('RenderText layout: text="$_text", constraints=$constraints, maxWidth=$maxWidth'); + + final config = TextLayoutConfig( + softWrap: _softWrap, + overflow: _overflow, + textAlign: _textAlign, + maxLines: _maxLines, + maxWidth: maxWidth, + ); + + _layoutResult = TextLayoutEngine.layout(_text, config); + + size = constraints.constrain( + Size( + _layoutResult!.actualWidth.toDouble(), + _layoutResult!.actualHeight.toDouble(), + ), + ); + } + + @override + void paint(TerminalCanvas canvas, Offset offset) { + super.paint(canvas, offset); + + if (_layoutResult == null) return; + + final lines = _layoutResult!.lines; + // Use the actual size width for alignment, not the constraint + // The size has been constrained during layout + final alignmentWidth = size.width.toInt(); + final style = _style; + + for (int i = 0; i < lines.length; i++) { + final line = lines[i]; + + // Check if this is the last line of a paragraph for justification + bool isLastLine = i == lines.length - 1; + if (i < lines.length - 1) { + // Check if next line starts a new paragraph (was preceded by newline) + // This is a simplification - proper implementation would track paragraph boundaries + isLastLine = false; + } + + String displayLine = line; + + // Remove leading and trailing whitespace + if (i != 0) { + displayLine = displayLine.trim(); + } + + // Apply justification if needed + if (_textAlign == TextAlign.justify && !isLastLine) { + displayLine = TextLayoutEngine.justifyLine( + line, + alignmentWidth, + isLastLine: isLastLine, + ); + } + + // Calculate horizontal offset based on text alignment + final xOffset = + offset.dx + + TextLayoutEngine.calculateAlignmentOffset( + displayLine, + alignmentWidth, + _textAlign, + ); + + // Apply clipping if needed + if (_overflow == TextOverflow.clip && constraints.maxWidth.isFinite) { + // The TextLayoutEngine already handles truncation, so we just paint what it gave us + // But we might need to clip at the canvas level for safety + } + + // Use selection-aware painting if there is an active selection + if (hasSelection) { + paintTextWithSelection( + canvas, + Offset(xOffset, offset.dy + i), + displayLine, + style, + i, + ); + } else { + canvas.drawText( + Offset(xOffset, offset.dy + i), + displayLine, + style: _style, + ); + } + } + } +} diff --git a/lib/src/form/state.dart b/lib/src/form/state.dart index 27574d3..7fde8e0 100644 --- a/lib/src/form/state.dart +++ b/lib/src/form/state.dart @@ -287,7 +287,7 @@ class MultiScreenFormState extends FormState { /// then the focus is moved to the buttons. @override void focusDown() { - if (!_focusOnButton && !hasSingleScreen) { + if (!_focusOnButton) { if (isSummary || _currentScreenIndex > 0) { focusBackButton(); } else if (!isSummary) { @@ -301,7 +301,7 @@ class MultiScreenFormState extends FormState { /// then the focus is moved to the form. @override void focusUp() { - if (_focusOnButton && !hasSingleScreen) { + if (_focusOnButton) { _focusOnButton = false; if (_currentScreenIndex < configurations.length) { final config = configurations[_currentScreenIndex]; @@ -319,7 +319,7 @@ class MultiScreenFormState extends FormState { @override void focusRight() { if (_focusOnButton) { - if (_focusedButtonIndex == 0 && !isSummary) { + if (_focusedButtonIndex == 0) { focusNextButton(); } } else if (_currentScreenIndex < configurations.length) { @@ -395,6 +395,9 @@ class MultiScreenFormState extends FormState { if (currentFocus > 0) { updateFocusedConfigOption(-currentFocus); } + } else { + // Focus action buttons on summary screen. + focusDown(); } } } diff --git a/pubspec.yaml b/pubspec.yaml index 2943732..39e0e1c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: serverpod_tui description: A collection of tools for building terminal user interfaces. -version: 0.8.0 +version: 0.9.0 repository: https://github.com/serverpod/serverpod_tui homepage: https://serverpod.dev issue_tracker: https://github.com/serverpod/serverpod_tui/issues diff --git a/test/form/multi_screen_test.dart b/test/form/multi_screen_test.dart index 741fd4e..f96f975 100644 --- a/test/form/multi_screen_test.dart +++ b/test/form/multi_screen_test.dart @@ -91,7 +91,14 @@ class _MultiScreenTestHolder extends TuiAppStateHolder<_MultiScreenTestState> { } class _MultiScreenTestApp extends TuiApp<_MultiScreenTestHolder> { - const _MultiScreenTestApp({required super.holder}); + const _MultiScreenTestApp({ + required super.holder, + this.submitButtonLabel, + this.onSubmit, + }); + + final String? submitButtonLabel; + final VoidCallback? onSubmit; @override TuiAppState<_MultiScreenTestApp> createState() => _MultiScreenTestAppState(); @@ -135,6 +142,9 @@ class _MultiScreenTestAppState extends TuiAppState<_MultiScreenTestApp> { component.holder.markDirty(); return true; case LogicalKey.space: + if (formState.focusOnButton && formState.focusedButtonIndex == 1) { + return false; + } formState.onSelect(); component.holder.markDirty(); return true; @@ -153,6 +163,8 @@ class _MultiScreenTestAppState extends TuiAppState<_MultiScreenTestApp> { state: formState, scrollController: _scrollController, rebuild: component.holder.markDirty, + submitButtonLabel: component.submitButtonLabel, + onSubmit: component.onSubmit, ), ); } @@ -203,6 +215,33 @@ void main() { }, ); + test( + 'when on the first screen, ' + 'then only the Next button is shown', + () async { + await _pump(tester); + + final screenText = tester.terminalState.getText(); + expect(screenText, isNot(contains('Back'))); + expect(screenText, contains('Next')); + }, + ); + + test( + 'when on non-first screen, ' + 'then the Back and Next buttons are shown', + () async { + await _pump(tester); + + await _sendKey(tester, LogicalKey.enter); + await _pump(tester); + + final screenText = tester.terminalState.getText(); + expect(screenText, contains('Back')); + expect(screenText, contains('Next')); + }, + ); + test( 'when Escape is pressed on the first screen, ' 'then it stays on the first screen', @@ -318,6 +357,21 @@ void main() { expect(state.isSummary, isFalse); }, ); + + test( + 'when on the summary screen and no submitButtonLabel is provided, ' + 'then the action button shows "Submit"', + () async { + for (var i = 0; i < state.configScreenCount; i++) { + await _sendKey(tester, LogicalKey.enter); + await _pump(tester); + } + expect(state.isSummary, isTrue); + + final screenText = tester.terminalState.getText(); + expect(screenText, contains('Submit')); + }, + ); }); group('Given a multi-screen form with a single config', () { @@ -345,12 +399,16 @@ void main() { test( 'when arrowDown is pressed, ' - 'then the buttons are not focused', + 'then the Submit button is shown and focused', () async { await _sendKey(tester, LogicalKey.arrowDown); await _pump(tester); - expect(state.focusOnButton, isFalse); + final screenText = tester.terminalState.getText(); + expect(screenText, isNot(contains('Back'))); + expect(screenText, contains('Submit')); + + expect(state.focusOnButton, isTrue); expect(state.currentScreenIndex, 0); }, ); @@ -379,4 +437,139 @@ void main() { }, ); }); + + group('Given a multi-screen form with a custom submitButtonLabel', () { + late NoctermTester tester; + late MultiScreenFormState state; + late _MultiScreenTestHolder holder; + + setUp(() async { + state = MultiScreenFormState(SimpleConfig.values); + holder = _MultiScreenTestHolder(_MultiScreenTestState(state)); + tester = await NoctermTester.create(size: const Size(80, 24)); + await tester.pumpComponent( + _MultiScreenTestApp(holder: holder, submitButtonLabel: 'Create'), + ); + }); + + tearDown(() async { + tester.dispose(); + await holder.dispose(); + }); + + test( + 'when on the summary screen, ' + 'then the action button shows the custom label', + () async { + for (var i = 0; i < state.configScreenCount; i++) { + await _sendKey(tester, LogicalKey.enter); + await _pump(tester); + } + expect(state.isSummary, isTrue); + + final screenText = tester.terminalState.getText(); + expect(screenText, contains('Create')); + }, + ); + }); + + group('Given a multi-screen form with multiple configs and onSubmit', () { + late NoctermTester tester; + late MultiScreenFormState state; + late _MultiScreenTestHolder holder; + var onSubmitCalled = false; + + setUp(() async { + onSubmitCalled = false; + state = MultiScreenFormState(SimpleConfig.values); + holder = _MultiScreenTestHolder(_MultiScreenTestState(state)); + tester = await NoctermTester.create(size: const Size(80, 24)); + await tester.pumpComponent( + _MultiScreenTestApp( + holder: holder, + onSubmit: () => onSubmitCalled = true, + ), + ); + }); + + tearDown(() async { + tester.dispose(); + await holder.dispose(); + }); + + test( + 'when on the summary screen and Space activates the submit button, ' + 'then onSubmit is called', + () async { + // Navigate to summary + for (var i = 0; i < state.configScreenCount; i++) { + await _sendKey(tester, LogicalKey.enter); + await _pump(tester); + } + expect(state.isSummary, isTrue); + expect(onSubmitCalled, isFalse); + + // Focus on Back button, then move to submit button + await _sendKey(tester, LogicalKey.arrowDown); + await _pump(tester); + await _sendKey(tester, LogicalKey.arrowRight); + await _pump(tester); + expect(state.focusOnButton, isTrue); + expect(state.focusedButtonIndex, 1); + expect(onSubmitCalled, isFalse); + + // Space to activate the submit button + await _sendKey(tester, LogicalKey.space); + await _pump(tester); + + expect(onSubmitCalled, isTrue); + }, + ); + }); + + group('Given a multi-screen form with a single config and onSubmit', () { + late NoctermTester tester; + late MultiScreenFormState state; + late _MultiScreenTestHolder holder; + var onSubmitCalled = false; + + setUp(() async { + onSubmitCalled = false; + state = MultiScreenFormState([SimpleConfig.database]); + holder = _MultiScreenTestHolder(_MultiScreenTestState(state)); + tester = await NoctermTester.create(size: const Size(80, 24)); + await tester.pumpComponent( + _MultiScreenTestApp( + holder: holder, + onSubmit: () => onSubmitCalled = true, + ), + ); + }); + + tearDown(() async { + tester.dispose(); + await holder.dispose(); + }); + + test( + 'when on the first screen and Space activates the submit button, ' + 'then onSubmit is called', + () async { + // Navigate to the summary screen (hasSingleScreen: Enter does nothing, + // but we use arrowDown to focus the submit button) + await _sendKey(tester, LogicalKey.arrowDown); + await _pump(tester); + expect(state.focusOnButton, isTrue); + expect(state.focusedButtonIndex, 1); + expect(state.currentScreenIndex, 0); + expect(onSubmitCalled, isFalse); + + // Space to activate the submit button via the outer handler + await _sendKey(tester, LogicalKey.space); + await _pump(tester); + + expect(onSubmitCalled, isTrue); + }, + ); + }); }