diff --git a/flutter/lib/desktop/pages/file_manager_page.dart b/flutter/lib/desktop/pages/file_manager_page.dart index 9d8ef6f7a..98f1481ff 100644 --- a/flutter/lib/desktop/pages/file_manager_page.dart +++ b/flutter/lib/desktop/pages/file_manager_page.dart @@ -7,6 +7,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_breadcrumb/flutter_breadcrumb.dart'; +import 'package:flutter_hbb/desktop/widgets/list_search_action_listener.dart'; import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart'; import 'package:flutter_hbb/models/file_model.dart'; import 'package:get/get.dart'; @@ -32,6 +33,18 @@ enum LocationStatus { fileSearchBar } +/// The status of currently focused scope of the mouse +enum MouseFocusScope { + /// Mouse is in local field. + local, + + /// Mouse is in remote field. + remote, + + /// Mouse is not in local field, remote neither. + none +} + class FileManagerPage extends StatefulWidget { const FileManagerPage({Key? key, required this.id}) : super(key: key); final String id; @@ -55,6 +68,11 @@ class _FileManagerPageState extends State final _searchTextRemote = "".obs; final _breadCrumbScrollerLocal = ScrollController(); final _breadCrumbScrollerRemote = ScrollController(); + final _mouseFocusScope = Rx(MouseFocusScope.none); + final _keyboardNodeLocal = FocusNode(debugLabel: "keyboardNodeLocal"); + final _keyboardNodeRemote = FocusNode(debugLabel: "keyboardNodeRemote"); + final _listSearchBufferLocal = TimeoutStringBuffer(); + final _listSearchBufferRemote = TimeoutStringBuffer(); /// [_lastClickTime], [_lastClickEntry] help to handle double click int _lastClickTime = @@ -197,6 +215,7 @@ class _FileManagerPageState extends State } Widget body({bool isLocal = false}) { + final scrollController = ScrollController(); return Container( decoration: BoxDecoration(border: Border.all(color: Colors.black26)), margin: const EdgeInsets.all(16.0), @@ -209,7 +228,8 @@ class _FileManagerPageState extends State onDragExited: (exit) { _dropMaskVisible.value = false; }, - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ headTools(isLocal), Expanded( child: Row( @@ -217,8 +237,8 @@ class _FileManagerPageState extends State children: [ Expanded( child: SingleChildScrollView( - controller: ScrollController(), - child: _buildDataTable(context, isLocal), + controller: scrollController, + child: _buildDataTable(context, isLocal, scrollController), ), ) ], @@ -228,7 +248,9 @@ class _FileManagerPageState extends State ); } - Widget _buildDataTable(BuildContext context, bool isLocal) { + Widget _buildDataTable( + BuildContext context, bool isLocal, ScrollController scrollController) { + const rowHeight = 25.0; final fd = model.getCurrentDir(isLocal); final entries = fd.entries; final sortIndex = (SortBy style) { @@ -246,127 +268,194 @@ class _FileManagerPageState extends State final sortAscending = isLocal ? model.localSortAscending : model.remoteSortAscending; - return ObxValue( - (searchText) { - final filteredEntries = searchText.isNotEmpty - ? entries.where((element) { - return element.name.contains(searchText.value); - }).toList(growable: false) - : entries; - return DataTable( - key: ValueKey(isLocal ? 0 : 1), - showCheckboxColumn: false, - dataRowHeight: 25, - headingRowHeight: 30, - horizontalMargin: 8, - columnSpacing: 8, - showBottomBorder: true, - sortColumnIndex: sortIndex, - sortAscending: sortAscending, - columns: [ - DataColumn( - label: Text( - translate("Name"), - ).marginSymmetric(horizontal: 4), - onSort: (columnIndex, ascending) { - model.changeSortStyle(SortBy.name, - isLocal: isLocal, ascending: ascending); - }), - DataColumn( - label: Text( - translate("Modified"), - ), - onSort: (columnIndex, ascending) { - model.changeSortStyle(SortBy.modified, - isLocal: isLocal, ascending: ascending); - }), - DataColumn( - label: Text(translate("Size")), - onSort: (columnIndex, ascending) { - model.changeSortStyle(SortBy.size, - isLocal: isLocal, ascending: ascending); - }), - ], - rows: filteredEntries.map((entry) { - final sizeStr = - entry.isFile ? readableFileSize(entry.size.toDouble()) : ""; - final lastModifiedStr = entry.isDrive - ? " " - : "${entry.lastModified().toString().replaceAll(".000", "")} "; - return DataRow( - key: ValueKey(entry.name), - onSelectChanged: (s) { - _onSelectedChanged(getSelectedItems(isLocal), filteredEntries, - entry, isLocal); - }, - selected: getSelectedItems(isLocal).contains(entry), - cells: [ - DataCell( - Container( - width: 200, - child: Tooltip( - waitDuration: Duration(milliseconds: 500), - message: entry.name, - child: Row(children: [ - entry.isDrive - ? Image( - image: iconHardDrive, - fit: BoxFit.scaleDown, + return MouseRegion( + onEnter: (evt) { + print("enter $evt"); + _mouseFocusScope.value = + isLocal ? MouseFocusScope.local : MouseFocusScope.remote; + if (isLocal) { + _keyboardNodeLocal.requestFocus(); + } else { + _keyboardNodeRemote.requestFocus(); + } + }, + onExit: (evt) { + print("exit $evt"); + _mouseFocusScope.value = MouseFocusScope.none; + // FocusManager.instance.primaryFocus?.unfocus(); + }, + child: ListSearchActionListener( + node: isLocal ? _keyboardNodeLocal : _keyboardNodeRemote, + buffer: isLocal ? _listSearchBufferLocal : _listSearchBufferRemote, + onNext: (buffer) { + debugPrint("searching next for $buffer"); + assert(buffer.length == 1); + final selectedEntries = getSelectedItems(isLocal); + assert(selectedEntries.length <= 1); + var skipCount = 0; + if (selectedEntries.items.isNotEmpty) { + final index = entries.indexOf(selectedEntries.items.first); + if (index < 0) { + return; + } + skipCount = index + 1; + } + var searchResult = entries + .skip(skipCount) + .where((element) => element.name.startsWith(buffer)); + if (searchResult.isEmpty) { + // loop + searchResult = + entries.where((element) => element.name.startsWith(buffer)); + } + if (searchResult.isEmpty) { + return; + } + final offset = entries.indexOf(searchResult.first) * rowHeight; + setState(() { + selectedEntries.clear(); + selectedEntries.add(isLocal, searchResult.first); + debugPrint("focused on ${searchResult.first.name}"); + }); + }, + onSearch: (buffer) { + debugPrint("searching for $buffer"); + final selectedEntries = getSelectedItems(isLocal); + final searchResult = + entries.where((element) => element.name.startsWith(buffer)); + selectedEntries.clear(); + if (searchResult.isEmpty) { + return; + } + setState(() { + selectedEntries.add(isLocal, searchResult.first); + debugPrint("focused on ${searchResult.first.name}"); + }); + }, + child: ObxValue( + (searchText) { + final filteredEntries = searchText.isNotEmpty + ? entries.where((element) { + return element.name.contains(searchText.value); + }).toList(growable: false) + : entries; + return DataTable( + key: ValueKey(isLocal ? 0 : 1), + showCheckboxColumn: false, + dataRowHeight: rowHeight, + headingRowHeight: 30, + horizontalMargin: 8, + columnSpacing: 8, + showBottomBorder: true, + sortColumnIndex: sortIndex, + sortAscending: sortAscending, + columns: [ + DataColumn( + label: Text( + translate("Name"), + ).marginSymmetric(horizontal: 4), + onSort: (columnIndex, ascending) { + model.changeSortStyle(SortBy.name, + isLocal: isLocal, ascending: ascending); + }), + DataColumn( + label: Text( + translate("Modified"), + ), + onSort: (columnIndex, ascending) { + model.changeSortStyle(SortBy.modified, + isLocal: isLocal, ascending: ascending); + }), + DataColumn( + label: Text(translate("Size")), + onSort: (columnIndex, ascending) { + model.changeSortStyle(SortBy.size, + isLocal: isLocal, ascending: ascending); + }), + ], + rows: filteredEntries.map((entry) { + final sizeStr = + entry.isFile ? readableFileSize(entry.size.toDouble()) : ""; + final lastModifiedStr = entry.isDrive + ? " " + : "${entry.lastModified().toString().replaceAll(".000", "")} "; + return DataRow( + key: ValueKey(entry.name), + onSelectChanged: (s) { + _onSelectedChanged(getSelectedItems(isLocal), + filteredEntries, entry, isLocal); + }, + selected: getSelectedItems(isLocal).contains(entry), + cells: [ + DataCell( + Container( + width: 200, + child: Tooltip( + waitDuration: Duration(milliseconds: 500), + message: entry.name, + child: Row(children: [ + entry.isDrive + ? Image( + image: iconHardDrive, + fit: BoxFit.scaleDown, + color: Theme.of(context) + .iconTheme + .color + ?.withOpacity(0.7)) + .paddingAll(4) + : Icon( + entry.isFile + ? Icons.feed_outlined + : Icons.folder, + size: 20, color: Theme.of(context) .iconTheme .color - ?.withOpacity(0.7)) - .paddingAll(4) - : Icon( - entry.isFile - ? Icons.feed_outlined - : Icons.folder, - size: 20, - color: Theme.of(context) - .iconTheme - .color - ?.withOpacity(0.7), - ).marginSymmetric(horizontal: 2), - Expanded( - child: Text(entry.name, - overflow: TextOverflow.ellipsis)) - ]), - )), - onTap: () { - final items = getSelectedItems(isLocal); - - // handle double click - if (_checkDoubleClick(entry)) { - openDirectory(entry.path, isLocal: isLocal); - items.clear(); - return; - } - _onSelectedChanged( - items, filteredEntries, entry, isLocal); - }, - ), - DataCell(FittedBox( - child: Tooltip( + ?.withOpacity(0.7), + ).marginSymmetric(horizontal: 2), + Expanded( + child: Text(entry.name, + overflow: TextOverflow.ellipsis)) + ]), + )), + onTap: () { + final items = getSelectedItems(isLocal); + + // handle double click + if (_checkDoubleClick(entry)) { + openDirectory(entry.path, isLocal: isLocal); + items.clear(); + return; + } + _onSelectedChanged( + items, filteredEntries, entry, isLocal); + }, + ), + DataCell(FittedBox( + child: Tooltip( + waitDuration: Duration(milliseconds: 500), + message: lastModifiedStr, + child: Text( + lastModifiedStr, + style: TextStyle( + fontSize: 12, color: MyTheme.darkGray), + )))), + DataCell(Tooltip( waitDuration: Duration(milliseconds: 500), - message: lastModifiedStr, + message: sizeStr, child: Text( - lastModifiedStr, - style: TextStyle( - fontSize: 12, color: MyTheme.darkGray), - )))), - DataCell(Tooltip( - waitDuration: Duration(milliseconds: 500), - message: sizeStr, - child: Text( - sizeStr, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 10, color: MyTheme.darkGray), - ))), - ]); - }).toList(growable: false), - ); - }, - isLocal ? _searchTextLocal : _searchTextRemote, + sizeStr, + overflow: TextOverflow.ellipsis, + style: + TextStyle(fontSize: 10, color: MyTheme.darkGray), + ))), + ]); + }).toList(growable: false), + ); + }, + isLocal ? _searchTextLocal : _searchTextRemote, + ), + ), ); } @@ -1015,4 +1104,14 @@ class _FileManagerPageState extends State } model.sendFiles(items, isRemote: false); } + + void refocusKeyboardListener(bool isLocal) { + Future.delayed(Duration.zero, () { + if (isLocal) { + _keyboardNodeLocal.requestFocus(); + } else { + _keyboardNodeRemote.requestFocus(); + } + }); + } } diff --git a/flutter/lib/desktop/widgets/list_search_action_listener.dart b/flutter/lib/desktop/widgets/list_search_action_listener.dart new file mode 100644 index 000000000..9598c3400 --- /dev/null +++ b/flutter/lib/desktop/widgets/list_search_action_listener.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +class ListSearchActionListener extends StatelessWidget { + final FocusNode node; + final TimeoutStringBuffer buffer; + final Widget child; + final Function(String) onNext; + final Function(String) onSearch; + + const ListSearchActionListener( + {super.key, + required this.node, + required this.buffer, + required this.child, + required this.onNext, + required this.onSearch}); + + @mustCallSuper + @override + Widget build(BuildContext context) { + return KeyboardListener( + autofocus: true, + onKeyEvent: (kv) { + final ch = kv.character; + if (ch == null) { + return; + } + final action = buffer.input(ch); + switch (action) { + case ListSearchAction.search: + onSearch(buffer.buffer); + break; + case ListSearchAction.next: + onNext(buffer.buffer); + break; + } + }, + focusNode: node, + child: child); + } +} + +enum ListSearchAction { search, next } + +class TimeoutStringBuffer { + var _buffer = ""; + late DateTime _duration; + + static int timeoutMilliSec = 1500; + + String get buffer => _buffer; + + TimeoutStringBuffer() { + _duration = DateTime.now(); + } + + ListSearchAction input(String ch) { + final curr = DateTime.now(); + try { + if (curr.difference(_duration).inMilliseconds > timeoutMilliSec) { + _buffer = ch; + return ListSearchAction.search; + } else { + if (ch == _buffer) { + return ListSearchAction.next; + } else { + _buffer += ch; + return ListSearchAction.search; + } + } + } finally { + _duration = curr; + } + } +} diff --git a/flutter/lib/models/file_model.dart b/flutter/lib/models/file_model.dart index a7968f701..142479c2a 100644 --- a/flutter/lib/models/file_model.dart +++ b/flutter/lib/models/file_model.dart @@ -213,7 +213,7 @@ class FileModel extends ChangeNotifier { } receiveFileDir(Map evt) { - debugPrint("recv file dir:$evt"); + // debugPrint("recv file dir:$evt"); if (evt['is_local'] == "false") { // init remote home, the connection will automatic read remote home when established, try {