Files
clide/lib/src/terminal/src/terminal_view.dart
T
jpmschweitzerandClaude ae8d774529
test / unit + widget + golden + a11y (push) Failing after 31s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m0s
forward mouse wheel as xterm wheel escapes when TUI asks for it (T-74)
Previously every PointerScrollEvent fell straight to PgUp/PgDown
keyInput as a "universal scroll" workaround. That kept plain shells
scrolling but starved vim mouse=a / htop / less of the wheel events
they expect.

Now `_onPointerSignal` checks `terminal.mouseMode.reportScroll`
first (the cascade of mouse handlers cares about this flag). If
the inner program declared ?1000h / ?1002h / ?1003h (optionally
+?1006h SGR), the wheel forwards as `wheelUp` / `wheelDown` button
events through the existing `renderTerminal.mouseEvent` path. Plain
shells stay on PgUp/PgDown because their mouse mode is `none` —
the existing test for that path keeps passing unchanged.

Click + drag forwarding through the gesture handler was already
wired (renderTerminal.mouseEvent for taps), so T-74's acceptance
list is met by this scroll fix alone.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 13:51:55 +02:00

498 lines
15 KiB
Dart

// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart';
import 'package:clide/src/terminal/src/core/input/keys.dart';
import 'package:clide/src/terminal/src/core/mouse/button.dart';
import 'package:clide/src/terminal/src/core/mouse/button_state.dart';
import 'package:clide/src/terminal/src/terminal.dart';
import 'package:clide/src/terminal/src/ui/controller.dart';
import 'package:clide/src/terminal/src/ui/cursor_type.dart';
import 'package:clide/src/terminal/src/ui/custom_text_edit.dart';
import 'package:clide/src/terminal/src/ui/gesture/gesture_handler.dart';
import 'package:clide/src/terminal/src/ui/input_map.dart';
import 'package:clide/src/terminal/src/ui/keyboard_listener.dart';
import 'package:clide/src/terminal/src/ui/render.dart';
import 'package:clide/src/terminal/src/ui/shortcut/actions.dart';
import 'package:clide/src/terminal/src/ui/shortcut/shortcuts.dart';
import 'package:clide/src/terminal/src/ui/terminal_text_style.dart';
import 'package:clide/src/terminal/src/ui/terminal_theme.dart';
import 'package:clide/src/terminal/src/ui/themes.dart';
class TerminalView extends StatefulWidget {
const TerminalView(
this.terminal, {
super.key,
this.controller,
this.theme = TerminalThemes.defaultTheme,
this.textStyle = const TerminalStyle(),
this.textScaler,
this.padding,
this.autoResize = true,
this.backgroundOpacity = 1,
this.focusNode,
this.autofocus = false,
this.onTapUp,
this.onSecondaryTapDown,
this.onSecondaryTapUp,
this.mouseCursor = SystemMouseCursors.text,
this.keyboardType = TextInputType.emailAddress,
this.keyboardAppearance = Brightness.dark,
this.cursorType = TerminalCursorType.block,
this.alwaysShowCursor = false,
this.deleteDetection = false,
this.shortcuts,
this.onKeyEvent,
this.readOnly = false,
this.hardwareKeyboardOnly = false,
});
/// The underlying terminal that this widget renders.
final Terminal terminal;
final TerminalController? controller;
/// The theme to use for this terminal.
final TerminalTheme theme;
/// The style to use for painting characters.
final TerminalStyle textStyle;
final TextScaler? textScaler;
/// Padding around the terminal viewport.
final EdgeInsets? padding;
/// Should this widget automatically notify the underlying terminal when its
/// size changes. [true] by default.
final bool autoResize;
/// Opacity of the terminal background. Set to 0 to make the terminal
/// background transparent.
final double backgroundOpacity;
/// An optional focus node to use as the focus node for this widget.
final FocusNode? focusNode;
/// True if this widget will be selected as the initial focus when no other
/// node in its scope is currently focused.
final bool autofocus;
/// Callback for when the user taps on the terminal.
final void Function(TapUpDetails, CellOffset)? onTapUp;
/// Function called when the user taps on the terminal with a secondary
/// button.
final void Function(TapDownDetails, CellOffset)? onSecondaryTapDown;
/// Function called when the user stops holding down a secondary button.
final void Function(TapUpDetails, CellOffset)? onSecondaryTapUp;
/// The mouse cursor for mouse pointers that are hovering over the terminal.
/// [SystemMouseCursors.text] by default.
final MouseCursor mouseCursor;
/// The type of information for which to optimize the text input control.
/// [TextInputType.emailAddress] by default.
final TextInputType keyboardType;
/// The appearance of the keyboard. [Brightness.dark] by default.
///
/// This setting is only honored on iOS devices.
final Brightness keyboardAppearance;
/// The type of cursor to use. [TerminalCursorType.block] by default.
final TerminalCursorType cursorType;
/// Whether to always show the cursor. This is useful for debugging.
/// [false] by default.
final bool alwaysShowCursor;
/// Workaround to detect delete key for platforms and IMEs that does not
/// emit hardware delete event. Prefered on mobile platforms. [false] by
/// default.
final bool deleteDetection;
/// Shortcuts for this terminal. This has higher priority than input handler
/// of the terminal If not provided, [defaultTerminalShortcuts] will be used.
final Map<ShortcutActivator, Intent>? shortcuts;
/// Keyboard event handler of the terminal. This has higher priority than
/// [shortcuts] and input handler of the terminal.
final FocusOnKeyEventCallback? onKeyEvent;
/// True if no input should send to the terminal.
final bool readOnly;
/// True if only hardware keyboard events should be used as input. This will
/// also prevent any on-screen keyboard to be shown.
final bool hardwareKeyboardOnly;
@override
State<TerminalView> createState() => TerminalViewState();
}
class TerminalViewState extends State<TerminalView> {
late FocusNode _focusNode;
late final ShortcutManager _shortcutManager;
final _customTextEditKey = GlobalKey<CustomTextEditState>();
final _viewportKey = GlobalKey();
String? _composingText;
late TerminalController _controller;
RenderTerminal get renderTerminal => _viewportKey.currentContext!.findRenderObject() as RenderTerminal;
void _onPointerSignal(PointerSignalEvent event) {
if (event is! PointerScrollEvent) return;
final lh = renderTerminal.lineHeight;
if (lh <= 0) return;
final lines = (event.scrollDelta.dy / lh).round().clamp(-5, 5);
if (lines == 0) return;
final button = lines > 0 ? TerminalMouseButton.wheelDown : TerminalMouseButton.wheelUp;
final count = lines.abs();
// If the inner program has declared a mouse mode that reports
// scroll (?1000h / ?1002h / ?1003h / +?1006h for SGR), forward
// proper xterm wheel-button escapes so the TUI can react (vim
// mouse=a scroll, less line-by-line, htop highlight). Otherwise
// fall back to PgUp/PgDown so plain shells still scroll (T-74).
if (widget.terminal.mouseMode.reportScroll) {
for (var i = 0; i < count; i++) {
renderTerminal.mouseEvent(button, TerminalMouseButtonState.down, event.localPosition);
}
return;
}
final key = lines < 0 ? TerminalKey.pageUp : TerminalKey.pageDown;
for (var i = 0; i < count; i++) {
widget.terminal.keyInput(key);
}
}
@override
void initState() {
_focusNode = widget.focusNode ?? FocusNode();
_controller = widget.controller ?? TerminalController();
_shortcutManager = ShortcutManager(
shortcuts: widget.shortcuts ?? defaultTerminalShortcuts,
);
super.initState();
}
@override
void didUpdateWidget(TerminalView oldWidget) {
if (oldWidget.focusNode != widget.focusNode) {
if (oldWidget.focusNode == null) {
_focusNode.dispose();
}
_focusNode = widget.focusNode ?? FocusNode();
}
if (oldWidget.controller != widget.controller) {
if (oldWidget.controller == null) {
_controller.dispose();
}
_controller = widget.controller ?? TerminalController();
}
_shortcutManager.shortcuts = widget.shortcuts ?? defaultTerminalShortcuts;
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
if (widget.focusNode == null) {
_focusNode.dispose();
}
if (widget.controller == null) {
_controller.dispose();
}
_shortcutManager.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
Widget child = _TerminalView(
key: _viewportKey,
terminal: widget.terminal,
controller: _controller,
offset: ViewportOffset.zero(),
padding: MediaQuery.of(context).padding,
autoResize: widget.autoResize,
textStyle: widget.textStyle,
textScaler: widget.textScaler ?? MediaQuery.textScalerOf(context),
theme: widget.theme,
focusNode: _focusNode,
cursorType: widget.cursorType,
alwaysShowCursor: widget.alwaysShowCursor,
onEditableRect: _onEditableRect,
composingText: _composingText,
);
if (!widget.hardwareKeyboardOnly) {
child = CustomTextEdit(
key: _customTextEditKey,
focusNode: _focusNode,
autofocus: widget.autofocus,
inputType: widget.keyboardType,
keyboardAppearance: widget.keyboardAppearance,
deleteDetection: widget.deleteDetection,
onInsert: _onInsert,
onDelete: () => widget.terminal.keyInput(TerminalKey.backspace),
onComposing: _onComposing,
onAction: (action) {
if (action == TextInputAction.done) {
widget.terminal.keyInput(TerminalKey.enter);
}
},
onKeyEvent: _handleKeyEvent,
readOnly: widget.readOnly,
child: child,
);
} else if (!widget.readOnly) {
// Only listen for key input from a hardware keyboard.
child = CustomKeyboardListener(
focusNode: _focusNode,
autofocus: widget.autofocus,
onInsert: _onInsert,
onComposing: _onComposing,
onKeyEvent: _handleKeyEvent,
child: child,
);
}
child = TerminalActions(
terminal: widget.terminal,
controller: _controller,
child: child,
);
child = TerminalGestureHandler(
terminalView: this,
terminalController: _controller,
onSingleTapUp: _onTapUp,
onTapDown: _onTapDown,
onSecondaryTapDown: widget.onSecondaryTapDown != null ? _onSecondaryTapDown : null,
onSecondaryTapUp: widget.onSecondaryTapUp != null ? _onSecondaryTapUp : null,
readOnly: widget.readOnly,
child: child,
);
child = MouseRegion(
cursor: widget.mouseCursor,
child: child,
);
child = Container(
color: widget.theme.background.withValues(alpha: widget.backgroundOpacity),
padding: widget.padding,
child: child,
);
return Listener(
onPointerSignal: _onPointerSignal,
child: child,
);
}
void requestKeyboard() {
_customTextEditKey.currentState?.requestKeyboard();
}
void closeKeyboard() {
_customTextEditKey.currentState?.closeKeyboard();
}
Rect get cursorRect {
return renderTerminal.cursorOffset & renderTerminal.cellSize;
}
Rect get globalCursorRect {
return renderTerminal.localToGlobal(renderTerminal.cursorOffset) & renderTerminal.cellSize;
}
void _onTapUp(TapUpDetails details) {
final offset = renderTerminal.getCellOffset(details.localPosition);
widget.onTapUp?.call(details, offset);
}
void _onTapDown(TapDownDetails _) {
if (_controller.selection != null) {
_controller.clearSelection();
} else {
if (!widget.hardwareKeyboardOnly) {
_customTextEditKey.currentState?.requestKeyboard();
} else {
_focusNode.requestFocus();
}
}
}
void _onSecondaryTapDown(TapDownDetails details) {
final offset = renderTerminal.getCellOffset(details.localPosition);
widget.onSecondaryTapDown?.call(details, offset);
}
void _onSecondaryTapUp(TapUpDetails details) {
final offset = renderTerminal.getCellOffset(details.localPosition);
widget.onSecondaryTapUp?.call(details, offset);
}
bool get hasInputConnection {
return _customTextEditKey.currentState?.hasInputConnection == true;
}
void _onInsert(String text) {
final key = charToTerminalKey(text.trim());
// On mobile platforms there is no guarantee that virtual keyboard will
// generate hardware key events. So we need first try to send the key
// as a hardware key event. If it fails, then we send it as a text input.
final consumed = key == null ? false : widget.terminal.keyInput(key);
if (!consumed) {
widget.terminal.textInput(text);
}
}
void _onComposing(String? text) {
setState(() => _composingText = text);
}
KeyEventResult _handleKeyEvent(FocusNode focusNode, KeyEvent event) {
final resultOverride = widget.onKeyEvent?.call(focusNode, event);
if (resultOverride != null && resultOverride != KeyEventResult.ignored) {
return resultOverride;
}
// ShortcutManager.handleKeypress is @protected — it's only public
// for the Shortcuts widget to call internally. We own our own
// ShortcutManager because the terminal has its own keybinding
// surface (defaultTerminalShortcuts: Ctrl+C → kill, Tab handoff
// rules, etc.) that needs to fire BEFORE the app's Shortcuts
// ancestor. Wrapping in a Shortcuts widget would invert that.
// T-107 approved leaving this suppression with an inline reason.
// ignore: invalid_use_of_protected_member
final shortcutResult = _shortcutManager.handleKeypress(
focusNode.context!,
event,
);
if (shortcutResult != KeyEventResult.ignored) {
return shortcutResult;
}
if (event is KeyUpEvent) {
return KeyEventResult.ignored;
}
final key = keyToTerminalKey(event.logicalKey);
if (key == null) {
return KeyEventResult.ignored;
}
final handled = widget.terminal.keyInput(
key,
ctrl: HardwareKeyboard.instance.isControlPressed,
alt: HardwareKeyboard.instance.isAltPressed,
shift: HardwareKeyboard.instance.isShiftPressed,
);
return handled ? KeyEventResult.handled : KeyEventResult.ignored;
}
void _onEditableRect(Rect rect, Rect caretRect) {
_customTextEditKey.currentState?.setEditableRect(rect, caretRect);
}
}
class _TerminalView extends LeafRenderObjectWidget {
const _TerminalView({
super.key,
required this.terminal,
required this.controller,
required this.offset,
required this.padding,
required this.autoResize,
required this.textStyle,
required this.textScaler,
required this.theme,
required this.focusNode,
required this.cursorType,
required this.alwaysShowCursor,
this.onEditableRect,
this.composingText,
});
final Terminal terminal;
final TerminalController controller;
final ViewportOffset offset;
final EdgeInsets padding;
final bool autoResize;
final TerminalStyle textStyle;
final TextScaler textScaler;
final TerminalTheme theme;
final FocusNode focusNode;
final TerminalCursorType cursorType;
final bool alwaysShowCursor;
final EditableRectCallback? onEditableRect;
final String? composingText;
@override
RenderTerminal createRenderObject(BuildContext context) {
return RenderTerminal(
terminal: terminal,
controller: controller,
offset: offset,
padding: padding,
autoResize: autoResize,
textStyle: textStyle,
textScaler: textScaler,
theme: theme,
focusNode: focusNode,
cursorType: cursorType,
alwaysShowCursor: alwaysShowCursor,
onEditableRect: onEditableRect,
composingText: composingText,
);
}
@override
void updateRenderObject(BuildContext context, RenderTerminal renderObject) {
renderObject
..terminal = terminal
..controller = controller
..offset = offset
..padding = padding
..autoResize = autoResize
..textStyle = textStyle
..textScaler = textScaler
..theme = theme
..focusNode = focusNode
..cursorType = cursorType
..alwaysShowCursor = alwaysShowCursor
..onEditableRect = onEditableRect
..composingText = composingText;
}
}