mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-11-04 00:33:12 +00:00
fix(file-transfer): keyboard navigation in the desktop file list (#16642)
* fix(file-transfer): keyboard navigation in the desktop file list Up/Down never moved the selection in the desktop file transfer list. The arrow keys fell through to directional focus traversal instead, which moved focus out of the list and stopped type-to-search until the mouse entered the list again. Handle the navigation keys on the list, as the Sciter grid did: - Up/Down move the selection one row; Shift extends or shrinks a range from its anchor. - PageUp/PageDown go to the edge of the visible rows first, then a page further; Home/End go to the first/last row. Shift extends with each. - Enter/numpad Enter opens the selected folder, like a double click. - Left/Right are consumed so focus does not jump to the other pane. Type-to-search now ignores control characters. On macOS and Linux Enter carries "\r", which matched no name and cleared the selection before Enter could open it. Fixes #16635 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(file-transfer): keep default keys on focused file list children The navigation bindings wrapped the whole file list, sort headers included, so a header focused with Tab lost Enter to sort and the arrow keys to move between headers: Enter ran the open-folder binding instead. Handle the bindings only while the list itself has primary focus and let keys on a focused child take their default path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(file-transfer): focus the file list when a row is clicked The navigation keys only act while the file list holds primary focus. Keyboard focus was handed to the list only when the mouse entered it, so after Tab moved focus to a sort header, clicking a row left the arrow keys inert until the mouse left and re-entered the list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b44b71cfab
commit
2016f48dd7
3 files changed
+206
-2
No files matched your search
@@ -497,7 +497,12 @@ class _FileManagerViewState extends State<FileManagerView> {
|
||||
},
|
||||
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<FileManagerView> {
|
||||
: "${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<FileManagerView> {
|
||||
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<ShortcutActivator, VoidCallback> _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<Entry> _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<Entry> entries,
|
||||
Entry entry, bool isLocal) {
|
||||
final isCtrlDown = RawKeyboard.instance.keysPressed
|
||||
@@ -1717,6 +1819,36 @@ class _FileManagerViewState extends State<FileManagerView> {
|
||||
// }
|
||||
}
|
||||
|
||||
/// 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<Entry> navigateSelection(
|
||||
List<Entry> entries, List<Entry> 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: [
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user