Skip to content
Closed
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
40 changes: 39 additions & 1 deletion lib/src/terminal_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,18 @@ class TerminalView extends StatefulWidget {
this.keyboardType = TextInputType.emailAddress,
this.keyboardAppearance = Brightness.dark,
this.cursorType = TerminalCursorType.block,
this.cursorBlink,
this.cursorBlinkInterval = const Duration(milliseconds: 750),
this.cursorBlinkTimeout = const Duration(seconds: 5),
this.alwaysShowCursor = false,
this.deleteDetection = false,
this.shortcuts,
this.onKeyEvent,
this.readOnly = false,
this.hardwareKeyboardOnly = false,
this.simulateScroll = true,
});
}) : assert(cursorBlinkInterval > Duration.zero),
assert(cursorBlinkTimeout == null || cursorBlinkTimeout > Duration.zero);

/// The underlying terminal that this widget renders.
final Terminal terminal;
Expand Down Expand Up @@ -196,6 +200,22 @@ class TerminalView extends StatefulWidget {
/// The type of cursor to use. [TerminalCursorType.block] by default.
final TerminalCursorType cursorType;

/// Overrides whether the cursor blinks.
///
/// When null, the terminal application's cursor blink mode is used.
final bool? cursorBlink;

/// The interval between cursor visibility changes.
///
/// Defaults to 750 milliseconds.
final Duration cursorBlinkInterval;

/// How long the cursor blinks before remaining visible.
///
/// Defaults to five seconds. Set to null to keep blinking while the terminal
/// has focus and cursor blinking is enabled.
final Duration? cursorBlinkTimeout;

/// Whether to always show the cursor. This is useful for debugging.
/// [false] by default.
final bool alwaysShowCursor;
Expand Down Expand Up @@ -447,6 +467,9 @@ class TerminalViewState extends State<TerminalView> {
theme: widget.theme,
focusNode: _focusNode,
cursorType: widget.cursorType,
cursorBlink: widget.cursorBlink,
cursorBlinkInterval: widget.cursorBlinkInterval,
cursorBlinkTimeout: widget.cursorBlinkTimeout,
alwaysShowCursor: widget.alwaysShowCursor,
activeHyperlinkId: _activeHyperlinkId,
onEditableRect: _onEditableRect,
Expand Down Expand Up @@ -872,6 +895,9 @@ class _TerminalView extends LeafRenderObjectWidget {
required this.theme,
required this.focusNode,
required this.cursorType,
required this.cursorBlink,
required this.cursorBlinkInterval,
required this.cursorBlinkTimeout,
required this.alwaysShowCursor,
this.activeHyperlinkId,
this.onEditableRect,
Expand Down Expand Up @@ -900,6 +926,12 @@ class _TerminalView extends LeafRenderObjectWidget {

final TerminalCursorType cursorType;

final bool? cursorBlink;

final Duration cursorBlinkInterval;

final Duration? cursorBlinkTimeout;

final bool alwaysShowCursor;

final int? activeHyperlinkId;
Expand All @@ -922,6 +954,9 @@ class _TerminalView extends LeafRenderObjectWidget {
theme: theme,
focusNode: focusNode,
cursorType: cursorType,
cursorBlink: cursorBlink,
cursorBlinkInterval: cursorBlinkInterval,
cursorBlinkTimeout: cursorBlinkTimeout,
alwaysShowCursor: alwaysShowCursor,
activeHyperlinkId: activeHyperlinkId,
onEditableRect: onEditableRect,
Expand All @@ -943,6 +978,9 @@ class _TerminalView extends LeafRenderObjectWidget {
..theme = theme
..focusNode = focusNode
..cursorType = cursorType
..cursorBlink = cursorBlink
..cursorBlinkInterval = cursorBlinkInterval
..cursorBlinkTimeout = cursorBlinkTimeout
..alwaysShowCursor = alwaysShowCursor
..activeHyperlinkId = activeHyperlinkId
..onEditableRect = onEditableRect
Expand Down
66 changes: 53 additions & 13 deletions lib/src/ui/render.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,26 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
required TerminalTheme theme,
required FocusNode focusNode,
required TerminalCursorType cursorType,
bool? cursorBlink,
Duration cursorBlinkInterval = const Duration(milliseconds: 750),
Duration? cursorBlinkTimeout = const Duration(seconds: 5),
required bool alwaysShowCursor,
int? activeHyperlinkId,
EditableRectCallback? onEditableRect,
String? composingText,
}) : _terminal = terminal,
}) : assert(cursorBlinkInterval > Duration.zero),
assert(cursorBlinkTimeout == null || cursorBlinkTimeout > Duration.zero),
_terminal = terminal,
_controller = controller,
_offset = offset,
_padding = padding,
_autoResize = autoResize,
_backgroundOpacity = backgroundOpacity,
_focusNode = focusNode,
_cursorType = cursorType,
_cursorBlink = cursorBlink,
_cursorBlinkInterval = cursorBlinkInterval,
_cursorBlinkTimeoutDuration = cursorBlinkTimeout,
_alwaysShowCursor = alwaysShowCursor,
_activeHyperlinkId = activeHyperlinkId,
_onEditableRect = onEditableRect,
Expand Down Expand Up @@ -143,6 +151,32 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
markNeedsPaint();
}

bool? _cursorBlink;
set cursorBlink(bool? value) {
if (value == _cursorBlink) return;
_cursorBlink = value;
_updateCursorBlinking(force: true);
markNeedsPaint();
}

Duration _cursorBlinkInterval;
set cursorBlinkInterval(Duration value) {
assert(value > Duration.zero);
if (value == _cursorBlinkInterval) return;
_cursorBlinkInterval = value;
_updateCursorBlinking(force: true);
markNeedsPaint();
}

Duration? _cursorBlinkTimeoutDuration;
set cursorBlinkTimeout(Duration? value) {
assert(value == null || value > Duration.zero);
if (value == _cursorBlinkTimeoutDuration) return;
_cursorBlinkTimeoutDuration = value;
_updateCursorBlinking(force: true);
markNeedsPaint();
}

bool _alwaysShowCursor;
set alwaysShowCursor(bool value) {
if (value == _alwaysShowCursor) return;
Expand Down Expand Up @@ -180,7 +214,7 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin {

Timer? _cursorBlinkTimer;

Timer? _cursorBlinkTimeout;
Timer? _cursorBlinkTimeoutTimer;

Timer? _textBlinkTimer;

Expand All @@ -192,6 +226,8 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin {

bool get isCursorBlinkVisible => _cursorBlinkVisible;

bool get _cursorBlinkEnabled => _cursorBlink ?? _terminal.cursorBlinkMode;

Color? debugBackgroundFillColor() {
_updatePainterColorState();
final backgroundOverride = _painter.backgroundColorOverride;
Expand Down Expand Up @@ -292,7 +328,7 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
}

void _updateCursorBlinking({bool force = false}) {
final enabled = _terminal.cursorBlinkMode && _focusNode.hasFocus;
final enabled = _cursorBlinkEnabled && _focusNode.hasFocus;
final blinkTimerActive = _cursorBlinkTimer != null;
if (!force &&
enabled == _cursorBlinkWasEnabled &&
Expand All @@ -306,25 +342,27 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
if (!enabled || !attached) return;

_cursorBlinkTimer = Timer.periodic(
const Duration(milliseconds: 750),
_cursorBlinkInterval,
(_) {
_cursorBlinkVisible = !_cursorBlinkVisible;
markNeedsPaint();
},
);
_cursorBlinkTimeout = Timer(const Duration(seconds: 5), () {
_cursorBlinkTimer?.cancel();
_cursorBlinkTimer = null;
_cursorBlinkVisible = true;
markNeedsPaint();
});
if (_cursorBlinkTimeoutDuration case final timeout?) {
_cursorBlinkTimeoutTimer = Timer(timeout, () {
_cursorBlinkTimer?.cancel();
_cursorBlinkTimer = null;
_cursorBlinkVisible = true;
markNeedsPaint();
});
}
}

void _stopCursorBlinking() {
_cursorBlinkTimer?.cancel();
_cursorBlinkTimeout?.cancel();
_cursorBlinkTimeoutTimer?.cancel();
_cursorBlinkTimer = null;
_cursorBlinkTimeout = null;
_cursorBlinkTimeoutTimer = null;
_cursorBlinkVisible = true;
}

Expand Down Expand Up @@ -640,7 +678,9 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
bool get _shouldShowCursor {
if (_alwaysShowCursor || _isComposingText) return true;
if (!_terminal.cursorVisibleMode) return false;
if (!_terminal.cursorBlinkMode || !_focusNode.hasFocus) return true;
if (!_cursorBlinkEnabled || !_focusNode.hasFocus) {
return true;
}
return _cursorBlinkVisible;
}

Expand Down
146 changes: 146 additions & 0 deletions test/src/terminal_view_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,152 @@ void main() {
focusNode.dispose();
});

testWidgets('TerminalView uses custom cursor blink timing', (tester) async {
final terminal = Terminal()..write('\x1b[1 q');
final focusNode = FocusNode();

await tester.pumpWidget(
MaterialApp(
home: TerminalView(
terminal,
focusNode: focusNode,
autofocus: true,
cursorBlinkInterval: const Duration(milliseconds: 100),
cursorBlinkTimeout: const Duration(milliseconds: 250),
),
),
);
await tester.pump();

final state = tester.state<TerminalViewState>(find.byType(TerminalView));
await tester.pump(const Duration(milliseconds: 100));
expect(state.renderTerminal.isCursorBlinkVisible, isFalse);

await tester.pump(const Duration(milliseconds: 100));
expect(state.renderTerminal.isCursorBlinkVisible, isTrue);

await tester.pump(const Duration(milliseconds: 50));
expect(state.renderTerminal.isCursorBlinkVisible, isTrue);

await tester.pumpWidget(const SizedBox());
focusNode.dispose();
});

testWidgets('TerminalView cursorBlink null follows terminal mode', (
tester,
) async {
final terminal = Terminal();
final focusNode = FocusNode();

await tester.pumpWidget(
MaterialApp(
home: TerminalView(
terminal,
focusNode: focusNode,
autofocus: true,
cursorBlinkInterval: const Duration(milliseconds: 100),
),
),
);
await tester.pump();

final state = tester.state<TerminalViewState>(find.byType(TerminalView));
await tester.pump(const Duration(milliseconds: 100));
expect(state.renderTerminal.isCursorBlinkVisible, isTrue);

terminal.write('\x1b[1 q');
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(state.renderTerminal.isCursorBlinkVisible, isFalse);

await tester.pumpWidget(const SizedBox());
focusNode.dispose();
});

testWidgets('TerminalView cursorBlink true overrides terminal mode', (
tester,
) async {
final terminal = Terminal();
final focusNode = FocusNode();

await tester.pumpWidget(
MaterialApp(
home: TerminalView(
terminal,
focusNode: focusNode,
autofocus: true,
cursorBlink: true,
cursorBlinkInterval: const Duration(milliseconds: 100),
),
),
);
await tester.pump();

final state = tester.state<TerminalViewState>(find.byType(TerminalView));
await tester.pump(const Duration(milliseconds: 100));
expect(state.renderTerminal.isCursorBlinkVisible, isFalse);

await tester.pumpWidget(const SizedBox());
focusNode.dispose();
});

testWidgets('TerminalView cursorBlink false overrides terminal mode', (
tester,
) async {
final terminal = Terminal()..setCursorBlinkMode(true);
final focusNode = FocusNode();

await tester.pumpWidget(
MaterialApp(
home: TerminalView(
terminal,
focusNode: focusNode,
autofocus: true,
cursorBlink: false,
cursorBlinkInterval: const Duration(milliseconds: 100),
),
),
);
await tester.pump();

final state = tester.state<TerminalViewState>(find.byType(TerminalView));
await tester.pump(const Duration(milliseconds: 100));
expect(state.renderTerminal.isCursorBlinkVisible, isTrue);

await tester.pumpWidget(const SizedBox());
focusNode.dispose();
});

testWidgets('TerminalView cursor blink timeout can be disabled', (
tester,
) async {
final terminal = Terminal()..setCursorBlinkMode(true);
final focusNode = FocusNode();

await tester.pumpWidget(
MaterialApp(
home: TerminalView(
terminal,
focusNode: focusNode,
autofocus: true,
cursorBlinkInterval: const Duration(milliseconds: 100),
cursorBlinkTimeout: null,
),
),
);
await tester.pump();

final state = tester.state<TerminalViewState>(find.byType(TerminalView));
await tester.pump(const Duration(seconds: 5));
final wasVisible = state.renderTerminal.isCursorBlinkVisible;

await tester.pump(const Duration(milliseconds: 100));
expect(state.renderTerminal.isCursorBlinkVisible, isNot(wasVisible));

await tester.pumpWidget(const SizedBox());
focusNode.dispose();
});

testWidgets('TerminalView activates OSC 8 hyperlinks with modifier', (
tester,
) async {
Expand Down