diff --git a/components/mobileshell/qml/widgets/krunner/KRunnerScreen.qml b/components/mobileshell/qml/widgets/krunner/KRunnerScreen.qml index 7420d590..58081323 100644 --- a/components/mobileshell/qml/widgets/krunner/KRunnerScreen.qml +++ b/components/mobileshell/qml/widgets/krunner/KRunnerScreen.qml @@ -20,7 +20,7 @@ import org.kde.kirigami 2.19 as Kirigami MouseArea { id: root - onClicked: root.requestedClose() + onClicked: root.requestedClose(false) function requestFocus() { queryField.forceActiveFocus(); @@ -30,11 +30,11 @@ MouseArea { queryField.text = ""; } - signal requestedClose() + signal requestedClose(triggeredByKeyEvent: bool) Keys.onPressed: (event) => { if (event.key === Qt.Key_Escape) { - root.requestedClose(); + root.requestedClose(true); event.accepted = true; } } @@ -77,7 +77,7 @@ MouseArea { if (event.key === Qt.Key_Down) { if (listView.count === 0) { // Close if listview has no elements - root.requestedClose(); + root.requestedClose(true); } else { // Focus on listview if there are elements listView.forceActiveFocus(); @@ -111,7 +111,7 @@ MouseArea { } onActivated: { - root.requestedClose(); + root.requestedClose(false); } onUpdateQueryString: { queryField.text = text @@ -153,7 +153,7 @@ MouseArea { // Close search view if we press down with last item selected Keys.onPressed: (event) => { if (event.key === Qt.Key_Down && (model.index === listView.count - 1)) { - root.requestedClose(); + root.requestedClose(true); event.accepted = true; } } @@ -169,7 +169,7 @@ MouseArea { listView.currentIndex = model.index; listView.runCurrentIndex(); - root.requestedClose(); + root.requestedClose(false); } hoverEnabled: true diff --git a/containments/homescreens/folio/qml/FavouritesBar.qml b/containments/homescreens/folio/qml/FavouritesBar.qml index 4f542038..5881c37c 100644 --- a/containments/homescreens/folio/qml/FavouritesBar.qml +++ b/containments/homescreens/folio/qml/FavouritesBar.qml @@ -34,6 +34,17 @@ MouseArea { } } + onActiveFocusChanged: { + if (activeFocus) { + // Focus on first delegate when favorites bar focused + let firstDelegate = repeater.itemAt(0); + if (!firstDelegate) { + return; + } + firstDelegate.keyboardFocus(); + } + } + MobileShell.HapticsEffect { id: haptics } @@ -54,11 +65,11 @@ MouseArea { readonly property var dragState: folio.HomeScreenState.dragState readonly property bool isDropPositionThis: dragState.candidateDropPosition.location === Folio.DelegateDragPosition.Favourites && - dragState.candidateDropPosition.favouritesPosition === delegate.index + dragState.candidateDropPosition.favouritesPosition === delegate.index readonly property bool isAppHoveredOver: folio.HomeScreenState.swipeState === Folio.HomeScreenState.DraggingDelegate && - dragState.dropDelegate && - dragState.dropDelegate.type === Folio.FolioDelegate.Application && - isDropPositionThis + dragState.dropDelegate && + dragState.dropDelegate.type === Folio.FolioDelegate.Application && + isDropPositionThis readonly property bool isLocationBottom: folio.HomeScreenState.favouritesBarLocation === Folio.HomeScreenState.Bottom @@ -79,7 +90,56 @@ MouseArea { width: folio.HomeScreenState.pageCellWidth height: folio.HomeScreenState.pageCellHeight + // Keyboard navigation to other delegates + Keys.onPressed: (event) => { + switch (event.key) { + case Qt.Key_Up: + if (!isLocationBottom) { + let nextDelegate = repeater.itemAt(delegate.index - 1); + if (nextDelegate) { + nextDelegate.keyboardFocus(); + event.accepted = true; + } + } + break; + case Qt.Key_Down: + if (!isLocationBottom) { + let nextDelegate = repeater.itemAt(delegate.index + 1); + if (nextDelegate) { + nextDelegate.keyboardFocus(); + event.accepted = true; + } + } + break; + case Qt.Key_Left: + if (isLocationBottom) { + let nextDelegate = repeater.itemAt(delegate.index - 1); + if (nextDelegate) { + nextDelegate.keyboardFocus(); + event.accepted = true; + } + } + break; + case Qt.Key_Right: + if (isLocationBottom) { + let nextDelegate = repeater.itemAt(delegate.index + 1); + if (nextDelegate) { + nextDelegate.keyboardFocus(); + event.accepted = true; + } + } + break; + } + } + + function keyboardFocus() { + if (loader.item) { + loader.item.keyboardFocus(); + } + } + Loader { + id: loader anchors.fill: parent sourceComponent: { diff --git a/containments/homescreens/folio/qml/FolderView.qml b/containments/homescreens/folio/qml/FolderView.qml index d31e22d8..af14eee4 100644 --- a/containments/homescreens/folio/qml/FolderView.qml +++ b/containments/homescreens/folio/qml/FolderView.qml @@ -36,12 +36,36 @@ Folio.DelegateTouchArea { folio.HomeScreenState.closeFolder(); } + Keys.onPressed: (event) => { + switch (event.key) { + case Qt.Key_Escape: + case Qt.Key_Back: + // Close view + root.close(); + event.accepted = true; + break; + case Qt.Key_Up: + case Qt.Key_Down: + case Qt.Key_Left: + case Qt.Key_Right: + // Keyboard focus on first item + if (delegateRepeater.count > 0) { + delegateRepeater.itemAt(0).keyboardFocus(); + event.accepted = true; + } + break; + } + } + Connections { target: folio.HomeScreenState function onFolderAboutToOpen(x, y) { root.folderPositionX = x - folio.HomeScreenState.viewLeftPadding; root.folderPositionY = y - folio.HomeScreenState.viewTopPadding; + + // Focus view when opened + root.forceActiveFocus(); } } @@ -73,6 +97,70 @@ Folio.DelegateTouchArea { folio.HomeScreenState.folderPageContentHeight = (folderBackground.height - margin * 2); } + function keyboardNavigateForDelegate(key, column, row, page) { + let dx = 0; + let dy = 0; + switch (key) { + case Qt.Key_Up: { dy = -1; break; } + case Qt.Key_Down: { dy = 1; break; } + case Qt.Key_Left: { dx = -1; break; } + case Qt.Key_Right: { dx = 1; break; } + default: return; + } + + let x = column + dx; + let y = row + dy; + + // Loop in direction to find next delegate + while (x >= 0 && x < folio.HomeScreenState.folderGridLength + && y >= 0 && y < folio.HomeScreenState.folderGridLength) { + + // Find delegate at x, y + for (let i = 0; i < delegateRepeater.count; ++i) { + let cDelegate = delegateRepeater.itemAt(i); + if (cDelegate.columnValue === x && cDelegate.rowValue === y && cDelegate.pageValue === page) { + // Delegate matches, focus on it and return + cDelegate.keyboardFocus(); + return; + } + } + + x += dx; + y += dy; + } + + // Behavior if no delegate is found to navigate to + switch (key) { + case Qt.Key_Up: + case Qt.Key_Down: + break; + case Qt.Key_Left: { + // Go to the left page if a delegate exists there + let cDelegate = delegateRepeater.itemAt((page - 1) + * folio.HomeScreenState.folderGridLength + * folio.HomeScreenState.folderGridLength); + if (cDelegate) { + cDelegate.keyboardFocus(); + folio.HomeScreenState.goToFolderPage(page - 1, false); + } + break; + } + case Qt.Key_Right: { + // Go to the right page if a delegate exists there + let cDelegate = delegateRepeater.itemAt((page + 1) + * folio.HomeScreenState.folderGridLength + * folio.HomeScreenState.folderGridLength); + if (cDelegate) { + cDelegate.keyboardFocus(); + folio.HomeScreenState.goToFolderPage(page + 1, false); + } + break; + } + default: + return; + } + } + Connections { target: folio.HomeScreenState @@ -168,6 +256,7 @@ Folio.DelegateTouchArea { x: folio.HomeScreenState.folderViewX Repeater { + id: delegateRepeater model: root.folder ? root.folder.applications : [] delegate: Item { @@ -187,21 +276,16 @@ Folio.DelegateTouchArea { readonly property var dragState: folio.HomeScreenState.dragState readonly property bool isDropPositionThis: dragState.candidateDropPosition.location === Folio.DelegateDragPosition.Folder && - dragState.candidateDropPosition.folderPosition === index + dragState.candidateDropPosition.folderPosition === index // get the index position value so we can animate them property double columnValue: model.columnIndex property double rowValue: model.rowIndex property double pageValue: model.pageIndex - Behavior on columnValue { - NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } - } - Behavior on rowValue { - NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } - } - Behavior on pageValue { - NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } - } + + Behavior on columnValue { NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } } + Behavior on rowValue { NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } } + Behavior on pageValue { NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } } // multiply the index values by the cell size to get the actual position readonly property int positionColumn: folderCellSize * columnValue @@ -215,6 +299,28 @@ Folio.DelegateTouchArea { width: cellWidth height: cellHeight + // Implement keyboard arrow navigation + Keys.onPressed: (event) => { + switch (event.key) { + case Qt.Key_Up: + case Qt.Key_Down: + case Qt.Key_Left: + case Qt.Key_Right: + event.accepted = true; + break; + default: + return; + } + + root.keyboardNavigateForDelegate(event.key, columnValue, rowValue, pageValue); + } + + function keyboardFocus() { + if (delegateLoader.item) { + delegateLoader.item.keyboardFocus(); + } + } + Loader { id: delegateLoader anchors.fill: parent diff --git a/containments/homescreens/folio/qml/FolioHomeScreen.qml b/containments/homescreens/folio/qml/FolioHomeScreen.qml index 28d80d65..9be8f6a4 100644 --- a/containments/homescreens/folio/qml/FolioHomeScreen.qml +++ b/containments/homescreens/folio/qml/FolioHomeScreen.qml @@ -43,6 +43,8 @@ Item { onBottomMarginChanged: folio.HomeScreenState.viewBottomPadding = root.bottomMargin onLeftMarginChanged: folio.HomeScreenState.viewLeftPadding = root.leftMargin onRightMarginChanged: folio.HomeScreenState.viewRightPadding = root.rightMargin + onWidthChanged: folio.HomeScreenState.viewWidth = width + onHeightChanged: folio.HomeScreenState.viewHeight = height signal wallpaperSelectorTriggered() @@ -75,6 +77,16 @@ Item { Plasmoid.internalAction("configure").trigger(); } + Keys.onPressed: (event) => { + // The root is focused when we aren't in key navigation mode + // Begin key navigation when arrow keys are pressed + if (event.key === Qt.Key_Up || event.key === Qt.Key_Down + || event.key === Qt.Key_Left || event.key === Qt.Key_Right) { + homeScreenPages.forceActiveFocus(); + event.accepted = true; + } + } + // determine how tall an app label is, for delegate measurements DelegateLabel { id: appLabelMetrics @@ -88,20 +100,23 @@ Item { } } - // determine screen dimensions - Item { - id: screenDimensions - anchors.fill: parent - - onWidthChanged: folio.HomeScreenState.viewWidth = width; - onHeightChanged: folio.HomeScreenState.viewHeight = height; - } - // a way of stopping focus FocusScope { id: noFocus } + // Listen to view changes + Connections { + target: folio.HomeScreenState + + function onViewStateChanged() { + if (folio.HomeScreenState.viewState === Folio.HomeScreenState.PageView) { + // Focus root when on page view + root.forceActiveFocus(); + } + } + } + // area that can be swiped MobileShell.SwipeArea { id: swipeArea @@ -211,6 +226,50 @@ Item { homeScreenState.pageHeight = homeScreenPages.height; } + // Keyboard navigation from pages + Keys.onPressed: (event) => { + switch (event.key) { + case Qt.Key_Up: + // Open search widget when going up + folio.HomeScreenState.openSearchWidget(); + event.accepted = true; + break; + case Qt.Key_Down: + // Focus on favorites bar or app drawer, depending on its physical location + if (folio.HomeScreenState.favouritesBarLocation === Folio.HomeScreenState.Bottom) { + favouritesBar.forceActiveFocus(); + } else { + folio.HomeScreenState.openAppDrawer(); + } + event.accepted = true; + break; + case Qt.Key_Left: + if (folio.HomeScreenState.favouritesBarLocation === Folio.HomeScreenState.Left) { + // If favorites bar is on the left, navigate to it + favouritesBar.forceActiveFocus(); + } else { + // Otherwise go to page on the left + folio.HomeScreenState.goToPage(folio.HomeScreenState.currentPage - 1, false); + homeScreenPages.focusCurrentPageForKeyboardNav(); + } + event.accepted = true; + break; + case Qt.Key_Right: + if (folio.HomeScreenState.favouritesBarLocation === Folio.HomeScreenState.Right) { + // If favorites bar is on the right, navigate to it + favouritesBar.forceActiveFocus(); + } else { + // Otherwise go to page on the right + folio.HomeScreenState.goToPage(folio.HomeScreenState.currentPage + 1, false); + homeScreenPages.focusCurrentPageForKeyboardNav(); + } + event.accepted = true; + break; + default: + break; + } + } + transform: [ Scale { // animation when settings opens @@ -298,6 +357,42 @@ Item { anchors.leftMargin: root.leftMargin anchors.rightMargin: root.rightMargin + // Keyboard navigation on favorites bar + Keys.onPressed: (event) => { + switch (event.key) { + case Qt.Key_Up: + // Focus on homescreen pages or search widget depending on physical position + if (folio.HomeScreenState.favouritesBarLocation === Folio.HomeScreenState.Bottom) { + homeScreenPages.forceActiveFocus(); + } else { + folio.HomeScreenState.openSearchWidget(); + } + event.accepted = true; + break; + case Qt.Key_Down: + // Open app drawer + folio.HomeScreenState.openAppDrawer(); + event.accepted = true; + break; + case Qt.Key_Left: + if (folio.HomeScreenState.favoritesBarLocation === Folio.HomeScreenState.Left) { + // Go to left page if mounted on the left + folio.HomeScreenState.goToPage(folio.HomeScreenState.currentPage - 1, false); + event.accepted = true; + } + break; + case Qt.Key_Right: + if (folio.HomeScreenState.favoritesBarLocation === Folio.HomeScreenState.Right) { + // Go to right page if mounted on the right + folio.HomeScreenState.goToPage(folio.HomeScreenState.currentPage + 1, false); + event.accepted = true; + } + break; + default: + break; + } + } + states: [ State { name: "bottom" @@ -499,13 +594,11 @@ Item { function onSearchWidgetOpenProgressChanged() { if (homeScreenState.searchWidgetOpenProgress === 1.0) { searchWidget.requestFocus(); - } else if (!root.activeFocus) { - root.forceActiveFocus(); } } } - onRequestedClose: { + onRequestedClose: (triggeredByKeyEvent) => { homeScreenState.closeSearchWidget(); } diff --git a/containments/homescreens/folio/qml/HomeScreenPage.qml b/containments/homescreens/folio/qml/HomeScreenPage.qml index 7cb92c10..f72f719b 100644 --- a/containments/homescreens/folio/qml/HomeScreenPage.qml +++ b/containments/homescreens/folio/qml/HomeScreenPage.qml @@ -25,6 +25,80 @@ Item { property var pageModel property var homeScreen + onActiveFocusChanged: { + if (activeFocus) { + // Focus on first delegate when this page is focused + let firstDelegate = findFirstDelegate(); + if (!firstDelegate) { + return; + } + firstDelegate.keyboardFocus(); + } + } + + // Returns the first delegate on the page, or null if none exist + function findFirstDelegate() { + let firstDelegate = delegateRepeater.itemAt(0); + if (!firstDelegate) { + return null; + } + + for (let i = 0; i < delegateRepeater.count; ++i) { + let delegate = delegateRepeater.itemAt(i); + + const isAppOrFolder = delegate.pageDelegate.type === Folio.FolioDelegate.Application || delegate.pageDelegate.type === Folio.FolioDelegate.Folder; + + // If it's on an earlier row, or on the same row but earlier column + if (isAppOrFolder + && (delegate.row < firstDelegate.row + || (delegate.column < firstDelegate.column + && delegate.row === firstDelegate.row))) { + firstDelegate = delegate; + } + } + return firstDelegate; + } + + // Returns the next application/folder delegate on the page from the given delegate + // in a certain direction, or null if none exist. + function findNextAppDelegate(delegate, direction: MobileShell.Direction) { + let dx = 0; + let dy = 0; + switch (direction) { + case MobileShell.Direction.Up: + dy = -1; + break; + case MobileShell.Direction.Down: + dy = 1; + break; + case MobileShell.Direction.Left: + dx = -1; + break; + case MobileShell.Direction.Right: + dx = 1; + break; + } + + let x = delegate.column + dx; + let y = delegate.row + dy; + + // Loop in direction to find delegate + while (x >= 0 && x < folio.HomeScreenState.pageColumns && y >= 0 && y < folio.HomeScreenState.pageRows) { + for (let i = 0; i < delegateRepeater.count; ++i) { + let delegate = delegateRepeater.itemAt(i); + + if (delegate.row === y && delegate.column === x + && (delegate.pageDelegate.type === Folio.FolioDelegate.Application + || delegate.pageDelegate.type === Folio.FolioDelegate.Folder)) { + return delegate; + } + } + x += dx; + y += dy; + } + return null; + } + MobileShell.HapticsEffect { id: haptics } @@ -72,10 +146,10 @@ Item { // only show if the widget can be placed here visible: folio.HomeScreenState.swipeState === Folio.HomeScreenState.DraggingDelegate && - dropPosition.location === Folio.DelegateDragPosition.Pages && - dropPosition.page === root.pageNum && - dropDelegateIsWidget && - pageModel.canAddDelegate(dropPosition.pageRow, dropPosition.pageColumn, dropDelegate) + dropPosition.location === Folio.DelegateDragPosition.Pages && + dropPosition.page === root.pageNum && + dropDelegateIsWidget && + pageModel.canAddDelegate(dropPosition.pageRow, dropPosition.pageColumn, dropDelegate) radius: Kirigami.Units.cornerRadius color: Qt.rgba(255, 255, 255, 0.3) @@ -89,6 +163,7 @@ Item { // repeater of all delegates in the page Repeater { + id: delegateRepeater model: root.pageModel delegate: Item { @@ -101,14 +176,14 @@ Item { property var dragState: folio.HomeScreenState.dragState property bool isDropPositionThis: dragState.candidateDropPosition.location === Folio.DelegateDragPosition.Pages && - dragState.candidateDropPosition.page === root.pageNum && - dragState.candidateDropPosition.pageRow === delegate.pageDelegate.row && - dragState.candidateDropPosition.pageColumn === delegate.pageDelegate.column + dragState.candidateDropPosition.page === root.pageNum && + dragState.candidateDropPosition.pageRow === delegate.pageDelegate.row && + dragState.candidateDropPosition.pageColumn === delegate.pageDelegate.column property bool isAppHoveredOver: folio.HomeScreenState.swipeState === Folio.HomeScreenState.DraggingDelegate && - dragState.dropDelegate && - dragState.dropDelegate.type === Folio.FolioDelegate.Application && - isDropPositionThis + dragState.dropDelegate && + dragState.dropDelegate.type === Folio.FolioDelegate.Application && + isDropPositionThis implicitWidth: loader.item ? loader.item.implicitWidth : 0 implicitHeight: loader.item ? loader.item.implicitHeight : 0 @@ -132,6 +207,39 @@ Item { // folio.HomeScreenState.snapPage(); } + // Keyboard navigation between delegates + Keys.onPressed: (event) => { + let direction = MobileShell.Direction.Up; + switch (event.key) { + case Qt.Key_Up: + direction = MobileShell.Direction.Up; + break; + case Qt.Key_Down: + direction = MobileShell.Direction.Down; + break; + case Qt.Key_Left: + direction = MobileShell.Direction.Left; + break; + case Qt.Key_Right: + direction = MobileShell.Direction.Right; + break; + default: + return; + } + + let nextDelegate = root.findNextAppDelegate(delegate, direction); + if (nextDelegate) { + nextDelegate.keyboardFocus(); + event.accepted = true; + } + } + + function keyboardFocus() { + if (loader.item) { + loader.item.keyboardFocus(); + } + } + Loader { id: loader anchors.top: parent.top @@ -251,8 +359,8 @@ Item { // do not show if the drop animation is running to this delegate, and the drop delegate is a folder visible: !(root.homeScreen.dropAnimationRunning && - delegate.isDropPositionThis && - delegate.dragState.dropDelegate.type === Folio.FolioDelegate.Folder) + delegate.isDropPositionThis && + delegate.dragState.dropDelegate.type === Folio.FolioDelegate.Folder) // don't show label in drag and drop mode labelOpacity: delegate.opacity diff --git a/containments/homescreens/folio/qml/HomeScreenPages.qml b/containments/homescreens/folio/qml/HomeScreenPages.qml index e5973412..c0cbd0c9 100644 --- a/containments/homescreens/folio/qml/HomeScreenPages.qml +++ b/containments/homescreens/folio/qml/HomeScreenPages.qml @@ -31,6 +31,20 @@ MouseArea { } } + onActiveFocusChanged: { + if (activeFocus) { + // When this component is focused, move focus to current page (keyboard navigation) + focusCurrentPageForKeyboardNav(); + } + } + + function focusCurrentPageForKeyboardNav() { + const currentPage = pageRepeater.itemAt(folio.HomeScreenState.currentPage); + if (currentPage) { + currentPage.forceActiveFocus(); + } + } + MobileShell.HapticsEffect { id: haptics } @@ -40,6 +54,7 @@ MouseArea { } Repeater { + id: pageRepeater model: folio.PageListModel delegate: HomeScreenPage { diff --git a/containments/homescreens/folio/qml/main.qml b/containments/homescreens/folio/qml/main.qml index 2ca836e1..e1c24f63 100644 --- a/containments/homescreens/folio/qml/main.qml +++ b/containments/homescreens/folio/qml/main.qml @@ -29,9 +29,6 @@ ContainmentItem { folio.FolioSettings.load(); folio.FavouritesModel.load(); folio.PageListModel.load(); - - // ensure the gestures work immediately on load - forceActiveFocus(); } property MobileShell.MaskManager maskManager: MobileShell.MaskManager { @@ -167,6 +164,9 @@ ContainmentItem { leftMargin: homeScreen.leftMargin rightMargin: homeScreen.rightMargin + // Ensure is the focused item at start + Component.onCompleted: forceActiveFocus() + onWallpaperSelectorTriggered: wallpaperSelectorLoader.active = true } }