From 34b13c1351389ca87406b10a30910514e3a6afde Mon Sep 17 00:00:00 2001 From: Preston Date: Fri, 3 Jul 2026 11:43:57 -0500 Subject: [PATCH 1/3] Replace copied hint text with inline copied text for alerts. Made alert copy and dismiss button clickable. --- lib/src/alert_line.dart | 56 ++++++++++++++++++++++++++++++----- lib/src/app.dart | 40 +++++++++++++++++++------ lib/src/app_state_holder.dart | 1 + lib/src/state.dart | 4 +++ test/alert_line_test.dart | 34 ++++++++++++++++++++- test/alert_test.dart | 32 ++++++++++++++++++-- 6 files changed, 147 insertions(+), 20 deletions(-) diff --git a/lib/src/alert_line.dart b/lib/src/alert_line.dart index 376be25..9d500d8 100644 --- a/lib/src/alert_line.dart +++ b/lib/src/alert_line.dart @@ -13,7 +13,14 @@ final _timeFormat = DateFormat('HH:mm:ss.SSS'); /// fit, so the line never wraps. Place it where it should appear - e.g. pinned /// at the bottom of a log panel. class AlertLine extends StatelessComponent { - const AlertLine({super.key, required this.alert, this.time}); + const AlertLine({ + super.key, + required this.alert, + required this.onCopy, + required this.onDismiss, + this.time, + this.copied = false, + }); final AlertMessage alert; @@ -21,6 +28,16 @@ class AlertLine extends StatelessComponent { /// Omitted when null. final DateTime? time; + /// When true, the copy hint shows a `✓ Copied` confirmation instead of + /// `C Copy`. + final bool copied; + + /// Invoked when the copy hint is clicked. + final VoidCallback onCopy; + + /// Invoked when the dismiss hint is clicked. + final VoidCallback onDismiss; + @override Component build(BuildContext context) { final st = ServerpodTheme.of(context); @@ -69,13 +86,26 @@ class AlertLine extends StatelessComponent { fontWeight: FontWeight.bold, ); final labelStyle = TextStyle(color: st.brightText); + final copiedStyle = TextStyle( + color: st.success, + fontWeight: FontWeight.bold, + ); - final hints = <(String, TextStyle)>[ - if (code != null) ...[('C', keyStyle), (' Copy ', labelStyle)], - ('Esc', keyStyle), - (' Dismiss', labelStyle), + // Each hint is a run of styled spans plus its tap handler, rendered as one + // clickable group. Trailing spaces after Copy separate it from Dismiss. The + // copy hint becomes `✓ Copied` (matching the log's green success mark) for + // a moment after a copy. + final copyHint = copied + ? [('✓', copiedStyle), (' Copied ', labelStyle)] + : [('C', keyStyle), (' Copy ', labelStyle)]; + final hints = <(List<(String, TextStyle)>, VoidCallback)>[ + if (code != null) (copyHint, onCopy), + ([('Esc', keyStyle), (' Dismiss', labelStyle)], onDismiss), ]; - final hintsWidth = hints.fold(0, (w, h) => w + h.$1.length); + final hintsWidth = hints.fold( + 0, + (w, hint) => w + hint.$1.fold(0, (w, span) => w + span.$1.length), + ); final message = _truncateKeepingTail( alert.displayText, @@ -101,10 +131,22 @@ class AlertLine extends StatelessComponent { return [ ...messageSpans, Expanded(child: const SizedBox.shrink()), - for (final (text, style) in hints) Text(text, style: style), + for (final (spans, onTap) in hints) _hint(spans, onTap), ]; } + /// Renders one hint group as a clickable [GestureDetector]. [MainAxisSize.min] + /// keeps the hit area to the text so only that hint responds to a tap. + Component _hint(List<(String, TextStyle)> spans, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [for (final (text, style) in spans) Text(text, style: style)], + ), + ); + } + /// Truncates [text] to [width] columns, keeping the tail and prefixing an /// ellipsis when it doesn't fit. Returns empty when there is no room. static String _truncateKeepingTail(String text, int width) { diff --git a/lib/src/app.dart b/lib/src/app.dart index ea96b7e..6c7b17c 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -23,9 +23,13 @@ abstract class TuiAppState extends State { /// How long after a first Ctrl-C a second press still counts as "exit". static const _exitArmWindow = Duration(seconds: 2); + /// How long the `✓ Copied` confirmation replaces the alert's copy hint. + static const _copiedWindow = Duration(seconds: 2); + bool _exitArmed = false; Timer? _exitArmTimer; Timer? _hintClearTimer; + Timer? _alertCopiedTimer; @override void initState() { @@ -43,6 +47,7 @@ abstract class TuiAppState extends State { void dispose() { _exitArmTimer?.cancel(); _hintClearTimer?.cancel(); + _alertCopiedTimer?.cancel(); component.holder.detach(this); super.dispose(); } @@ -121,16 +126,34 @@ abstract class TuiAppState extends State { final state = component.holder.state; state.alert = null; state.alertTime = null; + state.alertCopied = false; + _alertCopiedTimer?.cancel(); rebuild(); } + /// Copies the current alert's segment (re-copying in case the clipboard has + /// been overwritten since the alert appeared) and shows a `✓ Copied` + /// confirmation on the alert line for [_copiedWindow]. No-op without a + /// copyable segment. + void copyAlert() { + if (component.holder.state.alert?.copyText case final text?) { + copyToClipboard(text); + component.holder.state.alertCopied = true; + _alertCopiedTimer?.cancel(); + _alertCopiedTimer = Timer(_copiedWindow, () { + component.holder.state.alertCopied = false; + rebuild(); + }); + rebuild(); + } + } + bool _handleKeyEvent(KeyboardEvent event) { if (_handleCtrlC(event)) return true; return _handleAlertKeys(event); } - // Escape dismisses the alert; C re-copies its segment in case the clipboard - // has been overwritten since the alert appeared. + // Escape dismisses the alert; C re-copies its segment. bool _handleAlertKeys(KeyboardEvent event) { final alert = component.holder.state.alert; if (alert == null) return false; @@ -140,13 +163,12 @@ abstract class TuiAppState extends State { return true; } - if (alert.copyText case final text? - when event.logicalKey == LogicalKey.keyC && - !event.isControlPressed && - !event.isAltPressed && - !event.isMetaPressed) { - copyToClipboard(text); - _showHint('Copied to clipboard', autoClear: true); + if (alert.copyText != null && + event.logicalKey == LogicalKey.keyC && + !event.isControlPressed && + !event.isAltPressed && + !event.isMetaPressed) { + copyAlert(); return true; } diff --git a/lib/src/app_state_holder.dart b/lib/src/app_state_holder.dart index 1e2ebf8..4397dca 100644 --- a/lib/src/app_state_holder.dart +++ b/lib/src/app_state_holder.dart @@ -60,6 +60,7 @@ abstract class TuiAppStateHolder { void showAlert(AlertMessage alert, {DateTime? time}) { state.alert = alert; state.alertTime = time; + state.alertCopied = false; if (alert.copyText case final text?) { copyToClipboard(text); } diff --git a/lib/src/state.dart b/lib/src/state.dart index 5f9bc7b..638e86a 100644 --- a/lib/src/state.dart +++ b/lib/src/state.dart @@ -29,6 +29,10 @@ abstract class TuiState { /// When [alert] was raised, shown alongside it. Null when unknown. DateTime? alertTime; + + /// True briefly after the alert's segment is copied, so `AlertLine` can show + /// a `✓ Copied` confirmation in place of the `C Copy` hint. + bool alertCopied = false; } /// A tracked operation (server session or CLI progress). diff --git a/test/alert_line_test.dart b/test/alert_line_test.dart index 3575a2f..5f123c5 100644 --- a/test/alert_line_test.dart +++ b/test/alert_line_test.dart @@ -10,11 +10,18 @@ Future _render( String message, { required int width, DateTime? time, + bool copied = false, }) async { final tester = await NoctermTester.create(size: Size(width.toDouble(), 6)); try { await tester.pumpComponent( - AlertLine(alert: AlertMessage.parse(message), time: time), + AlertLine( + alert: AlertMessage.parse(message), + time: time, + copied: copied, + onCopy: () {}, + onDismiss: () {}, + ), ); return tester.terminalState .getText() @@ -72,6 +79,31 @@ void main() { }, ); + group('Given an alert whose code was just copied', () { + late String line; + + setUp(() async { + line = await _render( + 'Registration code: ', + width: 80, + time: _time, + copied: true, + ); + }); + + test('when rendered then the copy hint shows a copied confirmation', () { + expect(line, contains('✓ Copied')); + }); + + test('when rendered then the plain copy hint is gone', () { + expect(line, isNot(contains('C Copy'))); + }); + + test('when rendered then the dismiss hint still shows', () { + expect(line, contains('Dismiss')); + }); + }); + group('Given an alert without a code', () { late String line; diff --git a/test/alert_test.dart b/test/alert_test.dart index fa59820..4e9a179 100644 --- a/test/alert_test.dart +++ b/test/alert_test.dart @@ -74,10 +74,35 @@ void main() { expect(ClipboardManager.paste(), 'h2k9x3mp'); }); - test('when C is pressed then a confirmation hint is shown', () async { + test('when C is pressed then the copied confirmation is shown', () async { await _sendKey(tester, LogicalKey.keyC); - expect(state.ctrlCHint, 'Copied to clipboard'); + expect(state.alertCopied, isTrue); + }); + + test('when C is pressed then no bottom hint line is shown', () async { + await _sendKey(tester, LogicalKey.keyC); + + expect(state.ctrlCHint, isNull); + }); + + test('when the confirmation window elapses then it clears', () async { + await _sendKey(tester, LogicalKey.keyC); + expect(state.alertCopied, isTrue); + + // Just past the 2s confirmation window. + await tester.pump(const Duration(milliseconds: 2200)); + + expect(state.alertCopied, isFalse); + }); + + test('when the alert is dismissed then the confirmation clears', () async { + await _sendKey(tester, LogicalKey.keyC); + expect(state.alertCopied, isTrue); + + await _sendKey(tester, LogicalKey.escape); + + expect(state.alertCopied, isFalse); }); }, ); @@ -94,11 +119,12 @@ void main() { }); test( - 'when C is pressed then nothing is copied and no hint appears', + 'when C is pressed then nothing is copied and no confirmation appears', () async { await _sendKey(tester, LogicalKey.keyC); expect(ClipboardManager.paste(), 'previous clipboard content'); + expect(state.alertCopied, isFalse); expect(state.ctrlCHint, isNull); }, ); From dc2b12fc815b8d943e211a9401ca931df9c29d0a Mon Sep 17 00:00:00 2001 From: Preston Date: Fri, 3 Jul 2026 11:58:51 -0500 Subject: [PATCH 2/3] Bumped version and updated changelog. --- CHANGELOG.md | 6 ++++++ pubspec.yaml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7781ad2..6317e79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.8.0 + +- **FEAT**: `AlertLine` copy/dismiss hints are now clickable. +- **FEAT**: Copying an alert's segment now shows an inline green `✓ Copied` confirmation (matching the log's success mark) in place of the `C Copy` hint for a couple of seconds, instead of appending a `Copied to clipboard` line that displaced the content below it. +- **BREAKING**: `AlertLine` now requires `onCopy` and `onDismiss` callbacks and takes an optional `copied` flag. + ## 0.7.0 - **FEAT**: Added per-tab status indicators to `TabBar` via a new `TabActivity` enum and `states` parameter, plus a reusable `TabActivityIndicator` widget: running (`●`), loading (spinner), stopped (`○`). diff --git a/pubspec.yaml b/pubspec.yaml index 5b370c3..2943732 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.7.0 +version: 0.8.0 repository: https://github.com/serverpod/serverpod_tui homepage: https://serverpod.dev issue_tracker: https://github.com/serverpod/serverpod_tui/issues From fe3ff8ac1591f36d3458dcd9ac427c039c3cb2dd Mon Sep 17 00:00:00 2001 From: Preston Date: Fri, 3 Jul 2026 12:09:47 -0500 Subject: [PATCH 3/3] Formatted test file. --- test/alert_test.dart | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/test/alert_test.dart b/test/alert_test.dart index 4e9a179..c429548 100644 --- a/test/alert_test.dart +++ b/test/alert_test.dart @@ -96,14 +96,17 @@ void main() { expect(state.alertCopied, isFalse); }); - test('when the alert is dismissed then the confirmation clears', () async { - await _sendKey(tester, LogicalKey.keyC); - expect(state.alertCopied, isTrue); + test( + 'when the alert is dismissed then the confirmation clears', + () async { + await _sendKey(tester, LogicalKey.keyC); + expect(state.alertCopied, isTrue); - await _sendKey(tester, LogicalKey.escape); + await _sendKey(tester, LogicalKey.escape); - expect(state.alertCopied, isFalse); - }); + expect(state.alertCopied, isFalse); + }, + ); }, );