diff --git a/lib/src/terminal_view.dart b/lib/src/terminal_view.dart index e5c13f99..8bd252c8 100644 --- a/lib/src/terminal_view.dart +++ b/lib/src/terminal_view.dart @@ -118,6 +118,9 @@ 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, @@ -125,7 +128,8 @@ class TerminalView extends StatefulWidget { 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; @@ -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; @@ -447,6 +467,9 @@ class TerminalViewState extends State { theme: widget.theme, focusNode: _focusNode, cursorType: widget.cursorType, + cursorBlink: widget.cursorBlink, + cursorBlinkInterval: widget.cursorBlinkInterval, + cursorBlinkTimeout: widget.cursorBlinkTimeout, alwaysShowCursor: widget.alwaysShowCursor, activeHyperlinkId: _activeHyperlinkId, onEditableRect: _onEditableRect, @@ -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, @@ -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; @@ -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, @@ -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 diff --git a/lib/src/ui/render.dart b/lib/src/ui/render.dart index 34fa8cfe..c49b0e83 100644 --- a/lib/src/ui/render.dart +++ b/lib/src/ui/render.dart @@ -37,11 +37,16 @@ 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, @@ -49,6 +54,9 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin { _backgroundOpacity = backgroundOpacity, _focusNode = focusNode, _cursorType = cursorType, + _cursorBlink = cursorBlink, + _cursorBlinkInterval = cursorBlinkInterval, + _cursorBlinkTimeoutDuration = cursorBlinkTimeout, _alwaysShowCursor = alwaysShowCursor, _activeHyperlinkId = activeHyperlinkId, _onEditableRect = onEditableRect, @@ -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; @@ -180,7 +214,7 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin { Timer? _cursorBlinkTimer; - Timer? _cursorBlinkTimeout; + Timer? _cursorBlinkTimeoutTimer; Timer? _textBlinkTimer; @@ -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; @@ -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 && @@ -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; } @@ -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; } diff --git a/test/src/terminal_view_test.dart b/test/src/terminal_view_test.dart index 3e08b7f5..3e0b3e85 100644 --- a/test/src/terminal_view_test.dart +++ b/test/src/terminal_view_test.dart @@ -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(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(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(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(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(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 {