homescreens/folio: Add keyboard navigation on pages

Implements the rest of https://invent.kde.org/plasma/plasma-mobile/-/issues/219
This commit is contained in:
Devin Lin 2025-08-11 16:04:35 -04:00
parent 5c404c0f0d
commit dc1fa9b187
7 changed files with 430 additions and 48 deletions

View file

@ -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

View file

@ -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: {

View file

@ -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

View file

@ -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();
}

View file

@ -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

View file

@ -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 {

View file

@ -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
}
}