diff --git a/qml/components/FileGridView.qml b/qml/components/FileGridView.qml index a061600..33af843 100644 --- a/qml/components/FileGridView.qml +++ b/qml/components/FileGridView.qml @@ -7,11 +7,11 @@ Item { required property var session required property var files + required property var keyboardController property real uiScale: 1 property bool paneActive: true property bool restoring: false - property bool pointerSelection: false readonly property bool remote: root.session && root.session.remote readonly property bool previewOpen: !root.remote && root.session && root.session.previewVisible readonly property real previewWidth: Math.min( @@ -23,6 +23,12 @@ Item { clip: true + function focusView() { + if (!root.paneActive) + return + view.forceActiveFocus() + } + function restoreState() { if (!root.session || !root.files || root.session.model !== root.files) return @@ -47,6 +53,8 @@ Item { Qt.callLater(function() { root.restoring = false + if (root.paneActive) + root.focusView() }) }) } @@ -59,6 +67,10 @@ Item { restoring = true restoreState() } + onPaneActiveChanged: { + if (root.paneActive) + Qt.callLater(root.focusView) + } Shortcut { sequence: "Ctrl+Shift+P" @@ -94,13 +106,11 @@ Item { currentIndex: -1 boundsBehavior: Flickable.StopAtBounds reuseItems: true + activeFocusOnTab: root.paneActive - onCurrentIndexChanged: { - if (root.restoring || root.pointerSelection || !activeFocus || !root.session || !root.files || - root.files.loading || currentIndex < 0) - return - root.session.selectSingle(currentIndex) - } + readonly property int keyboardColumns: Math.max(1, Math.floor(width / Math.max(1, cellWidth))) + readonly property int keyboardRowsPerPage: Math.max(1, Math.floor(height / Math.max(1, cellHeight))) + readonly property int keyboardPageStep: keyboardColumns * keyboardRowsPerPage onContentYChanged: { if (!root.restoring && root.session && root.files && !root.files.loading) @@ -121,6 +131,8 @@ Item { var revision = root.session ? root.session.selectionRevision : 0 return revision >= 0 && root.session && root.session.isSelectedPath(filePath) } + readonly property bool currentItem: + root.paneActive && view.activeFocus && view.currentIndex === tile.index width: view.cellWidth - 8 * root.uiScale height: view.cellHeight - 8 * root.uiScale @@ -128,8 +140,10 @@ Item { color: selected ? Ryoku.bone : (mouse.containsMouse ? Ryoku.tint5 : "transparent") - border.width: selected ? 1 : 0 - border.color: selected ? Ryoku.bone : "transparent" + border.width: currentItem ? 1 : (selected ? 1 : 0) + border.color: currentItem + ? (selected ? Ryoku.inkOnBoneDim : Ryoku.lineStrong) + : (selected ? Ryoku.bone : "transparent") Column { anchors.fill: parent @@ -211,7 +225,6 @@ Item { onClicked: function(event) { root.paneActivated() - root.pointerSelection = true view.currentIndex = tile.index if (event.button === Qt.RightButton) { @@ -220,7 +233,6 @@ Item { var point = tile.mapToItem(null, event.x, event.y) root.contextRequested(point.x, point.y, tile.filePath, tile.isDir) - root.pointerSelection = false view.forceActiveFocus() return } @@ -232,7 +244,6 @@ Item { else root.session.selectSingle(tile.index) - root.pointerSelection = false view.forceActiveFocus() } @@ -240,12 +251,22 @@ Item { root.paneActivated() view.currentIndex = tile.index root.session.activate(tile.index) + view.forceActiveFocus() } } } - Keys.onReturnPressed: if (root.session) root.session.activate(currentIndex) - Keys.onEnterPressed: if (root.session) root.session.activate(currentIndex) + Keys.onPressed: function(event) { + if (root.keyboardController.handleKey( + event, + view, + true, + view.keyboardColumns, + view.keyboardPageStep, + GridView.Contain)) { + event.accepted = true + } + } } PreviewPanel { @@ -295,7 +316,9 @@ Item { TapHandler { onTapped: { root.paneActivated() - if (root.session && !root.remote) root.session.previewVisible = true + if (root.session && !root.remote) + root.session.previewVisible = true + Qt.callLater(root.focusView) } } } @@ -311,8 +334,8 @@ Item { } Component.onCompleted: { - if (root.paneActive) - view.forceActiveFocus() restoreState() + if (root.paneActive) + Qt.callLater(root.focusView) } } diff --git a/qml/components/FileListView.qml b/qml/components/FileListView.qml index 5ab1924..9e83ded 100644 --- a/qml/components/FileListView.qml +++ b/qml/components/FileListView.qml @@ -7,12 +7,12 @@ Item { required property var session required property var files + required property var keyboardController property real uiScale: 1 property bool compact: false property bool paneActive: true property bool restoring: false - property bool pointerSelection: false readonly property bool remote: root.session && root.session.remote readonly property bool previewOpen: !root.remote && root.session && root.session.previewVisible readonly property real previewWidth: Math.min( @@ -24,6 +24,12 @@ Item { clip: true + function focusView() { + if (!root.paneActive) + return + view.forceActiveFocus() + } + function restoreState() { if (!root.session || !root.files || root.session.model !== root.files) return @@ -48,6 +54,8 @@ Item { Qt.callLater(function() { root.restoring = false + if (root.paneActive) + root.focusView() }) }) } @@ -60,6 +68,10 @@ Item { restoring = true restoreState() } + onPaneActiveChanged: { + if (root.paneActive) + Qt.callLater(root.focusView) + } Shortcut { sequence: "Ctrl+Shift+P" @@ -93,13 +105,11 @@ Item { currentIndex: -1 boundsBehavior: Flickable.StopAtBounds reuseItems: true + activeFocusOnTab: root.paneActive - onCurrentIndexChanged: { - if (root.restoring || root.pointerSelection || !activeFocus || !root.session || !root.files || - root.files.loading || currentIndex < 0) - return - root.session.selectSingle(currentIndex) - } + readonly property int keyboardPageStep: Math.max( + 1, + Math.floor(height / Math.max(1, (root.compact ? 34 : 44) * root.uiScale))) onContentYChanged: { if (!root.restoring && root.session && root.files && !root.files.loading) @@ -120,6 +130,8 @@ Item { var revision = root.session ? root.session.selectionRevision : 0 return revision >= 0 && root.session && root.session.isSelectedPath(filePath) } + readonly property bool currentItem: + root.paneActive && view.activeFocus && view.currentIndex === row.index width: view.width height: (root.compact ? 34 : 44) * root.uiScale @@ -127,6 +139,10 @@ Item { color: selected ? Ryoku.bone : (mouse.containsMouse ? Ryoku.tint5 : "transparent") + border.width: currentItem ? 1 : 0 + border.color: currentItem + ? (selected ? Ryoku.inkOnBoneDim : Ryoku.lineStrong) + : "transparent" Row { anchors.fill: parent @@ -193,7 +209,6 @@ Item { onClicked: function(event) { root.paneActivated() - root.pointerSelection = true view.currentIndex = row.index if (event.button === Qt.RightButton) { @@ -202,7 +217,6 @@ Item { var point = row.mapToItem(null, event.x, event.y) root.contextRequested(point.x, point.y, row.filePath, row.isDir) - root.pointerSelection = false view.forceActiveFocus() return } @@ -214,7 +228,6 @@ Item { else root.session.selectSingle(row.index) - root.pointerSelection = false view.forceActiveFocus() } @@ -222,12 +235,22 @@ Item { root.paneActivated() view.currentIndex = row.index root.session.activate(row.index) + view.forceActiveFocus() } } } - Keys.onReturnPressed: if (root.session) root.session.activate(currentIndex) - Keys.onEnterPressed: if (root.session) root.session.activate(currentIndex) + Keys.onPressed: function(event) { + if (root.keyboardController.handleKey( + event, + view, + false, + 1, + view.keyboardPageStep, + ListView.Contain)) { + event.accepted = true + } + } } PreviewPanel { @@ -277,7 +300,9 @@ Item { TapHandler { onTapped: { root.paneActivated() - if (root.session && !root.remote) root.session.previewVisible = true + if (root.session && !root.remote) + root.session.previewVisible = true + Qt.callLater(root.focusView) } } } @@ -293,8 +318,8 @@ Item { } Component.onCompleted: { - if (root.paneActive) - view.forceActiveFocus() restoreState() + if (root.paneActive) + Qt.callLater(root.focusView) } } diff --git a/qml/components/FilePane.qml b/qml/components/FilePane.qml index 41a8032..64a4efe 100644 --- a/qml/components/FilePane.qml +++ b/qml/components/FilePane.qml @@ -25,6 +25,155 @@ Item { root.contextRequested(sceneX, sceneY, path, isDirectory, root.paneIndex) } + function focusView() { + if (viewLoader.item && viewLoader.item.focusView) + viewLoader.item.focusView() + } + + function activateAndFocus() { + root.activated(root.paneIndex) + Qt.callLater(root.focusView) + } + + onPaneActiveChanged: { + if (root.paneActive) + Qt.callLater(root.focusView) + } + + Item { + id: keyboardController + visible: false + width: 0 + height: 0 + + property string typeAheadBuffer: "" + property double typeAheadDeadlineMs: 0 + + function currentOrInitial(view) { + if (!root.files || root.files.count <= 0) + return -1 + if (view.currentIndex >= 0 && view.currentIndex < root.files.count) + return view.currentIndex + if (root.session && root.session.selectedPath !== "") { + var selected = root.files.indexOfPath(root.session.selectedPath) + if (selected >= 0) + return selected + } + return 0 + } + + function moveTo(view, index, modifiers, positionMode) { + if (!root.files || root.files.count <= 0 || !root.session) + return false + + var target = Math.max(0, Math.min(root.files.count - 1, index)) + view.currentIndex = target + view.positionViewAtIndex(target, positionMode) + + if (modifiers & Qt.ShiftModifier) + root.session.selectRange(target) + else if ((modifiers & Qt.ControlModifier) === 0) + root.session.selectSingle(target) + return true + } + + function findPrefix(prefix, startIndex) { + if (!root.files || root.files.count <= 0 || prefix === "") + return -1 + var needle = prefix.toLowerCase() + var count = root.files.count + for (var offset = 0; offset < count; ++offset) { + var index = (startIndex + offset) % count + var name = root.files.nameAt(index) + if (name && name.toLowerCase().indexOf(needle) === 0) + return index + } + return -1 + } + + function typeAhead(text, view, positionMode) { + if (!text || text.length === 0 || !root.files || root.files.count <= 0) + return false + + var now = Date.now() + var nextBuffer = now <= typeAheadDeadlineMs + ? typeAheadBuffer + text + : text + var current = currentOrInitial(view) + var start = current >= 0 ? (current + 1) % root.files.count : 0 + var match = findPrefix(nextBuffer, start) + + if (match < 0 && nextBuffer.length > 1) { + nextBuffer = text + match = findPrefix(nextBuffer, start) + } + if (match < 0) + return false + + typeAheadBuffer = nextBuffer + typeAheadDeadlineMs = now + 900 + return moveTo(view, match, 0, positionMode) + } + + function handleKey(event, view, gridMode, columns, pageStep, positionMode) { + if (!root.paneActive || !root.session || !root.files || root.files.loading) + return false + + var current = currentOrInitial(view) + var modifiers = event.modifiers + var target = current + var handled = true + + if (event.key === Qt.Key_Up) + target = current - Math.max(1, columns) + else if (event.key === Qt.Key_Down) + target = current + Math.max(1, columns) + else if (event.key === Qt.Key_Left && gridMode) + target = current - 1 + else if (event.key === Qt.Key_Right && gridMode) + target = current + 1 + else if (event.key === Qt.Key_Home) + target = 0 + else if (event.key === Qt.Key_End) + target = root.files.count - 1 + else if (event.key === Qt.Key_PageUp) + target = current - Math.max(1, pageStep) + else if (event.key === Qt.Key_PageDown) + target = current + Math.max(1, pageStep) + else if (event.key === Qt.Key_Space && (modifiers & Qt.ControlModifier)) { + if (current >= 0) { + view.currentIndex = current + view.positionViewAtIndex(current, positionMode) + root.session.toggleSelection(current) + } + return true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + if (current >= 0) + root.session.activate(current) + return true + } else if (event.key === Qt.Key_Escape) { + root.session.clearSelection() + return true + } else { + handled = false + } + + if (handled) { + if (current < 0) + target = 0 + return moveTo(view, target, modifiers, positionMode) + } + + var text = event.text + var blockedModifiers = Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier + if (text && text.length > 0 && (modifiers & blockedModifiers) === 0 + && text.charCodeAt(0) >= 32) { + return typeAhead(text, view, positionMode) + } + return false + } + } + Rectangle { anchors.fill: parent color: "transparent" @@ -67,7 +216,7 @@ Item { } HoverHandler { cursorShape: Qt.PointingHandCursor } - TapHandler { onTapped: root.activated(root.paneIndex) } + TapHandler { onTapped: root.activateAndFocus() } } Component { @@ -75,10 +224,11 @@ Item { FileListView { session: root.session files: root.files + keyboardController: keyboardController uiScale: root.uiScale compact: true paneActive: root.paneActive - onPaneActivated: root.activated(root.paneIndex) + onPaneActivated: root.activateAndFocus() onContextRequested: function(sceneX, sceneY, path, isDirectory) { root.routeContext(sceneX, sceneY, path, isDirectory) } @@ -90,9 +240,10 @@ Item { FileGridView { session: root.session files: root.files + keyboardController: keyboardController uiScale: root.uiScale paneActive: root.paneActive - onPaneActivated: root.activated(root.paneIndex) + onPaneActivated: root.activateAndFocus() onContextRequested: function(sceneX, sceneY, path, isDirectory) { root.routeContext(sceneX, sceneY, path, isDirectory) } @@ -104,10 +255,11 @@ Item { FileListView { session: root.session files: root.files + keyboardController: keyboardController uiScale: root.uiScale compact: false paneActive: root.paneActive - onPaneActivated: root.activated(root.paneIndex) + onPaneActivated: root.activateAndFocus() onContextRequested: function(sceneX, sceneY, path, isDirectory) { root.routeContext(sceneX, sceneY, path, isDirectory) } @@ -115,6 +267,7 @@ Item { } Loader { + id: viewLoader anchors.fill: parent anchors.margins: 2 * root.uiScale active: root.session !== null && root.files !== null @@ -127,6 +280,10 @@ Item { return gridView return detailsView } + onLoaded: { + if (root.paneActive) + Qt.callLater(root.focusView) + } } Text { diff --git a/qml/components/FolderFilterBar.qml b/qml/components/FolderFilterBar.qml index 6fade25..127f4b5 100644 --- a/qml/components/FolderFilterBar.qml +++ b/qml/components/FolderFilterBar.qml @@ -15,11 +15,21 @@ Item { readonly property bool remote: session && session.remote signal deepSearchRequested(string query) + signal focusReturnRequested() height: active ? 42 * uiScale : 0 visible: height > 0 clip: true + function returnFocus() { + Qt.callLater(function() { + if (root.parent && root.parent.focusView) + root.parent.focusView() + else + root.focusReturnRequested() + }) + } + function open() { if (!files) return @@ -39,6 +49,7 @@ Item { field.text = "" field.focus = false expanded = false + root.returnFocus() } onDeepSearchRequested: function(query) { @@ -125,6 +136,7 @@ Item { Keys.onReturnPressed: function(event) { focus = false + root.returnFocus() event.accepted = true } } diff --git a/qml/components/SplitPaneContainer.qml b/qml/components/SplitPaneContainer.qml index fa19e51..f8769d0 100644 --- a/qml/components/SplitPaneContainer.qml +++ b/qml/components/SplitPaneContainer.qml @@ -14,10 +14,27 @@ Item { readonly property real dividerWidth: 10 * root.uiScale readonly property real minimumPaneWidth: 240 * root.uiScale + function focusActivePane() { + if (!root.tabs) + return + if (root.tabs.split && root.tabs.activePane === 1) + secondaryPane.focusView() + else + primaryPane.focusView() + } + + function restoreFocusIfUnclaimed() { + var window = root.Window.window + if (!window || window.activeFocusItem || !root.visible || !root.tabs || !root.tabs.currentSession) + return + root.focusActivePane() + } + function activatePane(index) { if (!root.tabs) return root.tabs.activePane = index + Qt.callLater(root.focusActivePane) } function clampRatio(value) { @@ -121,7 +138,7 @@ Item { Shortcut { sequence: "F6" enabled: root.tabs && root.tabs.split - onActivated: root.tabs.activePane = root.tabs.activePane === 0 ? 1 : 0 + onActivated: root.activatePane(root.tabs.activePane === 0 ? 1 : 0) } Connections { @@ -131,6 +148,21 @@ Item { root.splitRatio = root.clampRatio(root.splitRatio) else root.splitRatio = 0.5 + Qt.callLater(root.focusActivePane) + } + function onActivePaneChanged() { + Qt.callLater(root.focusActivePane) + } + function onCurrentSessionChanged() { + Qt.callLater(root.focusActivePane) + } + } + + Connections { + target: root.Window.window + function onActiveFocusItemChanged() { + if (target && !target.activeFocusItem) + Qt.callLater(root.restoreFocusIfUnclaimed) } } @@ -138,4 +170,6 @@ Item { if (root.tabs && root.tabs.split) root.splitRatio = root.clampRatio(root.splitRatio) } + + Component.onCompleted: Qt.callLater(root.focusActivePane) } diff --git a/src/locations/SessionFileModel.cpp b/src/locations/SessionFileModel.cpp index 174fcf1..c98b7a9 100644 --- a/src/locations/SessionFileModel.cpp +++ b/src/locations/SessionFileModel.cpp @@ -145,6 +145,12 @@ QString SessionFileModel::pathAt(int index) const { return data(this->index(index, 0), PathRole).toString(); } +QString SessionFileModel::nameAt(int index) const { + if (index < 0 || index >= rowCount()) + return {}; + return data(this->index(index, 0), NameRole).toString(); +} + bool SessionFileModel::isDirectoryAt(int index) const { if (index < 0 || index >= rowCount()) return false; diff --git a/src/locations/SessionFileModel.hpp b/src/locations/SessionFileModel.hpp index 9d79061..53bb9c0 100644 --- a/src/locations/SessionFileModel.hpp +++ b/src/locations/SessionFileModel.hpp @@ -73,6 +73,7 @@ class SessionFileModel final : public QIdentityProxyModel { Q_INVOKABLE void refresh(); Q_INVOKABLE QString pathAt(int index) const; + Q_INVOKABLE QString nameAt(int index) const; Q_INVOKABLE bool isDirectoryAt(int index) const; Q_INVOKABLE int indexOfPath(const QString& path) const; diff --git a/tests/DirectorySessionLocationTest.cpp b/tests/DirectorySessionLocationTest.cpp index 3da194e..d528fc5 100644 --- a/tests/DirectorySessionLocationTest.cpp +++ b/tests/DirectorySessionLocationTest.cpp @@ -3,6 +3,7 @@ #include "navigation/DirectorySession.hpp" #include +#include #include #include @@ -21,6 +22,59 @@ private slots: QVERIFY(session.localBackendActive()); } + void localModelExposesNamesAndKeepsAnchoredSelection() { + QTemporaryDir temp; + QVERIFY(temp.isValid()); + + const QStringList names{ + QStringLiteral("alpha.txt"), + QStringLiteral("bravo.txt"), + QStringLiteral("charlie.txt"), + }; + for (const QString& name : names) { + QFile file(QDir(temp.path()).filePath(name)); + QVERIFY(file.open(QIODevice::WriteOnly)); + QVERIFY(file.write(name.toUtf8()) > 0); + } + + DirectorySession session(temp.path()); + QTRY_VERIFY_WITH_TIMEOUT(!session.model()->loading(), 5000); + QTRY_COMPARE_WITH_TIMEOUT(session.model()->rowCount(), static_cast(names.size()), 5000); + + const QString alphaPath = QDir(temp.path()).filePath(QStringLiteral("alpha.txt")); + const QString bravoPath = QDir(temp.path()).filePath(QStringLiteral("bravo.txt")); + const QString charliePath = QDir(temp.path()).filePath(QStringLiteral("charlie.txt")); + const int alpha = session.model()->indexOfPath(alphaPath); + const int bravo = session.model()->indexOfPath(bravoPath); + const int charlie = session.model()->indexOfPath(charliePath); + QVERIFY(alpha >= 0); + QVERIFY(bravo >= 0); + QVERIFY(charlie >= 0); + + QCOMPARE(session.model()->nameAt(alpha), QStringLiteral("alpha.txt")); + QCOMPARE(session.model()->nameAt(bravo), QStringLiteral("bravo.txt")); + QCOMPARE(session.model()->nameAt(charlie), QStringLiteral("charlie.txt")); + QVERIFY(session.model()->nameAt(-1).isEmpty()); + QVERIFY(session.model()->nameAt(session.model()->rowCount()).isEmpty()); + + session.selectSingle(alpha); + QCOMPARE(session.selectedPath(), alphaPath); + QCOMPARE(session.selectionCount(), 1); + + session.selectRange(charlie); + QCOMPARE(session.selectedPath(), charliePath); + QCOMPARE(session.selectionCount(), 3); + QVERIFY(session.isSelectedPath(alphaPath)); + QVERIFY(session.isSelectedPath(bravoPath)); + QVERIFY(session.isSelectedPath(charliePath)); + + session.toggleSelection(bravo); + QCOMPARE(session.selectionCount(), 2); + QVERIFY(session.isSelectedPath(alphaPath)); + QVERIFY(!session.isSelectedPath(bravoPath)); + QVERIFY(session.isSelectedPath(charliePath)); + } + void remoteHistorySwitchesBackendsWithoutLosingLocalState() { QTemporaryDir temp; QVERIFY(temp.isValid());