diff --git a/lib/src/core/buffer/range_shift.dart b/lib/src/core/buffer/range_shift.dart new file mode 100644 index 00000000..398fb558 --- /dev/null +++ b/lib/src/core/buffer/range_shift.dart @@ -0,0 +1,144 @@ +import 'package:xterm/src/core/buffer/cell_offset.dart'; +import 'package:xterm/src/core/buffer/range.dart'; +import 'package:xterm/src/core/buffer/segment.dart'; + +/// A range of cells in the buffer that represents a shift selection. +/// This range is used when the user holds the shift key while selecting text. +class BufferRangeShift extends BufferRange { + BufferRangeShift(super.begin, super.end); + + @override + bool get isNormalized => true; + + @override + bool get isCollapsed => begin == end; + + @override + BufferRange get normalized { + if (isNormalized) { + return this; + } + return BufferRangeShift(end, begin); + } + + @override + List toSegments() { + final isReversed = begin.y > end.y || (begin.y == end.y && begin.x > end.x); + final segmentStart = isReversed ? end : begin; + final segmentEnd = isReversed ? begin : end; + + if (segmentStart.y == segmentEnd.y) { + return [ + BufferSegment( + this, + segmentStart.y, + segmentStart.x, + segmentEnd.x, + ), + ]; + } + + final segments = []; + final startLine = segmentStart.y; + final endLine = segmentEnd.y; + + segments.add(BufferSegment( + this, + startLine, + segmentStart.x, + null, + )); + + for (var line = startLine + 1; line < endLine; line++) { + segments.add(BufferSegment( + this, + line, + 0, + null, + )); + } + + segments.add(BufferSegment( + this, + endLine, + 0, + segmentEnd.x, + )); + + return segments; + } + + @override + bool contains(CellOffset offset) { + final minY = begin.y < end.y ? begin.y : end.y; + final maxY = begin.y > end.y ? begin.y : end.y; + + if (offset.y < minY || offset.y > maxY) { + return false; + } + + if (begin.y == end.y) { + final minX = begin.x < end.x ? begin.x : end.x; + final maxX = begin.x > end.x ? begin.x : end.x; + return offset.x >= minX && offset.x <= maxX; + } + + if (offset.y == begin.y) { + if (begin.y < end.y) { + return offset.x >= begin.x; + } else { + return offset.x <= begin.x; + } + } + + if (offset.y == end.y) { + if (begin.y < end.y) { + return offset.x <= end.x; + } else { + return offset.x >= end.x; + } + } + + return true; + } + + @override + BufferRange merge(BufferRange other) { + if (other is! BufferRangeShift) { + final normalized = this.normalized; + final otherNormalized = other.normalized; + + final newBegin = normalized.begin.isBefore(otherNormalized.begin) + ? normalized.begin + : otherNormalized.begin; + + final newEnd = normalized.end.isAfter(otherNormalized.end) + ? normalized.end + : otherNormalized.end; + + return BufferRangeShift(newBegin, newEnd); + } + + final normalized = this.normalized; + final otherNormalized = other.normalized; + + final newBegin = normalized.begin.isBefore(otherNormalized.begin) + ? normalized.begin + : otherNormalized.begin; + + final newEnd = normalized.end.isAfter(otherNormalized.end) + ? normalized.end + : otherNormalized.end; + + return BufferRangeShift(newBegin, newEnd); + } + + @override + BufferRange extend(CellOffset newEnd) { + if (begin.y < newEnd.y || (begin.y == newEnd.y && begin.x < newEnd.x)) { + return BufferRangeShift(begin, newEnd); + } else { + return BufferRangeShift(newEnd, begin); + } + } +} diff --git a/lib/src/ui/controller.dart b/lib/src/ui/controller.dart index 1e107c2d..bf272099 100644 --- a/lib/src/ui/controller.dart +++ b/lib/src/ui/controller.dart @@ -6,6 +6,7 @@ import 'package:xterm/src/core/buffer/line.dart'; import 'package:xterm/src/core/buffer/range.dart'; import 'package:xterm/src/core/buffer/range_block.dart'; import 'package:xterm/src/core/buffer/range_line.dart'; +import 'package:xterm/src/core/buffer/range_shift.dart'; import 'package:xterm/src/ui/pointer_input.dart'; import 'package:xterm/src/ui/selection_mode.dart'; @@ -73,6 +74,8 @@ class TerminalController with ChangeNotifier { return BufferRangeLine(begin, end); case SelectionMode.block: return BufferRangeBlock(begin, end); + case SelectionMode.shift: + return BufferRangeShift(begin, end); } } diff --git a/lib/src/ui/gesture/gesture_handler.dart b/lib/src/ui/gesture/gesture_handler.dart index 2f6d7b69..d90b15e2 100644 --- a/lib/src/ui/gesture/gesture_handler.dart +++ b/lib/src/ui/gesture/gesture_handler.dart @@ -1,5 +1,6 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; import 'package:xterm/src/core/mouse/button.dart'; import 'package:xterm/src/core/mouse/button_state.dart'; import 'package:xterm/src/terminal_view.dart'; @@ -7,6 +8,9 @@ import 'package:xterm/src/ui/controller.dart'; import 'package:xterm/src/ui/gesture/gesture_detector.dart'; import 'package:xterm/src/ui/pointer_input.dart'; import 'package:xterm/src/ui/render.dart'; +import 'package:xterm/src/ui/selection_mode.dart'; +import 'package:xterm/src/core/buffer/line.dart'; +import 'package:xterm/src/core/buffer/cell_offset.dart'; class TerminalGestureHandler extends StatefulWidget { const TerminalGestureHandler({ @@ -59,6 +63,37 @@ class _TerminalGestureHandlerState extends State { LongPressStartDetails? _lastLongPressStartDetails; + bool _isShiftPressed = false; + + @override + void initState() { + super.initState(); + HardwareKeyboard.instance.addHandler(_handleKeyEvent); + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_handleKeyEvent); + super.dispose(); + } + + bool _handleKeyEvent(KeyEvent event) { + if (event is KeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.shiftLeft || + event.logicalKey == LogicalKeyboardKey.shiftRight) { + _isShiftPressed = true; + return false; + } + } else if (event is KeyUpEvent) { + if (event.logicalKey == LogicalKeyboardKey.shiftLeft || + event.logicalKey == LogicalKeyboardKey.shiftRight) { + _isShiftPressed = false; + return false; + } + } + return false; + } + @override Widget build(BuildContext context) { return TerminalGestureDetector( @@ -72,7 +107,6 @@ class _TerminalGestureHandlerState extends State { onTertiaryTapUp: onSecondaryTapUp, onLongPressStart: onLongPressStart, onLongPressMoveUpdate: onLongPressMoveUpdate, - // onLongPressUp: onLongPressUp, onDragStart: onDragStart, onDragUpdate: onDragUpdate, onDoubleTapDown: onDoubleTapDown, @@ -110,7 +144,6 @@ class _TerminalGestureHandlerState extends State { TerminalMouseButton button, { bool forceCallback = false, }) { - // Check if the terminal should and can handle the tap up event. var handled = false; if (_shouldSendTapEvent) { handled = renderTerminal.mouseEvent( @@ -126,14 +159,62 @@ class _TerminalGestureHandlerState extends State { } void onTapDown(TapDownDetails details) { - // onTapDown is special, as it will always call the supplied callback. - // The TerminalView depends on it to bring the terminal into focus. - _tapDown( - widget.onTapDown, - details, - TerminalMouseButton.left, - forceCallback: true, - ); + final position = renderTerminal.getCellOffset(details.localPosition); + if (position == null) return; + + if (_isShiftPressed) { + final currentSelection = widget.terminalController.selection; + final cursorX = terminalView.widget.terminal.buffer.cursorX; + final cursorY = terminalView.widget.terminal.buffer.cursorY; + + final anchorPosition = currentSelection != null + ? currentSelection.begin + : CellOffset(cursorX, cursorY); + + final anchor = terminalView.widget.terminal.buffer.createAnchorFromOffset(anchorPosition); + final positionAnchor = terminalView.widget.terminal.buffer.createAnchorFromOffset(position); + + if (currentSelection != null) { + final isReversed = currentSelection.begin.y > currentSelection.end.y || + (currentSelection.begin.y == currentSelection.end.y && + currentSelection.begin.x > currentSelection.end.x); + + if (isReversed) { + widget.terminalController.setSelection( + positionAnchor, + anchor, + mode: SelectionMode.shift, + ); + } else { + widget.terminalController.setSelection( + anchor, + positionAnchor, + mode: SelectionMode.shift, + ); + } + } else { + if (anchorPosition.y < position.y || (anchorPosition.y == position.y && anchorPosition.x < position.x)) { + widget.terminalController.setSelection( + anchor, + positionAnchor, + mode: SelectionMode.shift, + ); + } else { + widget.terminalController.setSelection( + positionAnchor, + anchor, + mode: SelectionMode.shift, + ); + } + } + } else { + final anchor = terminalView.widget.terminal.buffer.createAnchorFromOffset(position); + widget.terminalController.setSelection( + anchor, + anchor, + mode: SelectionMode.line, + ); + } } void onSingleTapUp(TapUpDetails details) { @@ -157,35 +238,166 @@ class _TerminalGestureHandlerState extends State { } void onDoubleTapDown(TapDownDetails details) { + final position = renderTerminal.getCellOffset(details.localPosition); + if (position == null) return; + + // Use the word selection functionality from RenderTerminal renderTerminal.selectWord(details.localPosition); } void onLongPressStart(LongPressStartDetails details) { _lastLongPressStartDetails = details; - renderTerminal.selectWord(details.localPosition); - } + final position = renderTerminal.getCellOffset(details.localPosition); + if (position == null) return; - void onLongPressMoveUpdate(LongPressMoveUpdateDetails details) { - renderTerminal.selectWord( - _lastLongPressStartDetails!.localPosition, - details.localPosition, + final anchor = terminalView.widget.terminal.buffer.createAnchorFromOffset(position); + + widget.terminalController.setSelection( + anchor, + anchor, + mode: SelectionMode.block, ); } - // void onLongPressUp() {} + void onLongPressMoveUpdate(LongPressMoveUpdateDetails details) { + final position = renderTerminal.getCellOffset(details.localPosition); + if (position == null) return; + + final selection = widget.terminalController.selection; + if (selection == null) return; + + final anchor = terminalView.widget.terminal.buffer.createAnchorFromOffset(position); + final beginAnchor = selection.begin is CellAnchor + ? selection.begin as CellAnchor + : terminalView.widget.terminal.buffer.createAnchorFromOffset(selection.begin); + + // Maintain the same selection direction as the current selection + final isReversed = selection.begin.y > selection.end.y || + (selection.begin.y == selection.end.y && + selection.begin.x > selection.end.x); + + if (isReversed) { + widget.terminalController.setSelection( + anchor, + beginAnchor, + mode: SelectionMode.block, + ); + } else { + widget.terminalController.setSelection( + beginAnchor, + anchor, + mode: SelectionMode.block, + ); + } + } void onDragStart(DragStartDetails details) { _lastDragStartDetails = details; - - details.kind == PointerDeviceKind.mouse - ? renderTerminal.selectCharacters(details.localPosition) - : renderTerminal.selectWord(details.localPosition); + final position = renderTerminal.getCellOffset(details.localPosition); + if (position == null) return; + + if (_isShiftPressed) { + // Get the current selection if it exists + final currentSelection = widget.terminalController.selection; + final cursorX = terminalView.widget.terminal.buffer.cursorX; + final cursorY = terminalView.widget.terminal.buffer.cursorY; + + // If there's an existing selection, use its start point as the anchor + final anchorPosition = currentSelection != null + ? currentSelection.begin + : CellOffset(cursorX, cursorY); + + final anchor = terminalView.widget.terminal.buffer.createAnchorFromOffset(anchorPosition); + final positionAnchor = terminalView.widget.terminal.buffer.createAnchorFromOffset(position); + + // Always maintain the same selection direction as the current selection + if (currentSelection != null) { + final isReversed = currentSelection.begin.y > currentSelection.end.y || + (currentSelection.begin.y == currentSelection.end.y && + currentSelection.begin.x > currentSelection.end.x); + + if (isReversed) { + widget.terminalController.setSelection( + positionAnchor, + anchor, + mode: SelectionMode.shift, + ); + } else { + widget.terminalController.setSelection( + anchor, + positionAnchor, + mode: SelectionMode.shift, + ); + } + } else { + // For new selections, ensure begin is before end + if (anchorPosition.y < position.y || (anchorPosition.y == position.y && anchorPosition.x < position.x)) { + widget.terminalController.setSelection( + anchor, + positionAnchor, + mode: SelectionMode.shift, + ); + } else { + widget.terminalController.setSelection( + positionAnchor, + anchor, + mode: SelectionMode.shift, + ); + } + } + } else { + // Normal selection + final anchor = terminalView.widget.terminal.buffer.createAnchorFromOffset(position); + widget.terminalController.setSelection( + anchor, + anchor, + mode: SelectionMode.line, + ); + } } void onDragUpdate(DragUpdateDetails details) { - renderTerminal.selectCharacters( - _lastDragStartDetails!.localPosition, - details.localPosition, - ); + final position = renderTerminal.getCellOffset(details.localPosition); + if (position == null) return; + + final selection = widget.terminalController.selection; + if (selection == null) return; + + if (_isShiftPressed) { + // Get the current selection's start point as the anchor + final anchorPosition = selection.begin; + final anchor = terminalView.widget.terminal.buffer.createAnchorFromOffset(anchorPosition); + final positionAnchor = terminalView.widget.terminal.buffer.createAnchorFromOffset(position); + + // Maintain the same selection direction as the current selection + final isReversed = selection.begin.y > selection.end.y || + (selection.begin.y == selection.end.y && + selection.begin.x > selection.end.x); + + if (isReversed) { + widget.terminalController.setSelection( + positionAnchor, + anchor, + mode: SelectionMode.shift, + ); + } else { + widget.terminalController.setSelection( + anchor, + positionAnchor, + mode: SelectionMode.shift, + ); + } + } else { + // Normal selection + final anchor = terminalView.widget.terminal.buffer.createAnchorFromOffset(position); + final beginAnchor = selection.begin is CellAnchor + ? selection.begin as CellAnchor + : terminalView.widget.terminal.buffer.createAnchorFromOffset(selection.begin); + widget.terminalController.setSelection( + beginAnchor, + anchor, + mode: SelectionMode.line, + ); + } } } diff --git a/lib/src/ui/selection_mode.dart b/lib/src/ui/selection_mode.dart index aa3a5044..2e406389 100644 --- a/lib/src/ui/selection_mode.dart +++ b/lib/src/ui/selection_mode.dart @@ -2,4 +2,6 @@ enum SelectionMode { line, block, + + shift, } diff --git a/pubspec.yaml b/pubspec.yaml index 945265ef..2c9d15ea 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,7 +19,6 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - test: ^1.6.5 lints: ^3.0.0 dart_code_metrics: ^5.0.0 mockito: ^5.3.1