mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-06 14:23:19 +08:00
feat: native优化终端选择&快捷命令
This commit is contained in:
@@ -121,7 +121,11 @@ class SshTerminalController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void clearTerminal() {
|
void clearTerminal() {
|
||||||
terminal.buffer.clear();
|
if (_session == null) {
|
||||||
|
terminal.write('\x1b[H\x1b[2J\x1b[3J');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeInput('\x0c');
|
||||||
}
|
}
|
||||||
|
|
||||||
void _writeTerminalOutput(String text) {
|
void _writeTerminalOutput(String text) {
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ class _TerminalShellPageState extends ConsumerState<TerminalShellPage> {
|
|||||||
caseSensitive: false,
|
caseSensitive: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TerminalView 自己的内边距:RenderTerminal.getOffset()/globalToLocal() 返回的坐标
|
||||||
|
// 是相对 RenderTerminal 自身(padding 内侧)的,而选区菜单/手柄/放大镜是用 Positioned
|
||||||
|
// 摆在外层 Stack(padding 外侧)里的,两者之间正好差这一圈 padding,必须补上,
|
||||||
|
// 否则手柄会系统性地偏离选中字符的实际边界。
|
||||||
|
static const _terminalContentPadding = EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 6,
|
||||||
|
);
|
||||||
|
|
||||||
double _lastKeyboardHeight = 0;
|
double _lastKeyboardHeight = 0;
|
||||||
bool _showKeyPanel = false;
|
bool _showKeyPanel = false;
|
||||||
bool _allowTerminalFocus = true;
|
bool _allowTerminalFocus = true;
|
||||||
@@ -38,8 +47,15 @@ class _TerminalShellPageState extends ConsumerState<TerminalShellPage> {
|
|||||||
final FocusNode _searchFocusNode = FocusNode();
|
final FocusNode _searchFocusNode = FocusNode();
|
||||||
final Map<String, VoidCallback> _selectionListeners = {};
|
final Map<String, VoidCallback> _selectionListeners = {};
|
||||||
final Map<String, _TerminalSearchState> _searchStates = {};
|
final Map<String, _TerminalSearchState> _searchStates = {};
|
||||||
String? _touchSelectingSessionId;
|
// 当前实时触摸点(active session 的终端本地坐标),驱动放大镜显示;
|
||||||
final Map<String, Offset> _touchSelectionStartPositions = {};
|
// 长按选词/拖动扩展选区完全交给 xterm 内建手势处理,这里只用 Listener 被动跟踪坐标,
|
||||||
|
// 不参与手势竞技场,避免和 xterm 内部的 LongPressGestureRecognizer 竞争同一次触摸。
|
||||||
|
final ValueNotifier<Offset?> _liveTouchPosition = ValueNotifier(null);
|
||||||
|
Timer? _longPressMenuTimer;
|
||||||
|
String? _longPressMenuSessionId;
|
||||||
|
Offset? _longPressMenuOffset;
|
||||||
|
Offset? _longPressPointerDownOffset;
|
||||||
|
bool _ignoreNextTapUp = false;
|
||||||
bool _showSearchBar = false;
|
bool _showSearchBar = false;
|
||||||
Timer? _searchDebounce;
|
Timer? _searchDebounce;
|
||||||
|
|
||||||
@@ -223,6 +239,11 @@ class _TerminalShellPageState extends ConsumerState<TerminalShellPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleLinkTap(TerminalSession session, TapUpDetails details) {
|
void _handleLinkTap(TerminalSession session, TapUpDetails details) {
|
||||||
|
if (_ignoreNextTapUp) {
|
||||||
|
_ignoreNextTapUp = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_clearLongPressMenu();
|
||||||
final offset = _cellOffsetForGlobalPosition(
|
final offset = _cellOffsetForGlobalPosition(
|
||||||
session,
|
session,
|
||||||
details.globalPosition,
|
details.globalPosition,
|
||||||
@@ -237,35 +258,94 @@ class _TerminalShellPageState extends ConsumerState<TerminalShellPage> {
|
|||||||
_onTerminalTap(session, offset);
|
_onTerminalTap(session, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleSelectionStart(
|
// RenderTerminal.getOffset()/globalToLocal() 返回的是相对 RenderTerminal 自身(也就是
|
||||||
TerminalSession session,
|
// TerminalView padding 内侧)的坐标;而选区菜单/手柄/放大镜都是用 Positioned 摆在外层
|
||||||
LongPressStartDetails details,
|
// Stack(padding 外侧,和 TerminalView 同级)里的,所以要统一补上这一圈 padding,
|
||||||
) {
|
// 否则这些浮层会系统性地偏离选中字符的实际像素位置。
|
||||||
final render = _renderTerminalFor(session);
|
Offset _toOverlayLocal(Offset renderLocalOffset) {
|
||||||
if (render == null) return;
|
return renderLocalOffset + _terminalContentPadding.topLeft;
|
||||||
_touchSelectingSessionId = session.id;
|
|
||||||
final localPosition = render.globalToLocal(details.globalPosition);
|
|
||||||
_touchSelectionStartPositions[session.id] = localPosition;
|
|
||||||
render.selectWord(localPosition);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleSelectionUpdate(
|
Offset? _overlayLocalForGlobalPosition(
|
||||||
TerminalSession session,
|
TerminalSession session,
|
||||||
LongPressMoveUpdateDetails details,
|
Offset globalPosition,
|
||||||
) {
|
) {
|
||||||
if (_touchSelectingSessionId != session.id) return;
|
|
||||||
final render = _renderTerminalFor(session);
|
final render = _renderTerminalFor(session);
|
||||||
final from = _touchSelectionStartPositions[session.id];
|
if (render == null) return null;
|
||||||
if (render == null || from == null) return;
|
return _toOverlayLocal(render.globalToLocal(globalPosition));
|
||||||
final to = render.globalToLocal(details.globalPosition);
|
|
||||||
render.selectWord(from, to);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleSelectionEnd(TerminalSession session) {
|
// 被动跟踪原始触摸点,只用来驱动放大镜定位——不调用 selectWord/setSelection,
|
||||||
if (_touchSelectingSessionId == session.id) {
|
// 长按选词和拖动扩展选区完全由 xterm 内部的 TerminalGestureHandler 处理。
|
||||||
_touchSelectingSessionId = null;
|
void _trackTouchPosition(TerminalSession session, Offset globalPosition) {
|
||||||
|
final offset = _overlayLocalForGlobalPosition(session, globalPosition);
|
||||||
|
if (offset == null) return;
|
||||||
|
_liveTouchPosition.value = offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearTouchPosition() {
|
||||||
|
if (_liveTouchPosition.value != null) {
|
||||||
|
_liveTouchPosition.value = null;
|
||||||
}
|
}
|
||||||
_touchSelectionStartPositions.remove(session.id);
|
}
|
||||||
|
|
||||||
|
void _clearPendingLongPressMenu() {
|
||||||
|
_longPressMenuTimer?.cancel();
|
||||||
|
_longPressMenuTimer = null;
|
||||||
|
_longPressPointerDownOffset = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearLongPressMenu() {
|
||||||
|
_clearPendingLongPressMenu();
|
||||||
|
_ignoreNextTapUp = false;
|
||||||
|
if (_longPressMenuSessionId == null && _longPressMenuOffset == null) return;
|
||||||
|
setState(() {
|
||||||
|
_longPressMenuSessionId = null;
|
||||||
|
_longPressMenuOffset = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTerminalPointerDown(
|
||||||
|
TerminalSession session,
|
||||||
|
PointerDownEvent event,
|
||||||
|
) {
|
||||||
|
_trackTouchPosition(session, event.position);
|
||||||
|
final menuOffset = _overlayLocalForGlobalPosition(session, event.position);
|
||||||
|
if (menuOffset == null) return;
|
||||||
|
_longPressPointerDownOffset = menuOffset;
|
||||||
|
_longPressMenuTimer?.cancel();
|
||||||
|
_longPressMenuTimer = Timer(const Duration(milliseconds: 500), () {
|
||||||
|
if (!mounted || _manager.activeSession?.id != session.id) return;
|
||||||
|
_ignoreNextTapUp = true;
|
||||||
|
setState(() {
|
||||||
|
_longPressMenuSessionId = session.id;
|
||||||
|
_longPressMenuOffset = menuOffset;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTerminalPointerMove(
|
||||||
|
TerminalSession session,
|
||||||
|
PointerMoveEvent event,
|
||||||
|
) {
|
||||||
|
_trackTouchPosition(session, event.position);
|
||||||
|
final start = _longPressPointerDownOffset;
|
||||||
|
final current = _overlayLocalForGlobalPosition(session, event.position);
|
||||||
|
if (start == null || current == null) return;
|
||||||
|
if ((current - start).distanceSquared > 144) {
|
||||||
|
_ignoreNextTapUp = false;
|
||||||
|
_clearPendingLongPressMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTerminalPointerUp() {
|
||||||
|
_clearPendingLongPressMenu();
|
||||||
|
_clearTouchPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTerminalPointerCancel() {
|
||||||
|
_clearPendingLongPressMenu();
|
||||||
|
_clearTouchPosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showTerminalSnack(String message) {
|
void _showTerminalSnack(String message) {
|
||||||
@@ -298,16 +378,17 @@ class _TerminalShellPageState extends ConsumerState<TerminalShellPage> {
|
|||||||
required bool start,
|
required bool start,
|
||||||
}) {
|
}) {
|
||||||
final render = _renderTerminalFor(session);
|
final render = _renderTerminalFor(session);
|
||||||
final selection = _normalizedSelection(session);
|
final selection = session.viewController.selection;
|
||||||
if (render == null || selection == null) return null;
|
if (render == null || selection == null) return null;
|
||||||
|
// selection.end 已经是「选区之后第一个格子」的位置(exclusive),
|
||||||
|
// getOffset(end) 本身就是选区的右边界——不能再额外加一个 cellSize.width,
|
||||||
|
// 否则会多移一整格,导致终点手柄盖住选区后面的下一个字符。
|
||||||
|
//
|
||||||
|
// 手柄位置必须使用未 normalized 的 base/extent。用户把终点手柄反向拖过起点时,
|
||||||
|
// normalized.begin 会变成正在拖动的终点,导致视觉上的起点手柄跟着手指跑。
|
||||||
final cell = start ? selection.begin : selection.end;
|
final cell = start ? selection.begin : selection.end;
|
||||||
var offset = render.getOffset(cell);
|
final offset = render.getOffset(cell).translate(0, render.cellSize.height);
|
||||||
if (!start) {
|
return _toOverlayLocal(offset);
|
||||||
offset = offset.translate(render.cellSize.width, render.cellSize.height);
|
|
||||||
} else {
|
|
||||||
offset = offset.translate(0, render.cellSize.height);
|
|
||||||
}
|
|
||||||
return offset;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateSelectionHandle(
|
void _updateSelectionHandle(
|
||||||
@@ -316,7 +397,7 @@ class _TerminalShellPageState extends ConsumerState<TerminalShellPage> {
|
|||||||
required DragUpdateDetails details,
|
required DragUpdateDetails details,
|
||||||
}) {
|
}) {
|
||||||
final buffer = session.controller.terminal.buffer;
|
final buffer = session.controller.terminal.buffer;
|
||||||
final selection = _normalizedSelection(session);
|
final selection = session.viewController.selection;
|
||||||
final target = _cellOffsetForGlobalPosition(
|
final target = _cellOffsetForGlobalPosition(
|
||||||
session,
|
session,
|
||||||
details.globalPosition,
|
details.globalPosition,
|
||||||
@@ -330,11 +411,51 @@ class _TerminalShellPageState extends ConsumerState<TerminalShellPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleHandleDragStart(
|
||||||
|
TerminalSession session,
|
||||||
|
DragStartDetails details,
|
||||||
|
) {
|
||||||
|
_trackTouchPosition(session, details.globalPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleHandleDragUpdate(
|
||||||
|
TerminalSession session, {
|
||||||
|
required bool start,
|
||||||
|
required DragUpdateDetails details,
|
||||||
|
}) {
|
||||||
|
_updateSelectionHandle(session, start: start, details: details);
|
||||||
|
_trackTouchPosition(session, details.globalPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _normalizeSelectionDirection(TerminalSession session) {
|
||||||
|
final selection = session.viewController.selection;
|
||||||
|
if (selection == null || selection.isNormalized) return;
|
||||||
|
final normalized = selection.normalized;
|
||||||
|
final buffer = session.controller.terminal.buffer;
|
||||||
|
session.viewController.setSelection(
|
||||||
|
buffer.createAnchorFromOffset(normalized.begin),
|
||||||
|
buffer.createAnchorFromOffset(normalized.end),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleHandleDragEnd(TerminalSession session, DragEndDetails details) {
|
||||||
|
_normalizeSelectionDirection(session);
|
||||||
|
_clearTouchPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleHandleDragCancel(TerminalSession session) {
|
||||||
|
_normalizeSelectionDirection(session);
|
||||||
|
_clearTouchPosition();
|
||||||
|
}
|
||||||
|
|
||||||
Offset? _selectionMenuOffset(TerminalSession session) {
|
Offset? _selectionMenuOffset(TerminalSession session) {
|
||||||
final render = _renderTerminalFor(session);
|
final render = _renderTerminalFor(session);
|
||||||
final selection = _normalizedSelection(session);
|
final selection = _normalizedSelection(session);
|
||||||
if (render == null || selection == null) return null;
|
if (render == null) return null;
|
||||||
final beginOffset = render.getOffset(selection.begin);
|
final beginOffset = selection == null
|
||||||
|
? _longPressMenuOffset
|
||||||
|
: _toOverlayLocal(render.getOffset(selection.begin));
|
||||||
|
if (beginOffset == null) return null;
|
||||||
final boxSize = render.size;
|
final boxSize = render.size;
|
||||||
const menuWidth = 200.0;
|
const menuWidth = 200.0;
|
||||||
const menuHeight = 44.0;
|
const menuHeight = 44.0;
|
||||||
@@ -353,9 +474,13 @@ class _TerminalShellPageState extends ConsumerState<TerminalShellPage> {
|
|||||||
|
|
||||||
Future<void> _copySelection(TerminalSession session) async {
|
Future<void> _copySelection(TerminalSession session) async {
|
||||||
final text = _selectedText(session);
|
final text = _selectedText(session);
|
||||||
if (text == null) return;
|
if (text == null) {
|
||||||
|
_clearLongPressMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
await Clipboard.setData(ClipboardData(text: text));
|
await Clipboard.setData(ClipboardData(text: text));
|
||||||
session.viewController.clearSelection();
|
session.viewController.clearSelection();
|
||||||
|
_clearLongPressMenu();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final l = AppLocalizations.of(context);
|
final l = AppLocalizations.of(context);
|
||||||
_showTerminalSnack(l.tr('terminal.selectionCopied'));
|
_showTerminalSnack(l.tr('terminal.selectionCopied'));
|
||||||
@@ -364,20 +489,20 @@ class _TerminalShellPageState extends ConsumerState<TerminalShellPage> {
|
|||||||
Future<void> _pasteToTerminal(TerminalSession session) async {
|
Future<void> _pasteToTerminal(TerminalSession session) async {
|
||||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||||
final text = data?.text;
|
final text = data?.text;
|
||||||
if (text == null || text.isEmpty) return;
|
if (text == null || text.isEmpty) {
|
||||||
|
_clearLongPressMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
session.controller.writeInput(text);
|
session.controller.writeInput(text);
|
||||||
session.viewController.clearSelection();
|
session.viewController.clearSelection();
|
||||||
if (!mounted) return;
|
_clearLongPressMenu();
|
||||||
final l = AppLocalizations.of(context);
|
|
||||||
_showTerminalSnack(l.tr('terminal.pasted'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearTerminal(TerminalSession session) {
|
void _clearTerminal(TerminalSession session) {
|
||||||
session.controller.clearTerminal();
|
session.controller.clearTerminal();
|
||||||
session.viewController.clearSelection();
|
session.viewController.clearSelection();
|
||||||
|
_clearLongPressMenu();
|
||||||
_searchStates.remove(session.id)?.dispose();
|
_searchStates.remove(session.id)?.dispose();
|
||||||
final l = AppLocalizations.of(context);
|
|
||||||
_showTerminalSnack(l.tr('terminal.cleared'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// void _toggleSearchBar() {
|
// void _toggleSearchBar() {
|
||||||
@@ -513,6 +638,8 @@ class _TerminalShellPageState extends ConsumerState<TerminalShellPage> {
|
|||||||
_searchCtrl.dispose();
|
_searchCtrl.dispose();
|
||||||
_searchFocusNode.dispose();
|
_searchFocusNode.dispose();
|
||||||
_terminalFocusNode.dispose();
|
_terminalFocusNode.dispose();
|
||||||
|
_longPressMenuTimer?.cancel();
|
||||||
|
_liveTouchPosition.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,44 +731,51 @@ class _TerminalShellPageState extends ConsumerState<TerminalShellPage> {
|
|||||||
: null,
|
: null,
|
||||||
autofocus: session.id == active?.id,
|
autofocus: session.id == active?.id,
|
||||||
deleteDetection: true,
|
deleteDetection: true,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: _terminalContentPadding,
|
||||||
horizontal: 4,
|
|
||||||
vertical: 6,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (session.id == active?.id)
|
if (session.id == active?.id)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: GestureDetector(
|
// Listener 是原始指针监听,不进入手势竞技场,
|
||||||
|
// 只用来给放大镜取实时触摸坐标,长按选词/拖动
|
||||||
|
// 扩展选区完全交给 xterm 内建的 TerminalView 处理,
|
||||||
|
// 避免和它内部的 LongPressGestureRecognizer 竞争。
|
||||||
|
child: Listener(
|
||||||
behavior:
|
behavior:
|
||||||
HitTestBehavior.translucent,
|
HitTestBehavior.translucent,
|
||||||
onTapUp: (details) =>
|
onPointerDown: (event) =>
|
||||||
_handleLinkTap(
|
_handleTerminalPointerDown(
|
||||||
session,
|
session,
|
||||||
details,
|
event,
|
||||||
),
|
),
|
||||||
onLongPressStart: (details) =>
|
onPointerMove: (event) =>
|
||||||
_handleSelectionStart(
|
_handleTerminalPointerMove(
|
||||||
session,
|
|
||||||
details,
|
|
||||||
),
|
|
||||||
onLongPressMoveUpdate:
|
|
||||||
(details) =>
|
|
||||||
_handleSelectionUpdate(
|
|
||||||
session,
|
|
||||||
details,
|
|
||||||
),
|
|
||||||
onLongPressEnd: (_) =>
|
|
||||||
_handleSelectionEnd(
|
|
||||||
session,
|
session,
|
||||||
|
event,
|
||||||
),
|
),
|
||||||
|
onPointerUp: (_) =>
|
||||||
|
_handleTerminalPointerUp(),
|
||||||
|
onPointerCancel: (_) =>
|
||||||
|
_handleTerminalPointerCancel(),
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior
|
||||||
|
.translucent,
|
||||||
|
onTapUp: (details) =>
|
||||||
|
_handleLinkTap(
|
||||||
|
session,
|
||||||
|
details,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (session.id == active?.id &&
|
if (session.id == active?.id &&
|
||||||
activeRange != null) ...[
|
(activeRange != null ||
|
||||||
|
_longPressMenuSessionId ==
|
||||||
|
session.id))
|
||||||
_SelectionContextMenu(
|
_SelectionContextMenu(
|
||||||
offset: _selectionMenuOffset(
|
offset: _selectionMenuOffset(
|
||||||
session,
|
session,
|
||||||
),
|
),
|
||||||
|
showCopy: activeRange != null,
|
||||||
onCopy: () =>
|
onCopy: () =>
|
||||||
_copySelection(session),
|
_copySelection(session),
|
||||||
onPaste: () =>
|
onPaste: () =>
|
||||||
@@ -649,31 +783,80 @@ class _TerminalShellPageState extends ConsumerState<TerminalShellPage> {
|
|||||||
onClear: () =>
|
onClear: () =>
|
||||||
_clearTerminal(session),
|
_clearTerminal(session),
|
||||||
),
|
),
|
||||||
|
if (session.id == active?.id &&
|
||||||
|
activeRange != null) ...[
|
||||||
_SelectionHandle(
|
_SelectionHandle(
|
||||||
offset: _selectionHandleOffset(
|
offset: _selectionHandleOffset(
|
||||||
session,
|
session,
|
||||||
start: true,
|
start: true,
|
||||||
),
|
),
|
||||||
alignLeft: true,
|
knobOnTop: true,
|
||||||
|
cellHeight:
|
||||||
|
_renderTerminalFor(
|
||||||
|
session,
|
||||||
|
)?.cellSize.height ??
|
||||||
|
16,
|
||||||
|
onDragStart: (details) =>
|
||||||
|
_handleHandleDragStart(
|
||||||
|
session,
|
||||||
|
details,
|
||||||
|
),
|
||||||
onDragUpdate: (details) =>
|
onDragUpdate: (details) =>
|
||||||
_updateSelectionHandle(
|
_handleHandleDragUpdate(
|
||||||
session,
|
session,
|
||||||
start: true,
|
start: true,
|
||||||
details: details,
|
details: details,
|
||||||
),
|
),
|
||||||
|
onDragEnd: (details) =>
|
||||||
|
_handleHandleDragEnd(
|
||||||
|
session,
|
||||||
|
details,
|
||||||
|
),
|
||||||
|
onDragCancel: () =>
|
||||||
|
_handleHandleDragCancel(
|
||||||
|
session,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_SelectionHandle(
|
_SelectionHandle(
|
||||||
offset: _selectionHandleOffset(
|
offset: _selectionHandleOffset(
|
||||||
session,
|
session,
|
||||||
start: false,
|
start: false,
|
||||||
),
|
),
|
||||||
alignLeft: false,
|
knobOnTop: false,
|
||||||
|
cellHeight:
|
||||||
|
_renderTerminalFor(
|
||||||
|
session,
|
||||||
|
)?.cellSize.height ??
|
||||||
|
16,
|
||||||
|
onDragStart: (details) =>
|
||||||
|
_handleHandleDragStart(
|
||||||
|
session,
|
||||||
|
details,
|
||||||
|
),
|
||||||
onDragUpdate: (details) =>
|
onDragUpdate: (details) =>
|
||||||
_updateSelectionHandle(
|
_handleHandleDragUpdate(
|
||||||
session,
|
session,
|
||||||
start: false,
|
start: false,
|
||||||
details: details,
|
details: details,
|
||||||
),
|
),
|
||||||
|
onDragEnd: (details) =>
|
||||||
|
_handleHandleDragEnd(
|
||||||
|
session,
|
||||||
|
details,
|
||||||
|
),
|
||||||
|
onDragCancel: () =>
|
||||||
|
_handleHandleDragCancel(
|
||||||
|
session,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_TerminalMagnifier(
|
||||||
|
liveTouchPosition:
|
||||||
|
_liveTouchPosition,
|
||||||
|
containerSize:
|
||||||
|
_renderTerminalFor(
|
||||||
|
session,
|
||||||
|
)?.size ??
|
||||||
|
Size.zero,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -838,12 +1021,14 @@ class _StatusDot extends StatelessWidget {
|
|||||||
class _SelectionContextMenu extends StatelessWidget {
|
class _SelectionContextMenu extends StatelessWidget {
|
||||||
const _SelectionContextMenu({
|
const _SelectionContextMenu({
|
||||||
required this.offset,
|
required this.offset,
|
||||||
|
required this.showCopy,
|
||||||
required this.onCopy,
|
required this.onCopy,
|
||||||
required this.onPaste,
|
required this.onPaste,
|
||||||
required this.onClear,
|
required this.onClear,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Offset? offset;
|
final Offset? offset;
|
||||||
|
final bool showCopy;
|
||||||
final VoidCallback onCopy;
|
final VoidCallback onCopy;
|
||||||
final VoidCallback onPaste;
|
final VoidCallback onPaste;
|
||||||
final VoidCallback onClear;
|
final VoidCallback onClear;
|
||||||
@@ -871,11 +1056,13 @@ class _SelectionContextMenu extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_SelectionMenuItem(
|
if (showCopy) ...[
|
||||||
label: l.tr('terminal.selection.copy'),
|
_SelectionMenuItem(
|
||||||
onTap: onCopy,
|
label: l.tr('terminal.selection.copy'),
|
||||||
),
|
onTap: onCopy,
|
||||||
_SelectionMenuDivider(),
|
),
|
||||||
|
_SelectionMenuDivider(),
|
||||||
|
],
|
||||||
_SelectionMenuItem(
|
_SelectionMenuItem(
|
||||||
label: l.tr('terminal.selection.paste'),
|
label: l.tr('terminal.selection.paste'),
|
||||||
onTap: onPaste,
|
onTap: onPaste,
|
||||||
@@ -933,13 +1120,26 @@ class _SelectionMenuDivider extends StatelessWidget {
|
|||||||
class _SelectionHandle extends StatelessWidget {
|
class _SelectionHandle extends StatelessWidget {
|
||||||
const _SelectionHandle({
|
const _SelectionHandle({
|
||||||
required this.offset,
|
required this.offset,
|
||||||
required this.alignLeft,
|
required this.knobOnTop,
|
||||||
|
required this.cellHeight,
|
||||||
|
required this.onDragStart,
|
||||||
required this.onDragUpdate,
|
required this.onDragUpdate,
|
||||||
|
required this.onDragEnd,
|
||||||
|
required this.onDragCancel,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Offset? offset;
|
final Offset? offset;
|
||||||
final bool alignLeft;
|
final bool knobOnTop;
|
||||||
|
final double cellHeight;
|
||||||
|
final GestureDragStartCallback onDragStart;
|
||||||
final GestureDragUpdateCallback onDragUpdate;
|
final GestureDragUpdateCallback onDragUpdate;
|
||||||
|
final GestureDragEndCallback onDragEnd;
|
||||||
|
final GestureDragCancelCallback onDragCancel;
|
||||||
|
|
||||||
|
static const double _knobRadius = 8.0;
|
||||||
|
static const double _stemWidth = 2.0;
|
||||||
|
// 热区比视觉图形大一圈,满足约 44 逻辑像素的最小触控目标,不改变视觉大小。
|
||||||
|
static const double _hitPadding = 14.0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -947,36 +1147,36 @@ class _SelectionHandle extends StatelessWidget {
|
|||||||
if (handleOffset == null) {
|
if (handleOffset == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
const knobRadius = 8.0;
|
// stem 竖线的高度用真实的字符格高度(随字体大小设置变化),而不是写死的像素值,
|
||||||
const stemHeight = 16.0;
|
// 这样竖线正好贴合被选中字符的上下边界,不会有间隙。
|
||||||
final left = alignLeft
|
final stemHeight = cellHeight;
|
||||||
? handleOffset.dx - knobRadius
|
final knobDiameter = _knobRadius * 2;
|
||||||
: handleOffset.dx - knobRadius;
|
final color = Theme.of(context).colorScheme.primary;
|
||||||
final top = handleOffset.dy - stemHeight;
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: left,
|
// 竖线和圆点整体以 handleOffset.dx(选区边界的真实像素位置)为中心水平居中,
|
||||||
top: top,
|
// 外面再包一圈透明热区方便手指拖拽,不影响视觉大小和居中效果。
|
||||||
|
left: handleOffset.dx - _knobRadius - _hitPadding,
|
||||||
|
top:
|
||||||
|
handleOffset.dy -
|
||||||
|
stemHeight -
|
||||||
|
(knobOnTop ? knobDiameter : 0) -
|
||||||
|
_hitPadding,
|
||||||
|
width: _knobRadius * 2 + _hitPadding * 2,
|
||||||
|
height: stemHeight + _knobRadius * 2 + _hitPadding * 2,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onPanStart: onDragStart,
|
||||||
onPanUpdate: onDragUpdate,
|
onPanUpdate: onDragUpdate,
|
||||||
child: SizedBox(
|
onPanEnd: onDragEnd,
|
||||||
width: knobRadius * 2,
|
onPanCancel: onDragCancel,
|
||||||
height: stemHeight + knobRadius * 2,
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(_hitPadding),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
if (knobOnTop) _SelectionHandleKnob(color: color),
|
||||||
width: 2,
|
Container(width: _stemWidth, height: stemHeight, color: color),
|
||||||
height: stemHeight,
|
if (!knobOnTop) _SelectionHandleKnob(color: color),
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
width: knobRadius * 2,
|
|
||||||
height: knobRadius * 2,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -985,6 +1185,83 @@ class _SelectionHandle extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _SelectionHandleKnob extends StatelessWidget {
|
||||||
|
const _SelectionHandleKnob({required this.color});
|
||||||
|
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: _SelectionHandle._knobRadius * 2,
|
||||||
|
height: _SelectionHandle._knobRadius * 2,
|
||||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TerminalMagnifier extends StatelessWidget {
|
||||||
|
const _TerminalMagnifier({
|
||||||
|
required this.liveTouchPosition,
|
||||||
|
required this.containerSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ValueNotifier<Offset?> liveTouchPosition;
|
||||||
|
final Size containerSize;
|
||||||
|
|
||||||
|
static const _size = Size(140, 90);
|
||||||
|
static const _verticalGap = 28.0;
|
||||||
|
static const _scale = 1.75;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ValueListenableBuilder<Offset?>(
|
||||||
|
valueListenable: liveTouchPosition,
|
||||||
|
builder: (context, touch, _) {
|
||||||
|
if (touch == null) return const SizedBox.shrink();
|
||||||
|
final maxLeft = max(4.0, containerSize.width - _size.width - 4.0);
|
||||||
|
final left = (touch.dx - _size.width / 2).clamp(4.0, maxLeft);
|
||||||
|
var top = touch.dy - _verticalGap - _size.height;
|
||||||
|
if (top < 4.0) {
|
||||||
|
// 上方空间不够(比如选中的是第一行)时,改为显示在触摸点下方。
|
||||||
|
top = touch.dy + _verticalGap;
|
||||||
|
}
|
||||||
|
final magnifierCenter = Offset(
|
||||||
|
left + _size.width / 2,
|
||||||
|
top + _size.height / 2,
|
||||||
|
);
|
||||||
|
return Positioned(
|
||||||
|
left: left,
|
||||||
|
top: top,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: RawMagnifier(
|
||||||
|
size: _size,
|
||||||
|
magnificationScale: _scale,
|
||||||
|
focalPointOffset: touch - magnifierCenter,
|
||||||
|
decoration: MagnifierDecoration(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: BorderSide(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
shadows: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black38,
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _TerminalSearchBar extends StatelessWidget {
|
class _TerminalSearchBar extends StatelessWidget {
|
||||||
const _TerminalSearchBar({
|
const _TerminalSearchBar({
|
||||||
required this.controller,
|
required this.controller,
|
||||||
|
|||||||
Reference in New Issue
Block a user