Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 (`○`).
Expand Down
56 changes: 49 additions & 7 deletions lib/src/alert_line.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,31 @@ 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;

/// When the alert was raised; rendered in the same column as log timestamps.
/// 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);
Expand Down Expand Up @@ -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<int>(0, (w, h) => w + h.$1.length);
final hintsWidth = hints.fold<int>(
0,
(w, hint) => w + hint.$1.fold<int>(0, (w, span) => w + span.$1.length),
);

final message = _truncateKeepingTail(
alert.displayText,
Expand All @@ -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)],
),
);
}
Comment on lines +138 to +148

/// 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) {
Expand Down
40 changes: 31 additions & 9 deletions lib/src/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,13 @@ abstract class TuiAppState<S extends TuiApp> extends State<S> {
/// 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() {
Expand All @@ -43,6 +47,7 @@ abstract class TuiAppState<S extends TuiApp> extends State<S> {
void dispose() {
_exitArmTimer?.cancel();
_hintClearTimer?.cancel();
_alertCopiedTimer?.cancel();
component.holder.detach(this);
super.dispose();
}
Expand Down Expand Up @@ -121,16 +126,34 @@ abstract class TuiAppState<S extends TuiApp> extends State<S> {
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;
Expand All @@ -140,13 +163,12 @@ abstract class TuiAppState<S extends TuiApp> extends State<S> {
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;
}

Expand Down
1 change: 1 addition & 0 deletions lib/src/app_state_holder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ abstract class TuiAppStateHolder<S extends TuiState> {
void showAlert(AlertMessage alert, {DateTime? time}) {
state.alert = alert;
state.alertTime = time;
state.alertCopied = false;
if (alert.copyText case final text?) {
copyToClipboard(text);
}
Expand Down
4 changes: 4 additions & 0 deletions lib/src/state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
34 changes: 33 additions & 1 deletion test/alert_line_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,18 @@ Future<String> _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()
Expand Down Expand Up @@ -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: <h2k9x3mp>',
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;

Expand Down
35 changes: 32 additions & 3 deletions test/alert_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,39 @@ 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);
},
);
},
);

Expand All @@ -94,11 +122,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);
},
);
Expand Down
Loading