From b73bb5984d642feb3ede1222a011ba13ceccaba8 Mon Sep 17 00:00:00 2001 From: 6m10cmUK Date: Sun, 8 Mar 2026 16:11:46 +0900 Subject: [PATCH 1/3] WIP: Windows IME support Co-Authored-By: Claude Opus 4.6 From 9b8c0fa6c9a6d43fe242a6ad16627c88ba806831 Mon Sep 17 00:00:00 2001 From: 6m10cmUK Date: Mon, 9 Mar 2026 09:54:17 +0900 Subject: [PATCH 2/3] Remove external focusNode from TerminalView to fix keyboard input Use GlobalKey instead of FocusNode for terminal reference. External FocusNode likely broke keyboard input on Windows. Co-Authored-By: Claude Opus 4.6 --- lib/screens/terminal_screen.dart | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/screens/terminal_screen.dart b/lib/screens/terminal_screen.dart index 37f95d4..079334f 100644 --- a/lib/screens/terminal_screen.dart +++ b/lib/screens/terminal_screen.dart @@ -38,7 +38,7 @@ class _TerminalTab { List subscriptions = []; Offset? _lastPointerPosition; - final FocusNode focusNode = FocusNode(); + final GlobalKey terminalKey = GlobalKey(); _TerminalTab({required this.sessionName}) : terminal = Terminal(maxLines: _kMaxLines), @@ -287,7 +287,6 @@ class _TerminalScreenState extends State } tab.subscriptions.clear(); tab.session?.close(); - tab.focusNode.dispose(); setState(() { _splitController?.removeTabFromAll(index); _tabs.removeAt(index); @@ -379,8 +378,7 @@ class _TerminalScreenState extends State } tab.subscriptions.clear(); tab.session?.close(); - tab.focusNode.dispose(); - } + } _sharedClient?.close(); _debounceTimer?.cancel(); super.dispose(); @@ -610,7 +608,7 @@ class _TerminalScreenState extends State final cursorX = buffer.cursorX; final cursorY = buffer.cursorY; // Approximate cell size (will be close enough for overlay positioning) - final renderBox = tab.focusNode.context?.findRenderObject() as RenderBox?; + final renderBox = tab.terminalKey.currentContext?.findRenderObject() as RenderBox?; if (renderBox == null) return; final size = renderBox.size; final cellWidth = size.width / tab.terminal.viewWidth; @@ -764,8 +762,8 @@ class _TerminalScreenState extends State }, child: TerminalView( tab.terminal, + key: tab.terminalKey, controller: tab.controller, - focusNode: tab.focusNode, autofocus: true, deleteDetection: !_isDesktop, keyboardType: _isDesktop ? TextInputType.text : TextInputType.emailAddress, @@ -853,7 +851,11 @@ class _TerminalScreenState extends State } else { // Re-focus the terminal to open the keyboard final tab = _tabs[_currentIndex]; - tab.focusNode.requestFocus(); + // Re-focus terminal to open keyboard + final context = tab.terminalKey.currentContext; + if (context != null) { + FocusScope.of(context).requestFocus(); + } } }, child: Container( From 9d1d07dbcf93c348838a39d032aaf7f0db5cc849 Mon Sep 17 00:00:00 2001 From: "6M10CM-PC\\jupit" Date: Sat, 21 Mar 2026 11:34:56 +0900 Subject: [PATCH 3/3] ver win --- lib/screens/host_list_screen.dart | 22 +- lib/screens/terminal_screen.dart | 593 ++++++++++++--- lib/widgets/dock_view.dart | 684 ++++++++++++++++++ lib/widgets/split_view.dart | 390 ---------- .../xterm/lib/src/core/buffer/buffer.dart | 2 +- packages/xterm/lib/src/terminal_view.dart | 10 +- .../xterm/lib/src/ui/custom_text_edit.dart | 44 +- 7 files changed, 1247 insertions(+), 498 deletions(-) create mode 100644 lib/widgets/dock_view.dart delete mode 100644 lib/widgets/split_view.dart diff --git a/lib/screens/host_list_screen.dart b/lib/screens/host_list_screen.dart index 11da74a..92da887 100644 --- a/lib/screens/host_list_screen.dart +++ b/lib/screens/host_list_screen.dart @@ -1,8 +1,10 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../providers/providers.dart'; import 'host_edit_screen.dart'; import 'session_list_screen.dart'; +import 'terminal_screen.dart'; class HostListScreen extends ConsumerWidget { const HostListScreen({super.key}); @@ -34,12 +36,20 @@ class HostListScreen extends ConsumerWidget { return ListTile( title: Text(host.label), subtitle: Text('${host.username}@${host.host}:${host.port}'), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => SessionListScreen(host: host), - ), - ), + onTap: () { + final isDesktop = + defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.linux; + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => isDesktop + ? TerminalScreen(host: host) + : SessionListScreen(host: host), + ), + ); + }, trailing: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/screens/terminal_screen.dart b/lib/screens/terminal_screen.dart index 37f95d4..c502126 100644 --- a/lib/screens/terminal_screen.dart +++ b/lib/screens/terminal_screen.dart @@ -11,7 +11,7 @@ import 'package:xterm/xterm.dart'; import '../models/host_config.dart'; import '../services/command_history_service.dart'; import '../services/ssh_service.dart'; -import '../widgets/split_view.dart'; +import '../widgets/dock_view.dart'; import 'package:xterm/suggestion.dart'; final _isDesktop = defaultTargetPlatform == TargetPlatform.windows || @@ -47,12 +47,12 @@ class _TerminalTab { class TerminalScreen extends StatefulWidget { final HostConfig host; - final String sessionName; + final String? sessionName; const TerminalScreen({ super.key, required this.host, - required this.sessionName, + this.sessionName, }); @override @@ -66,9 +66,15 @@ class _TerminalScreenState extends State bool _ctrlHeld = false; SSHClient? _sharedClient; Completer? _connectingClient; - SplitViewController? _splitController; + DockViewController? _dockController; _TerminalTab get _currentTab => _tabs[_currentIndex]; + // Sidebar + bool _sidebarOpen = false; + double _sidebarWidth = 220; + List _sidebarSessions = []; + bool _sidebarLoading = false; + // Autocomplete late final _historyService = CommandHistoryService(hostId: widget.host.id); final _suggestionController = SuggestionPortalController(); @@ -80,7 +86,17 @@ class _TerminalScreenState extends State void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - _addTab(widget.sessionName); + if (widget.sessionName != null) { + _addTab(widget.sessionName!); + if (_isDesktop) { + _sidebarOpen = true; + _loadSidebarSessions(); + } + } else { + // Desktop: open with sidebar, no initial tab + _sidebarOpen = true; + _loadSidebarSessions(); + } } Future _enableBackground() async { @@ -190,10 +206,20 @@ class _TerminalScreenState extends State _tabs.add(tab); _currentIndex = _tabs.length - 1; if (_isDesktop) { - _splitController ??= SplitViewController(initialTabIndex: 0); + if (_dockController == null) { + _dockController = DockViewController(initialTabIndex: _currentIndex); + } else { + final focused = _dockController!.focusedLeaf(); + if (focused != null) { + _dockController!.addTabToLeaf(focused.id, _currentIndex); + } + } } }); _connectTab(tab); + if (_isDesktop && _sidebarOpen) { + _loadSidebarSessions(); + } } Future _connectTab(_TerminalTab tab) async { @@ -249,12 +275,12 @@ class _TerminalScreenState extends State } }); - setState(() => tab.connected = true); + if (mounted) setState(() => tab.connected = true); if (!_isDesktop && !FlutterBackground.isBackgroundExecutionEnabled) { _enableBackground(); } } catch (e) { - setState(() => tab.error = e.toString()); + if (mounted) setState(() => tab.error = e.toString()); } } @@ -289,15 +315,26 @@ class _TerminalScreenState extends State tab.session?.close(); tab.focusNode.dispose(); setState(() { - _splitController?.removeTabFromAll(index); + _dockController?.closeTab(index); _tabs.removeAt(index); - if (index < _currentIndex) { - _currentIndex--; - } else if (_currentIndex >= _tabs.length) { - _currentIndex = _tabs.isEmpty ? 0 : _tabs.length - 1; + if (_tabs.isEmpty) { + _dockController = null; + _currentIndex = 0; + } else { + if (index < _currentIndex) { + _currentIndex--; + } else if (_currentIndex >= _tabs.length) { + _currentIndex = _tabs.length - 1; + } + if (_isDesktop && _dockController != null) { + final focused = _dockController!.focusedLeaf(); + if (focused != null && focused.tabIndices.isNotEmpty) { + _currentIndex = focused.activeTabIndex; + } + } } }); - if (_tabs.isEmpty) { + if (_tabs.isEmpty && !_isDesktop) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) Navigator.pop(context); }); @@ -326,33 +363,190 @@ class _TerminalScreenState extends State if (!mounted) return; - final selected = await showModalBottomSheet( - context: context, - builder: (ctx) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Padding( - padding: EdgeInsets.all(16), - child: Text('Open session', style: TextStyle(fontSize: 18)), - ), - if (available.isEmpty) + final String? selected; + if (_isDesktop) { + selected = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: const Text('Open session'), + children: [ + if (available.isEmpty) + const Padding( + padding: EdgeInsets.all(16), + child: Text('No other sessions available'), + ), + ...available.map((name) => SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, name), + child: Row( + children: [ + const Icon(Icons.terminal, size: 20), + const SizedBox(width: 12), + Text(name), + ], + ), + )), + ], + ), + ); + } else { + selected = await showModalBottomSheet( + context: context, + builder: (ctx) => Column( + mainAxisSize: MainAxisSize.min, + children: [ const Padding( padding: EdgeInsets.all(16), - child: Text('No other sessions available'), + child: Text('Open session', style: TextStyle(fontSize: 18)), ), - ...available.map((name) => ListTile( - leading: const Icon(Icons.terminal), - title: Text(name), - onTap: () => Navigator.pop(ctx, name), - )), - const SizedBox(height: 16), + if (available.isEmpty) + const Padding( + padding: EdgeInsets.all(16), + child: Text('No other sessions available'), + ), + ...available.map((name) => ListTile( + leading: const Icon(Icons.terminal), + title: Text(name), + onTap: () => Navigator.pop(ctx, name), + )), + const SizedBox(height: 16), + ], + ), + ); + } + + if (selected != null) { + _addTab(selected); + } + } + + Future _loadSidebarSessions() async { + setState(() => _sidebarLoading = true); + try { + final client = await _getClient(); + final ssh = SshService(); + final (sessions, _) = await ssh.listTmuxSessions(client); + if (mounted) { + setState(() { + _sidebarSessions = sessions; + _sidebarLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() => _sidebarLoading = false); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Error loading sessions: $e'))); + } + } + } + + Future _sidebarCreateSession() async { + final ctrl = TextEditingController(); + final name = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('New tmux session'), + content: TextField( + controller: ctrl, + decoration: const InputDecoration(hintText: 'Session name'), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, ctrl.text), + child: const Text('Create'), + ), ], ), ); + if (name == null || name.isEmpty) return; + if (!_kSessionNamePattern.hasMatch(name)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Invalid session name')), + ); + return; + } + try { + final client = await _getClient(); + final ssh = SshService(); + await ssh.createSession(client, name); + _loadSidebarSessions(); + _addTab(name); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } - if (selected != null) { - _addTab(selected); + Future _sidebarKillSession(String name) async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Kill session?'), + content: Text('Kill "$name"?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Kill'), + ), + ], + ), + ); + if (confirm != true) return; + // Close the tab if open + final tabIndex = _tabs.indexWhere((t) => t.sessionName == name); + if (tabIndex >= 0) { + _closeTab(tabIndex); } + try { + final client = await _getClient(); + final ssh = SshService(); + await ssh.killSession(client, name); + _loadSidebarSessions(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + void _sidebarTapSession(String name) { + // If tab already open, find its pane and activate it + final existingIndex = _tabs.indexWhere((t) => t.sessionName == name); + if (existingIndex >= 0) { + setState(() { + _currentIndex = existingIndex; + if (_isDesktop && _dockController != null) { + final leaf = _dockController!.findLeafContaining(existingIndex); + if (leaf != null) { + _dockController!.activateTab(leaf.id, existingIndex); + } else { + // Tab exists but not in any leaf — add to focused leaf + final focused = _dockController!.focusedLeaf(); + if (focused != null) { + _dockController!.addTabToLeaf(focused.id, existingIndex); + } + } + } + }); + if (_isDesktop && _currentIndex < _tabs.length) { + _tabs[_currentIndex].focusNode.requestFocus(); + } + return; + } + // Otherwise open new tab + _addTab(name); } void _handleScroll(_TerminalTab tab, double delta) { @@ -388,24 +582,35 @@ class _TerminalScreenState extends State @override Widget build(BuildContext context) { - if (_tabs.isEmpty) return const SizedBox.shrink(); + if (_tabs.isEmpty && !_isDesktop) return const SizedBox.shrink(); return SuggestionPortal( controller: _suggestionController, overlayBuilder: (_) => _buildSuggestionOverlay(), child: Scaffold( resizeToAvoidBottomInset: false, - appBar: AppBar( - toolbarHeight: 0, - bottom: PreferredSize( - preferredSize: const Size.fromHeight(40), - child: _buildTabBar(), - ), - ), + appBar: _isDesktop + ? null + : AppBar( + toolbarHeight: 0, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(40), + child: _buildTabBar(), + ), + ), body: _buildBody(), ), ); } + void _navigateBack() { + for (final tab in _tabs) { + tab.session?.close(); + } + _sharedClient?.close(); + Navigator.pop(context); + } + + /// Mobile-only tab bar Widget _buildTabBar() { return SizedBox( height: 40, @@ -413,24 +618,26 @@ class _TerminalScreenState extends State children: [ IconButton( icon: const Icon(Icons.arrow_back, size: 20), - onPressed: () { - for (final tab in _tabs) { - tab.session?.close(); - } - _sharedClient?.close(); - Navigator.pop(context); - }, + onPressed: _navigateBack, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 40), ), Expanded( child: ListView.builder( scrollDirection: Axis.horizontal, - itemCount: _tabs.length, + itemCount: _tabs.length + 1, itemBuilder: (context, i) { + if (i == _tabs.length) { + return IconButton( + icon: const Icon(Icons.add, size: 20), + onPressed: _showAddTabDialog, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 40), + ); + } final tab = _tabs[i]; final selected = i == _currentIndex; - final tabWidget = GestureDetector( + return GestureDetector( onTap: () => setState(() => _currentIndex = i), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12), @@ -482,38 +689,35 @@ class _TerminalScreenState extends State ), ), ); - if (!_isDesktop) return tabWidget; - return Draggable( - data: i, - feedback: Material( - elevation: 4, - borderRadius: BorderRadius.circular(6), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(6), - ), - child: Text(tab.sessionName, - style: const TextStyle(fontSize: 13)), - ), - ), - childWhenDragging: Opacity( - opacity: 0.4, - child: tabWidget, - ), - child: tabWidget, - ); }, ), ), + ], + ), + ); + } + + /// Desktop toolbar shown when sidebar is collapsed + Widget _buildDesktopToolbar() { + return Container( + height: 32, + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: Row( + children: [ IconButton( - icon: const Icon(Icons.add, size: 20), - onPressed: _showAddTabDialog, + icon: const Icon(Icons.menu, size: 18), + onPressed: () => setState(() => _sidebarOpen = true), padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 40), + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + tooltip: 'Show sidebar', + ), + IconButton( + icon: const Icon(Icons.arrow_back, size: 18), + onPressed: _navigateBack, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), ), + const Spacer(), ], ), ); @@ -539,9 +743,7 @@ class _TerminalScreenState extends State if (_tabs.isEmpty) return; final data = await Clipboard.getData(Clipboard.kTextPlain); if (data?.text != null && data!.text!.isNotEmpty) { - _currentTab.session?.write( - Uint8List.fromList(utf8.encode(data.text!)), - ); + _currentTab.terminal.paste(data.text!); } } @@ -778,35 +980,236 @@ class _TerminalScreenState extends State ); } + Widget _buildSidebar() { + final openNames = _tabs.map((t) => t.sessionName).toSet(); + return Container( + width: _sidebarWidth, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + ), + child: Column( + children: [ + // Host header with back + collapse + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Row( + children: [ + SizedBox( + width: 32, + height: 32, + child: IconButton( + icon: const Icon(Icons.arrow_back, size: 18), + onPressed: _navigateBack, + padding: EdgeInsets.zero, + tooltip: 'Back to hosts', + ), + ), + Expanded( + child: Text( + widget.host.label, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + SizedBox( + width: 32, + height: 32, + child: IconButton( + icon: const Icon(Icons.chevron_left, size: 18), + onPressed: () => setState(() => _sidebarOpen = false), + padding: EdgeInsets.zero, + tooltip: 'Collapse sidebar', + ), + ), + ], + ), + ), + const Divider(height: 1), + // Sessions header with refresh + add + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + children: [ + const Expanded( + child: Text( + 'Sessions', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: Colors.grey, + ), + ), + ), + SizedBox( + width: 28, + height: 28, + child: IconButton( + icon: const Icon(Icons.refresh, size: 16), + onPressed: _loadSidebarSessions, + padding: EdgeInsets.zero, + ), + ), + SizedBox( + width: 28, + height: 28, + child: IconButton( + icon: const Icon(Icons.add, size: 16), + onPressed: _sidebarCreateSession, + padding: EdgeInsets.zero, + ), + ), + ], + ), + ), + // Session list + Expanded( + child: _sidebarLoading + ? const Center(child: CircularProgressIndicator(strokeWidth: 2)) + : _sidebarSessions.isEmpty + ? const Center( + child: Text( + 'No sessions', + style: TextStyle(color: Colors.grey), + ), + ) + : ListView.builder( + itemCount: _sidebarSessions.length, + itemBuilder: (context, i) { + final name = _sidebarSessions[i]; + final isOpen = openNames.contains(name); + final isActive = _tabs.isNotEmpty && + _currentIndex < _tabs.length && + _tabs[_currentIndex].sessionName == name; + return ListTile( + dense: true, + visualDensity: VisualDensity.compact, + leading: Icon( + Icons.terminal, + size: 18, + color: isOpen + ? Theme.of(context).colorScheme.primary + : null, + ), + title: Text( + name, + style: TextStyle( + fontSize: 13, + fontWeight: + isActive ? FontWeight.bold : FontWeight.normal, + color: isOpen + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + selected: isActive, + onTap: () => _sidebarTapSession(name), + trailing: SizedBox( + width: 28, + height: 28, + child: IconButton( + icon: const Icon(Icons.delete_outline, size: 16), + onPressed: () => _sidebarKillSession(name), + padding: EdgeInsets.zero, + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + Widget _buildBody() { - if (_isDesktop && _splitController != null) { - return SplitView( - controller: _splitController!, + if (_isDesktop) { + return _buildDesktopLayout(); + } + final terminalView = _buildTerminalPane(_currentIndex); + final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; + return Transform.translate( + offset: Offset(0, -keyboardHeight), + child: Column( + children: [ + Expanded(child: terminalView), + _buildAccessoryBar(), + ], + ), + ); + } + + Widget _buildDesktopLayout() { + Widget terminal; + if (_dockController != null) { + terminal = DockView( + controller: _dockController!, tabCount: _tabs.length, + tabBuilder: (tabIndex) { + if (tabIndex < 0 || tabIndex >= _tabs.length) { + return const SizedBox(); + } + return Text(_tabs[tabIndex].sessionName); + }, paneBuilder: (tabIndex, leafId, focused) { return _buildTerminalPane(tabIndex); }, onFocusChanged: (leafId) { - final leaf = _splitController!.focusedLeaf(); - if (leaf != null) { - setState(() => _currentIndex = leaf.tabIndex); + final leaf = _dockController!.focusedLeaf(); + if (leaf != null && leaf.tabIndices.isNotEmpty) { + setState(() => _currentIndex = leaf.activeTabIndex); + if (_currentIndex >= 0 && _currentIndex < _tabs.length) { + _tabs[_currentIndex].focusNode.requestFocus(); + } } }, + onTabClosed: (tabIndex) => _closeTab(tabIndex), + onTabReloaded: (tabIndex) => _reloadTab(tabIndex), onChanged: () => setState(() {}), ); + } else { + terminal = Container( + color: Colors.black, + child: const Center( + child: Text( + 'Select a session from the sidebar', + style: TextStyle(color: Colors.white54), + ), + ), + ); } - final terminalView = _buildTerminalPane(_currentIndex); - - final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; - return Transform.translate( - offset: Offset(0, -keyboardHeight), - child: Column( - children: [ - Expanded(child: terminalView), - _buildAccessoryBar(), + return Row( + children: [ + if (_sidebarOpen) ...[ + _buildSidebar(), + MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + child: GestureDetector( + onHorizontalDragUpdate: (details) { + setState(() { + _sidebarWidth = (_sidebarWidth + details.delta.dx) + .clamp(150.0, 400.0); + }); + }, + child: Container( + width: 4, + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + ), ], - ), + Expanded( + child: Column( + children: [ + if (!_sidebarOpen) _buildDesktopToolbar(), + Expanded(child: terminal), + ], + ), + ), + ], ); } diff --git a/lib/widgets/dock_view.dart b/lib/widgets/dock_view.dart new file mode 100644 index 0000000..402c813 --- /dev/null +++ b/lib/widgets/dock_view.dart @@ -0,0 +1,684 @@ +import 'package:flutter/material.dart'; + +const _kDividerThickness = 6.0; +const _kDropEdgeRatio = 0.25; +const _kMinPaneSize = 80.0; +const _kTabBarHeight = 32.0; + +enum DockDirection { horizontal, vertical } + +sealed class DockNode { + final String id; + DockNode(this.id); +} + +class DockLeaf extends DockNode { + List tabIndices; + int activeTabIndex; + + DockLeaf({ + required String id, + required this.tabIndices, + int? activeTabIndex, + }) : activeTabIndex = + activeTabIndex ?? (tabIndices.isNotEmpty ? tabIndices.first : 0), + super(id); +} + +class DockBranch extends DockNode { + DockDirection direction; + DockNode first; + DockNode second; + double ratio; + + DockBranch({ + required String id, + required this.direction, + required this.first, + required this.second, + this.ratio = 0.5, + }) : super(id); +} + +class DockTabDragData { + final int tabIndex; + final String sourceLeafId; + + DockTabDragData({required this.tabIndex, required this.sourceLeafId}); +} + +enum DockDropPosition { left, right, top, bottom, center } + +class DockViewController { + DockNode root; + String? focusedLeafId; + int _nextId = 0; + + DockViewController({required int initialTabIndex}) + : root = DockLeaf( + id: '0', + tabIndices: [initialTabIndex], + ) { + focusedLeafId = '0'; + _nextId = 1; + } + + String _genId() => '${_nextId++}'; + + void addTabToLeaf(String leafId, int tabIndex) { + final leaf = _findLeaf(root, leafId); + if (leaf == null) return; + if (!leaf.tabIndices.contains(tabIndex)) { + leaf.tabIndices.add(tabIndex); + } + leaf.activeTabIndex = tabIndex; + } + + void moveTabToLeaf(String targetLeafId, int tabIndex) { + // Remove from source leaf + for (final leaf in allLeaves()) { + if (leaf.tabIndices.contains(tabIndex)) { + leaf.tabIndices.remove(tabIndex); + if (leaf.activeTabIndex == tabIndex) { + leaf.activeTabIndex = + leaf.tabIndices.isNotEmpty ? leaf.tabIndices.last : -1; + } + if (leaf.tabIndices.isEmpty) { + _closeLeaf(leaf.id); + } + break; + } + } + // Add to target + final target = _findLeaf(root, targetLeafId); + if (target != null) { + if (!target.tabIndices.contains(tabIndex)) { + target.tabIndices.add(tabIndex); + } + target.activeTabIndex = tabIndex; + focusedLeafId = targetLeafId; + } + } + + void splitLeaf(String leafId, DockDropPosition position, int tabIndex) { + if (position == DockDropPosition.center) { + moveTabToLeaf(leafId, tabIndex); + return; + } + + final leaf = _findLeaf(root, leafId); + if (leaf == null) return; + + // Remove tabIndex from its current leaf + for (final l in allLeaves()) { + if (l.tabIndices.contains(tabIndex)) { + l.tabIndices.remove(tabIndex); + if (l.activeTabIndex == tabIndex) { + l.activeTabIndex = + l.tabIndices.isNotEmpty ? l.tabIndices.last : -1; + } + if (l.tabIndices.isEmpty && l.id != leafId) { + _closeLeaf(l.id); + } + break; + } + } + + final direction = + (position == DockDropPosition.left || position == DockDropPosition.right) + ? DockDirection.horizontal + : DockDirection.vertical; + + final newLeaf = DockLeaf( + id: _genId(), + tabIndices: [tabIndex], + ); + + final isFirstNew = + position == DockDropPosition.left || position == DockDropPosition.top; + + final existingCopy = DockLeaf( + id: leaf.id, + tabIndices: List.of(leaf.tabIndices), + activeTabIndex: leaf.activeTabIndex, + ); + + final branch = DockBranch( + id: _genId(), + direction: direction, + first: isFirstNew ? newLeaf : existingCopy, + second: isFirstNew ? existingCopy : newLeaf, + ); + + _replaceNode(leafId, branch); + focusedLeafId = newLeaf.id; + } + + void activateTab(String leafId, int tabIndex) { + final leaf = _findLeaf(root, leafId); + if (leaf != null && leaf.tabIndices.contains(tabIndex)) { + leaf.activeTabIndex = tabIndex; + focusedLeafId = leafId; + } + } + + void reorderTab(String leafId, int oldPos, int newPos) { + final leaf = _findLeaf(root, leafId); + if (leaf == null) return; + if (oldPos < 0 || oldPos >= leaf.tabIndices.length) return; + if (newPos < 0 || newPos >= leaf.tabIndices.length) return; + final tab = leaf.tabIndices.removeAt(oldPos); + leaf.tabIndices.insert(newPos, tab); + } + + void closeTab(int tabIndex) { + for (final leaf in allLeaves()) { + leaf.tabIndices.remove(tabIndex); + if (leaf.activeTabIndex == tabIndex) { + leaf.activeTabIndex = + leaf.tabIndices.isNotEmpty ? leaf.tabIndices.last : -1; + } + if (leaf.tabIndices.isEmpty) { + _closeLeaf(leaf.id); + } + } + _adjustIndicesAfterRemoval(tabIndex); + } + + void _adjustIndicesAfterRemoval(int removedIndex) { + for (final leaf in allLeaves()) { + leaf.tabIndices = + leaf.tabIndices.map((i) => i > removedIndex ? i - 1 : i).toList(); + if (leaf.activeTabIndex > removedIndex) { + leaf.activeTabIndex--; + } + } + } + + DockLeaf? focusedLeaf() { + if (focusedLeafId == null) return null; + return _findLeaf(root, focusedLeafId!); + } + + List allLeaves() { + final out = []; + _collectLeaves(root, out); + return out; + } + + DockLeaf? findLeafContaining(int tabIndex) { + for (final leaf in allLeaves()) { + if (leaf.tabIndices.contains(tabIndex)) return leaf; + } + return null; + } + + void _closeLeaf(String leafId) { + if (root is DockLeaf) return; + final parent = _findParent(root, leafId); + if (parent == null) return; + + final sibling = + parent.first.id == leafId ? parent.second : parent.first; + _replaceNode(parent.id, sibling); + + if (focusedLeafId == leafId) { + focusedLeafId = _firstLeaf(root)?.id; + } + } + + void _collectLeaves(DockNode node, List out) { + if (node is DockLeaf) { + out.add(node); + } else if (node is DockBranch) { + _collectLeaves(node.first, out); + _collectLeaves(node.second, out); + } + } + + DockLeaf? _findLeaf(DockNode node, String id) { + if (node is DockLeaf && node.id == id) return node; + if (node is DockBranch) { + return _findLeaf(node.first, id) ?? _findLeaf(node.second, id); + } + return null; + } + + DockLeaf? _firstLeaf(DockNode node) { + if (node is DockLeaf) return node; + if (node is DockBranch) return _firstLeaf(node.first); + return null; + } + + DockBranch? _findParent(DockNode node, String childId) { + if (node is DockBranch) { + if (node.first.id == childId || node.second.id == childId) return node; + return _findParent(node.first, childId) ?? + _findParent(node.second, childId); + } + return null; + } + + void _replaceNode(String targetId, DockNode replacement) { + if (root.id == targetId) { + root = replacement; + return; + } + _replaceInTree(root, targetId, replacement); + } + + void _replaceInTree( + DockNode node, String targetId, DockNode replacement) { + if (node is DockBranch) { + if (node.first.id == targetId) { + node.first = replacement; + return; + } + if (node.second.id == targetId) { + node.second = replacement; + return; + } + _replaceInTree(node.first, targetId, replacement); + _replaceInTree(node.second, targetId, replacement); + } + } +} + +// --------------------------------------------------------------------------- +// Widget +// --------------------------------------------------------------------------- + +class DockView extends StatefulWidget { + final DockViewController controller; + final int tabCount; + final Widget Function(int tabIndex) tabBuilder; + final Widget Function(int tabIndex, String leafId, bool focused) paneBuilder; + final void Function(String leafId) onFocusChanged; + final void Function(int tabIndex) onTabClosed; + final void Function(int tabIndex)? onTabReloaded; + final void Function() onChanged; + + const DockView({ + super.key, + required this.controller, + required this.tabCount, + required this.tabBuilder, + required this.paneBuilder, + required this.onFocusChanged, + required this.onTabClosed, + this.onTabReloaded, + required this.onChanged, + }); + + @override + State createState() => _DockViewState(); +} + +class _DockViewState extends State { + @override + Widget build(BuildContext context) { + return _buildNode(widget.controller.root); + } + + Widget _buildNode(DockNode node) { + if (node is DockLeaf) return _buildLeaf(node); + if (node is DockBranch) return _buildBranch(node); + return const SizedBox.shrink(); + } + + // -- Branch (divider + two children) -------------------------------------- + + Widget _buildBranch(DockBranch branch) { + final isHorizontal = branch.direction == DockDirection.horizontal; + + return LayoutBuilder( + builder: (context, constraints) { + final totalSize = + isHorizontal ? constraints.maxWidth : constraints.maxHeight; + const dividerSize = _kDividerThickness; + final availableSize = totalSize - dividerSize; + final firstSize = (availableSize * branch.ratio) + .clamp(_kMinPaneSize, availableSize - _kMinPaneSize); + final secondSize = availableSize - firstSize; + + final children = [ + SizedBox( + width: isHorizontal ? firstSize : null, + height: isHorizontal ? null : firstSize, + child: _buildNode(branch.first), + ), + GestureDetector( + behavior: HitTestBehavior.opaque, + onPanUpdate: (details) { + setState(() { + final delta = + isHorizontal ? details.delta.dx : details.delta.dy; + final newFirstSize = (firstSize + delta) + .clamp(_kMinPaneSize, availableSize - _kMinPaneSize); + branch.ratio = newFirstSize / availableSize; + }); + widget.onChanged(); + }, + child: MouseRegion( + cursor: isHorizontal + ? SystemMouseCursors.resizeColumn + : SystemMouseCursors.resizeRow, + child: Container( + width: isHorizontal ? dividerSize : null, + height: isHorizontal ? null : dividerSize, + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + ), + SizedBox( + width: isHorizontal ? secondSize : null, + height: isHorizontal ? null : secondSize, + child: _buildNode(branch.second), + ), + ]; + + return isHorizontal + ? Row(children: children) + : Column(children: children); + }, + ); + } + + // -- Leaf (tab bar + content) --------------------------------------------- + + Widget _buildLeaf(DockLeaf leaf) { + final focused = widget.controller.focusedLeafId == leaf.id; + + return Listener( + onPointerDown: (_) { + if (widget.controller.focusedLeafId != leaf.id) { + widget.controller.focusedLeafId = leaf.id; + widget.onFocusChanged(leaf.id); + setState(() {}); + } + }, + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: focused + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + width: 1, + ), + ), + child: Column( + children: [ + _buildLeafTabBar(leaf, focused), + Expanded(child: _buildLeafContent(leaf, focused)), + ], + ), + ), + ); + } + + // -- Tab bar -------------------------------------------------------------- + + Widget _buildLeafTabBar(DockLeaf leaf, bool focused) { + return DragTarget( + onWillAcceptWithDetails: (details) { + // Accept drops from other leaves + if (details.data.sourceLeafId == leaf.id && + leaf.tabIndices.length <= 1) { + return false; + } + return true; + }, + onAcceptWithDetails: (details) { + setState(() { + if (details.data.sourceLeafId != leaf.id) { + widget.controller.moveTabToLeaf(leaf.id, details.data.tabIndex); + } + }); + widget.onChanged(); + }, + builder: (context, candidates, rejected) { + final highlight = candidates.isNotEmpty; + return Container( + height: _kTabBarHeight, + decoration: BoxDecoration( + color: highlight + ? Theme.of(context).colorScheme.primary.withAlpha(30) + : Theme.of(context).colorScheme.surfaceContainerLow, + border: Border( + bottom: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + width: 1, + ), + ), + ), + child: Row( + children: [ + Expanded( + child: ListView( + scrollDirection: Axis.horizontal, + children: leaf.tabIndices.map((tabIndex) { + return _buildDraggableTab(leaf, tabIndex); + }).toList(), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildDraggableTab(DockLeaf leaf, int tabIndex) { + final isActive = tabIndex == leaf.activeTabIndex; + final isFocused = widget.controller.focusedLeafId == leaf.id; + + if (tabIndex < 0 || tabIndex >= widget.tabCount) { + return const SizedBox.shrink(); + } + + final tabContent = GestureDetector( + onTap: () { + setState(() { + widget.controller.activateTab(leaf.id, tabIndex); + }); + widget.onFocusChanged(leaf.id); + }, + child: Container( + height: _kTabBarHeight, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: isActive + ? Theme.of(context).colorScheme.surface + : Colors.transparent, + border: Border( + bottom: BorderSide( + color: isActive && isFocused + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + width: 2, + ), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + DefaultTextStyle( + style: TextStyle( + fontSize: 13, + fontWeight: isActive ? FontWeight.bold : FontWeight.normal, + color: Theme.of(context).colorScheme.onSurface, + ), + child: widget.tabBuilder(tabIndex), + ), + if (widget.onTabReloaded != null) ...[ + const SizedBox(width: 4), + _tabIconButton( + Icons.refresh, () => widget.onTabReloaded!(tabIndex)), + ], + const SizedBox(width: 4), + _tabIconButton(Icons.close, () => widget.onTabClosed(tabIndex)), + ], + ), + ), + ); + + return Draggable( + data: DockTabDragData(tabIndex: tabIndex, sourceLeafId: leaf.id), + feedback: Material( + elevation: 4, + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(6), + ), + child: DefaultTextStyle( + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface, + ), + child: widget.tabBuilder(tabIndex), + ), + ), + ), + childWhenDragging: Opacity(opacity: 0.4, child: tabContent), + child: tabContent, + ); + } + + Widget _tabIconButton(IconData icon, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: SizedBox( + width: 20, + height: 20, + child: Center( + child: Icon(icon, size: 14), + ), + ), + ); + } + + // -- Leaf content with drop zones ----------------------------------------- + + Widget _buildLeafContent(DockLeaf leaf, bool focused) { + final activeTab = leaf.activeTabIndex; + if (activeTab < 0 || + !leaf.tabIndices.contains(activeTab) || + activeTab >= widget.tabCount) { + return Container(color: Colors.black); + } + + return DragTarget( + builder: (context, candidates, rejected) { + final showDropZones = candidates.isNotEmpty; + return Stack( + children: [ + widget.paneBuilder(activeTab, leaf.id, focused), + if (showDropZones) ..._buildDropZones(leaf), + ], + ); + }, + onWillAcceptWithDetails: (details) { + if (details.data.sourceLeafId == leaf.id && + leaf.tabIndices.length <= 1) { + return false; + } + return true; + }, + onAcceptWithDetails: (details) { + // Handled by individual zone targets + }, + ); + } + + List _buildDropZones(DockLeaf leaf) { + return [ + // Center (behind edges so edges take priority) + _dropZone( + leaf, + DockDropPosition.center, + Alignment.center, + 1.0 - 2 * _kDropEdgeRatio, + 1.0 - 2 * _kDropEdgeRatio, + ), + // Left + _dropZone( + leaf, + DockDropPosition.left, + Alignment.centerLeft, + _kDropEdgeRatio, + 1.0, + ), + // Right + _dropZone( + leaf, + DockDropPosition.right, + Alignment.centerRight, + _kDropEdgeRatio, + 1.0, + ), + // Top + _dropZone( + leaf, + DockDropPosition.top, + Alignment.topCenter, + 1.0, + _kDropEdgeRatio, + ), + // Bottom + _dropZone( + leaf, + DockDropPosition.bottom, + Alignment.bottomCenter, + 1.0, + _kDropEdgeRatio, + ), + ]; + } + + Widget _dropZone( + DockLeaf leaf, + DockDropPosition position, + Alignment alignment, + double widthFactor, + double heightFactor, + ) { + return Positioned.fill( + child: FractionallySizedBox( + alignment: alignment, + widthFactor: widthFactor, + heightFactor: heightFactor, + child: DragTarget( + builder: (context, candidates, rejected) { + return Container( + color: candidates.isNotEmpty + ? Theme.of(context).colorScheme.primary.withAlpha(60) + : Colors.transparent, + ); + }, + onWillAcceptWithDetails: (details) { + if (details.data.sourceLeafId == leaf.id && + leaf.tabIndices.length <= 1) { + return false; + } + // Reject center drop to self (already in this leaf) + if (position == DockDropPosition.center && + details.data.sourceLeafId == leaf.id) { + return false; + } + return true; + }, + onAcceptWithDetails: (details) { + setState(() { + widget.controller + .splitLeaf(leaf.id, position, details.data.tabIndex); + }); + widget.onChanged(); + }, + ), + ), + ); + } +} diff --git a/lib/widgets/split_view.dart b/lib/widgets/split_view.dart deleted file mode 100644 index 0b90d44..0000000 --- a/lib/widgets/split_view.dart +++ /dev/null @@ -1,390 +0,0 @@ -import 'package:flutter/material.dart'; - -const _kDividerThickness = 6.0; -const _kDropZoneRatio = 0.25; -const _kMinPaneSize = 80.0; - -enum SplitDirection { horizontal, vertical } - -sealed class SplitNode { - final String id; - SplitNode(this.id); -} - -class SplitLeaf extends SplitNode { - int tabIndex; - SplitLeaf({required String id, required this.tabIndex}) : super(id); -} - -class SplitBranch extends SplitNode { - SplitDirection direction; - SplitNode first; - SplitNode second; - double ratio; - - SplitBranch({ - required String id, - required this.direction, - required this.first, - required this.second, - this.ratio = 0.5, - }) : super(id); -} - -enum DropPosition { left, right, top, bottom } - -class SplitViewController { - SplitNode root; - String? focusedLeafId; - int _nextId = 0; - - SplitViewController({required int initialTabIndex}) - : root = SplitLeaf(id: '0', tabIndex: initialTabIndex) { - focusedLeafId = '0'; - _nextId = 1; - } - - String _genId() => '${_nextId++}'; - - void splitLeaf(String leafId, DropPosition position, int newTabIndex) { - final leaf = _findLeaf(root, leafId); - if (leaf == null) return; - - final direction = (position == DropPosition.left || - position == DropPosition.right) - ? SplitDirection.horizontal - : SplitDirection.vertical; - - final newLeaf = SplitLeaf(id: _genId(), tabIndex: newTabIndex); - final isFirstNew = - position == DropPosition.left || position == DropPosition.top; - - final branch = SplitBranch( - id: _genId(), - direction: direction, - first: isFirstNew ? newLeaf : SplitLeaf(id: leaf.id, tabIndex: leaf.tabIndex), - second: isFirstNew ? SplitLeaf(id: leaf.id, tabIndex: leaf.tabIndex) : newLeaf, - ); - - _replaceNode(leafId, branch); - focusedLeafId = newLeaf.id; - } - - void closeLeaf(String leafId) { - if (root is SplitLeaf) return; - final parent = _findParent(root, leafId); - if (parent == null) return; - - final sibling = - parent.first.id == leafId ? parent.second : parent.first; - - _replaceNode(parent.id, sibling); - - if (focusedLeafId == leafId) { - focusedLeafId = _firstLeaf(root)?.id; - } - } - - void removeTabFromAll(int tabIndex) { - final leaves = []; - _collectLeaves(root, leaves); - for (final leaf in leaves) { - if (leaf.tabIndex == tabIndex) { - if (root is SplitLeaf) { - // Last pane, don't close - continue; - } - closeLeaf(leaf.id); - } - } - // Adjust indices for tabs after the removed one - final allLeaves = []; - _collectLeaves(root, allLeaves); - for (final leaf in allLeaves) { - if (leaf.tabIndex > tabIndex) { - leaf.tabIndex--; - } - } - } - - SplitLeaf? focusedLeaf() { - if (focusedLeafId == null) return null; - return _findLeaf(root, focusedLeafId!); - } - - List allLeaves() { - final leaves = []; - _collectLeaves(root, leaves); - return leaves; - } - - void _collectLeaves(SplitNode node, List out) { - if (node is SplitLeaf) { - out.add(node); - } else if (node is SplitBranch) { - _collectLeaves(node.first, out); - _collectLeaves(node.second, out); - } - } - - SplitLeaf? _findLeaf(SplitNode node, String id) { - if (node is SplitLeaf && node.id == id) return node; - if (node is SplitBranch) { - return _findLeaf(node.first, id) ?? _findLeaf(node.second, id); - } - return null; - } - - SplitLeaf? _firstLeaf(SplitNode node) { - if (node is SplitLeaf) return node; - if (node is SplitBranch) return _firstLeaf(node.first); - return null; - } - - SplitBranch? _findParent(SplitNode node, String childId) { - if (node is SplitBranch) { - if (node.first.id == childId || node.second.id == childId) return node; - return _findParent(node.first, childId) ?? - _findParent(node.second, childId); - } - return null; - } - - void _replaceNode(String targetId, SplitNode replacement) { - if (root.id == targetId) { - root = replacement; - return; - } - _replaceInTree(root, targetId, replacement); - } - - void _replaceInTree(SplitNode node, String targetId, SplitNode replacement) { - if (node is SplitBranch) { - if (node.first.id == targetId) { - node.first = replacement; - return; - } - if (node.second.id == targetId) { - node.second = replacement; - return; - } - _replaceInTree(node.first, targetId, replacement); - _replaceInTree(node.second, targetId, replacement); - } - } -} - -class SplitView extends StatefulWidget { - final SplitViewController controller; - final Widget Function(int tabIndex, String leafId, bool focused) paneBuilder; - final void Function(String leafId) onFocusChanged; - final void Function() onChanged; - final int tabCount; - - const SplitView({ - super.key, - required this.controller, - required this.paneBuilder, - required this.onFocusChanged, - required this.onChanged, - required this.tabCount, - }); - - @override - State createState() => _SplitViewState(); -} - -class _SplitViewState extends State { - @override - Widget build(BuildContext context) { - return _buildNode(widget.controller.root); - } - - Widget _buildNode(SplitNode node) { - if (node is SplitLeaf) { - return _buildLeafPane(node); - } - if (node is SplitBranch) { - return _buildBranch(node); - } - return const SizedBox.shrink(); - } - - Widget _buildBranch(SplitBranch branch) { - final isHorizontal = branch.direction == SplitDirection.horizontal; - - return LayoutBuilder( - builder: (context, constraints) { - final totalSize = isHorizontal - ? constraints.maxWidth - : constraints.maxHeight; - final dividerSize = _kDividerThickness; - final availableSize = totalSize - dividerSize; - final firstSize = (availableSize * branch.ratio) - .clamp(_kMinPaneSize, availableSize - _kMinPaneSize); - final secondSize = availableSize - firstSize; - - final children = [ - SizedBox( - width: isHorizontal ? firstSize : null, - height: isHorizontal ? null : firstSize, - child: _buildNode(branch.first), - ), - GestureDetector( - behavior: HitTestBehavior.opaque, - onPanUpdate: (details) { - setState(() { - final delta = isHorizontal - ? details.delta.dx - : details.delta.dy; - final newFirstSize = (firstSize + delta) - .clamp(_kMinPaneSize, availableSize - _kMinPaneSize); - branch.ratio = newFirstSize / availableSize; - }); - widget.onChanged(); - }, - child: MouseRegion( - cursor: isHorizontal - ? SystemMouseCursors.resizeColumn - : SystemMouseCursors.resizeRow, - child: Container( - width: isHorizontal ? dividerSize : null, - height: isHorizontal ? null : dividerSize, - color: Theme.of(context).colorScheme.outlineVariant, - ), - ), - ), - SizedBox( - width: isHorizontal ? secondSize : null, - height: isHorizontal ? null : secondSize, - child: _buildNode(branch.second), - ), - ]; - - return isHorizontal - ? Row(children: children) - : Column(children: children); - }, - ); - } - - Widget _buildLeafPane(SplitLeaf leaf) { - final focused = widget.controller.focusedLeafId == leaf.id; - final tabIndex = leaf.tabIndex; - - if (tabIndex < 0 || tabIndex >= widget.tabCount) { - return Container(color: Colors.black); - } - - return GestureDetector( - onTap: () { - widget.controller.focusedLeafId = leaf.id; - widget.onFocusChanged(leaf.id); - }, - child: DragTarget( - builder: (context, candidateData, rejectedData) { - final showDropZones = candidateData.isNotEmpty; - return Stack( - children: [ - Container( - decoration: BoxDecoration( - border: Border.all( - color: focused - ? Theme.of(context).colorScheme.primary - : Colors.transparent, - width: 1, - ), - ), - child: widget.paneBuilder(tabIndex, leaf.id, focused), - ), - if (showDropZones) ..._buildDropZones(leaf), - ], - ); - }, - onWillAcceptWithDetails: (details) { - return details.data != leaf.tabIndex; - }, - onAcceptWithDetails: (details) { - // Determine drop position based on pointer location - // This is handled by individual drop zones - }, - ), - ); - } - - List _buildDropZones(SplitLeaf leaf) { - return [ - // Left - _dropZone( - leaf, - DropPosition.left, - Alignment.centerLeft, - FractionalOffset(0, 0), - _kDropZoneRatio, - 1.0, - ), - // Right - _dropZone( - leaf, - DropPosition.right, - Alignment.centerRight, - FractionalOffset(1 - _kDropZoneRatio, 0), - _kDropZoneRatio, - 1.0, - ), - // Top - _dropZone( - leaf, - DropPosition.top, - Alignment.topCenter, - FractionalOffset(0, 0), - 1.0, - _kDropZoneRatio, - ), - // Bottom - _dropZone( - leaf, - DropPosition.bottom, - Alignment.bottomCenter, - FractionalOffset(0, 1 - _kDropZoneRatio), - 1.0, - _kDropZoneRatio, - ), - ]; - } - - Widget _dropZone( - SplitLeaf leaf, - DropPosition position, - Alignment alignment, - FractionalOffset offset, - double widthFactor, - double heightFactor, - ) { - return Positioned.fill( - child: FractionallySizedBox( - alignment: alignment, - widthFactor: widthFactor, - heightFactor: heightFactor, - child: DragTarget( - builder: (context, candidates, rejected) { - return Container( - color: candidates.isNotEmpty - ? Theme.of(context).colorScheme.primary.withAlpha(60) - : Colors.transparent, - ); - }, - onWillAcceptWithDetails: (details) { - return details.data != leaf.tabIndex; - }, - onAcceptWithDetails: (details) { - setState(() { - widget.controller.splitLeaf(leaf.id, position, details.data); - }); - widget.onChanged(); - }, - ), - ), - ); - } -} diff --git a/packages/xterm/lib/src/core/buffer/buffer.dart b/packages/xterm/lib/src/core/buffer/buffer.dart index 1078938..7ec6785 100644 --- a/packages/xterm/lib/src/core/buffer/buffer.dart +++ b/packages/xterm/lib/src/core/buffer/buffer.dart @@ -190,7 +190,7 @@ class Buffer { /// cursor. void eraseLineToCursor() { currentLine.isWrapped = false; - currentLine.eraseRange(0, _cursorX, terminal.cursor); + currentLine.eraseRange(0, _cursorX.clamp(0, viewWidth), terminal.cursor); } /// Erases the line at the current cursor position. diff --git a/packages/xterm/lib/src/terminal_view.dart b/packages/xterm/lib/src/terminal_view.dart index cf2c3c3..2c72df5 100644 --- a/packages/xterm/lib/src/terminal_view.dart +++ b/packages/xterm/lib/src/terminal_view.dart @@ -380,7 +380,15 @@ class TerminalViewState extends State { final consumed = key == null ? false : widget.terminal.keyInput(key); if (!consumed) { - widget.terminal.textInput(text); + // Multi-character or newline-containing input is treated as a paste + // (e.g. from right-click paste or IME commit of multi-char text). + // Use terminal.paste() so bracketed paste mode is respected when the + // remote application has enabled it (e.g. PowerShell / PSReadLine). + if (text.length > 1 || text.contains('\n')) { + widget.terminal.paste(text); + } else { + widget.terminal.textInput(text); + } } _scrollToBottom(); diff --git a/packages/xterm/lib/src/ui/custom_text_edit.dart b/packages/xterm/lib/src/ui/custom_text_edit.dart index 6d01a81..8043358 100644 --- a/packages/xterm/lib/src/ui/custom_text_edit.dart +++ b/packages/xterm/lib/src/ui/custom_text_edit.dart @@ -58,6 +58,7 @@ class CustomTextEdit extends StatefulWidget { class CustomTextEditState extends State with TextInputClient { TextInputConnection? _connection; + bool _stateResetPending = false; @override void initState() { @@ -126,12 +127,13 @@ class CustomTextEditState extends State with TextInputClient { return; } - _connection?.setEditableSizeAndTransform( - rect.size, - Matrix4.translationValues(0, 0, 0), - ); + final renderObject = context.findRenderObject(); + final transform = + renderObject?.getTransformTo(null) ?? Matrix4.identity(); + _connection?.setEditableSizeAndTransform(rect.size, transform); _connection?.setCaretRect(caretRect); + _connection?.setComposingRect(caretRect); } void _onFocusChange() { @@ -178,6 +180,7 @@ class CustomTextEditState extends State with TextInputClient { _connection!.show(); } else { final config = TextInputConfiguration( + viewId: View.of(context).viewId, inputType: widget.inputType, inputAction: widget.inputAction, keyboardAppearance: widget.keyboardAppearance, @@ -215,6 +218,10 @@ class CustomTextEditState extends State with TextInputClient { late var _currentEditingState = _initEditingState.copyWith(); + /// The text that was confirmed just before the last state reset. + /// Used to distinguish reset echoes from new content (e.g. paste). + String _textBeforeReset = ''; + @override TextEditingValue? get currentTextEditingValue { return _currentEditingState; @@ -227,6 +234,29 @@ class CustomTextEditState extends State with TextInputClient { @override void updateEditingValue(TextEditingValue value) { + // After we reset the editing state, the platform may echo back the old + // confirmed value before processing the reset. Skip such echoes to + // prevent duplicate input. + if (_stateResetPending) { + if (value.text == _initEditingState.text) { + // Platform acknowledged the reset. + _stateResetPending = false; + _currentEditingState = value; + return; + } + if (value.composing.isCollapsed) { + if (value.text == _textBeforeReset) { + // Echo of previously confirmed text — skip. + return; + } + // New text arrived while reset was pending (e.g. paste) — process it. + _stateResetPending = false; + } else { + // New composing started; reset was implicitly processed. + _stateResetPending = false; + } + } + _currentEditingState = value; // Get input after composing is done @@ -246,12 +276,16 @@ class CustomTextEditState extends State with TextInputClient { _initEditingState.text.length, ); - widget.onInsert(textDelta); + if (textDelta.isNotEmpty) { + widget.onInsert(textDelta); + } } // Reset editing state if composing is done if (_currentEditingState.composing.isCollapsed && _currentEditingState.text != _initEditingState.text) { + _textBeforeReset = _currentEditingState.text; + _stateResetPending = true; _connection!.setEditingState(_initEditingState); } }