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
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>
498 lines
15 KiB
Dart
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;
|
|
}
|
|
}
|