diff --git a/flutter/lib/desktop/pages/file_manager_page.dart b/flutter/lib/desktop/pages/file_manager_page.dart index 17674c268..eb7cd2bfd 100644 --- a/flutter/lib/desktop/pages/file_manager_page.dart +++ b/flutter/lib/desktop/pages/file_manager_page.dart @@ -497,7 +497,12 @@ class _FileManagerViewState extends State { }, onExit: (evt) => widget._mouseFocusScope.value = MouseFocusScope.none, - child: _buildFileList(context, _fileListScrollController), + child: Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: _onFileListKeyEvent, + child: _buildFileList(context, _fileListScrollController), + ), )) ], ), @@ -1135,6 +1140,7 @@ class _FileManagerViewState extends State { : "${entry.lastModified().toString().replaceAll(".000", "")} "; var secondaryPosition = RelativeRect.fromLTRB(0, 0, 0, 0); onTap() { + _keyboardNode.requestFocus(); final items = selectedItems; // handle double click if (_checkDoubleClick(entry)) { @@ -1363,6 +1369,102 @@ class _FileManagerViewState extends State { debugPrint("focused on ${searchResult.first.name}"); } + KeyEventResult _onFileListKeyEvent(FocusNode node, KeyEvent event) { + // A focused child such as a sort header keeps its default keys, e.g. + // Enter to sort. + if (!_keyboardNode.hasPrimaryFocus) return KeyEventResult.ignored; + for (final binding in _fileListKeyBindings().entries) { + if (binding.key.accepts(event, HardwareKeyboard.instance)) { + binding.value(); + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + } + + Map _fileListKeyBindings() { + int all() => controller.directory.value.entries.length; + return { + const SingleActivator(LogicalKeyboardKey.arrowUp): () => + _moveSelectionByKey(-1, false), + const SingleActivator(LogicalKeyboardKey.arrowDown): () => + _moveSelectionByKey(1, false), + const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): () => + _moveSelectionByKey(-1, true), + const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): () => + _moveSelectionByKey(1, true), + // Unhandled, they would move keyboard focus to the other pane. + const SingleActivator(LogicalKeyboardKey.arrowLeft): () {}, + const SingleActivator(LogicalKeyboardKey.arrowRight): () {}, + const SingleActivator(LogicalKeyboardKey.pageUp): () => + _moveSelectionByKey(_pageDelta(false), false), + const SingleActivator(LogicalKeyboardKey.pageDown): () => + _moveSelectionByKey(_pageDelta(true), false), + const SingleActivator(LogicalKeyboardKey.home): () => + _moveSelectionByKey(-all(), false), + const SingleActivator(LogicalKeyboardKey.end): () => + _moveSelectionByKey(all(), false), + const SingleActivator(LogicalKeyboardKey.pageUp, shift: true): () => + _moveSelectionByKey(_pageDelta(false), true), + const SingleActivator(LogicalKeyboardKey.pageDown, shift: true): () => + _moveSelectionByKey(_pageDelta(true), true), + const SingleActivator(LogicalKeyboardKey.home, shift: true): () => + _moveSelectionByKey(-all(), true), + const SingleActivator(LogicalKeyboardKey.end, shift: true): () => + _moveSelectionByKey(all(), true), + const SingleActivator(LogicalKeyboardKey.enter): _openSelectedDirectory, + const SingleActivator(LogicalKeyboardKey.numpadEnter): + _openSelectedDirectory, + }; + } + + void _openSelectedDirectory() { + final items = selectedItems.items; + if (items.length != 1 || items.first.isFile) return; + controller.openDirectory(items.first.path); + selectedItems.clear(); + } + + List _filteredEntries() { + final entries = controller.directory.value.entries; + return _searchText.isNotEmpty + ? entries.where((element) { + return element.name.contains(_searchText.value); + }).toList(growable: false) + : entries; + } + + int _pageDelta(bool down) { + final entries = _filteredEntries(); + final items = selectedItems.items; + final index = items.isEmpty ? -1 : entries.indexOf(items.last); + final position = _fileListScrollController.position; + final first = (position.pixels / kDesktopFileTransferRowHeight).ceil(); + final last = ((position.pixels + position.viewportDimension) / + kDesktopFileTransferRowHeight) + .floor() - + 1; + return pageDelta( + index >= 0 ? index : (down ? -1 : entries.length), first, last, down); + } + + void _moveSelectionByKey(int delta, bool extend) { + final filteredEntries = _filteredEntries(); + final selection = + navigateSelection(filteredEntries, selectedItems.items, delta, extend); + if (selection.isEmpty) return; + selectedItems.items.assignAll(selection.where((e) => !e.isDrive)); + final position = _fileListScrollController.position; + final top = + filteredEntries.indexOf(selection.last) * kDesktopFileTransferRowHeight; + final bottom = top + kDesktopFileTransferRowHeight; + if (top < position.pixels) { + _fileListScrollController.jumpTo(top); + } else if (bottom > position.pixels + position.viewportDimension) { + _fileListScrollController.jumpTo(bottom - position.viewportDimension); + } + } + void _onSelectedChanged(SelectedItems selectedItems, List entries, Entry entry, bool isLocal) { final isCtrlDown = RawKeyboard.instance.keysPressed @@ -1717,6 +1819,36 @@ class _FileManagerViewState extends State { // } } +/// Selection after a navigation key moves it by [delta] rows. A shift range is +/// returned ordered from its anchor to the moved end, so the first and last +/// items of [selected] stand for those two ends on the next press without +/// separate state. +@visibleForTesting +List navigateSelection( + List entries, List selected, int delta, bool extend) { + if (entries.isEmpty) return []; + final current = selected.isEmpty ? -1 : entries.indexOf(selected.last); + // With nothing selected, moving down starts above the first row and moving + // up below the last one, as the Sciter grid did. + final lead = current >= 0 ? current : (delta > 0 ? -1 : entries.length); + final next = (lead + delta).clamp(0, entries.length - 1); + if (!extend || current < 0) return [entries[next]]; + final first = entries.indexOf(selected.first); + final anchor = first < 0 ? current : first; + final step = next < anchor ? -1 : 1; + return [for (var i = anchor; i != next + step; i += step) entries[i]]; +} + +/// Rows a PageUp/PageDown press moves from [lead] while rows [first]..[last] +/// are fully visible: to that edge first, then a page further, as list views +/// do. +@visibleForTesting +int pageDelta(int lead, int first, int last, bool down) { + final page = max(1, last - first); + if (down) return lead < last ? last - lead : page; + return lead > first ? first - lead : -page; +} + Widget buildWindowsThisPC(BuildContext context, [TextStyle? textStyle]) { final color = Theme.of(context).iconTheme.color?.withOpacity(0.7); return Row(children: [ diff --git a/flutter/lib/desktop/widgets/list_search_action_listener.dart b/flutter/lib/desktop/widgets/list_search_action_listener.dart index 36128bf26..0032231c4 100644 --- a/flutter/lib/desktop/widgets/list_search_action_listener.dart +++ b/flutter/lib/desktop/widgets/list_search_action_listener.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; class ListSearchActionListener extends StatelessWidget { final FocusNode node; @@ -22,7 +23,7 @@ class ListSearchActionListener extends StatelessWidget { autofocus: true, onKeyEvent: (kv) { final ch = kv.character; - if (ch == null) { + if (ch == null || LogicalKeyboardKey.isControlCharacter(ch)) { return; } final action = buffer.input(ch); diff --git a/flutter/test/file_manager_navigation_keys_test.dart b/flutter/test/file_manager_navigation_keys_test.dart new file mode 100644 index 000000000..896d18eaa --- /dev/null +++ b/flutter/test/file_manager_navigation_keys_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hbb/desktop/pages/file_manager_page.dart'; +import 'package:flutter_hbb/desktop/widgets/list_search_action_listener.dart'; +import 'package:flutter_hbb/models/file_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +List _entries(int count) => [ + for (var i = 0; i < count; i++) + Entry() + ..name = 'f$i' + ..path = '/f$i' + ]; + +List _names(List entries) => + entries.map((e) => e.name).toList()..sort(); + +void main() { + test('navigation keys move the selection and stop at the ends', () { + final entries = _entries(5); + expect(_names(navigateSelection(entries, [], 1, false)), ['f0']); + expect(_names(navigateSelection(entries, [], -1, false)), ['f4']); + var selected = navigateSelection(entries, [entries[1]], 1, false); + expect(_names(selected), ['f2']); + selected = navigateSelection(entries, selected, -1, false); + expect(_names(selected), ['f1']); + expect(_names(navigateSelection(entries, selected, -3, false)), ['f0']); + expect(_names(navigateSelection(entries, selected, 5, false)), ['f4']); + }); + + test('shift extends and shrinks the range from the anchor', () { + final entries = _entries(5); + var selected = [entries[2]]; + selected = navigateSelection(entries, selected, 1, true); + selected = navigateSelection(entries, selected, 1, true); + expect(_names(selected), ['f2', 'f3', 'f4']); + selected = navigateSelection(entries, selected, -1, true); + selected = navigateSelection(entries, selected, -1, true); + selected = navigateSelection(entries, selected, -1, true); + expect(_names(selected), ['f1', 'f2']); + selected = navigateSelection(entries, selected, 1, false); + expect(_names(selected), ['f2']); + }); + + test('page keys go to the visible edge before paging', () { + // Rows 10..19 are fully visible. + expect(pageDelta(12, 10, 19, true), 7); + expect(pageDelta(19, 10, 19, true), 9); + expect(pageDelta(12, 10, 19, false), -2); + expect(pageDelta(10, 10, 19, false), -9); + }); + + testWidgets('Enter does not reach type-to-search', (tester) async { + final node = FocusNode(); + addTearDown(node.dispose); + final searched = []; + await tester.pumpWidget(MaterialApp( + home: ListSearchActionListener( + node: node, + buffer: TimeoutStringBuffer(), + onNext: searched.add, + onSearch: searched.add, + child: const SizedBox(), + ), + )); + node.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter, character: '\r'); + expect(searched, isEmpty); + }); +}