homescreens/folio: Add keyboard navigation to app drawer and fix on

search

This commit is a subset of
https://invent.kde.org/plasma/plasma-mobile/-/merge_requests/694 to add
keyboard navigation to the app drawer.

Pressing the arrow keys while in the app drawer will allow you to
navigate between the apps and the search bar, and going up will exit the
app drawer. Escape/Back is also supported for unfocusing the search bar
and exiting the view.

This also fixes an issue in the keyboard navigation on the search screen
where it wouldn't close when there are no search results.
This commit is contained in:
Devin Lin 2025-07-10 12:54:01 -04:00
parent 04bcbd9f8d
commit 0bcab0ae3a
7 changed files with 156 additions and 10 deletions

View file

@ -72,7 +72,21 @@ MouseArea {
font.weight: Font.Bold font.weight: Font.Bold
KeyNavigation.down: listView // Keyboard navigation
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Down) {
if (listView.count === 0) {
// Close if listview has no elements
root.requestedClose();
} else {
// Focus on listview if there are elements
listView.forceActiveFocus();
listView.currentIndex = 0;
}
event.accepted = true;
}
}
} }
QQC2.ScrollView { QQC2.ScrollView {

View file

@ -28,11 +28,21 @@ Item {
required property int headerHeight required property int headerHeight
required property var headerItem required property var headerItem
// height from top of screen that the drawer starts // Height from top of screen that the drawer starts
readonly property real drawerTopMargin: height - topPadding - bottomPadding readonly property real drawerTopMargin: height - topPadding - bottomPadding
property alias flickable: appDrawerGrid property alias flickable: appDrawerGrid
// Keyboard navigation
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Escape || event.key === Qt.Key_Back) {
// Close drawer if "back" action
folio.HomeScreenState.closeAppDrawer();
event.accepted = true;
}
}
// App drawer container
Item { Item {
anchors.fill: parent anchors.fill: parent
@ -41,7 +51,7 @@ Item {
anchors.rightMargin: root.rightPadding anchors.rightMargin: root.rightPadding
anchors.bottomMargin: root.bottomPadding anchors.bottomMargin: root.bottomPadding
// drawer header // Drawer header
MobileShell.BaseItem { MobileShell.BaseItem {
id: drawerHeader id: drawerHeader
z: 1 z: 1
@ -52,8 +62,23 @@ Item {
anchors.right: parent.right anchors.right: parent.right
contentItem: root.headerItem contentItem: root.headerItem
// Keyboard navigation for header (search bar)
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Down || event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab) {
// Go from search bar to app grid
appDrawerGrid.forceActiveFocus();
appDrawerGrid.currentIndex = 0;
event.accepted = true;
} else if (event.key === Qt.Key_Up) {
// Go to homescreen pages
folio.HomeScreenState.closeAppDrawer();
event.accepted = true;
}
}
} }
// App list
AppDrawerGrid { AppDrawerGrid {
id: appDrawerGrid id: appDrawerGrid
folio: root.folio folio: root.folio
@ -64,9 +89,22 @@ Item {
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
opacity: 0 // we display with the opacity gradient below opacity: 0 // we display with the opacity gradient below
headerHeight: root.headerHeight headerHeight: root.headerHeight
// Keyboard navigation
topEdgeCallback: () => {
drawerHeader.contentItem.forceActiveFocus();
currentIndex = -1;
}
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab) {
topEdgeCallback();
event.accepted = true;
}
}
} }
// opacity gradient at grid edges // Opacity gradient at grid edges
MobileShell.FlickableOpacityGradient { MobileShell.FlickableOpacityGradient {
anchors.fill: appDrawerGrid anchors.fill: appDrawerGrid
flickable: appDrawerGrid flickable: appDrawerGrid

View file

@ -23,6 +23,10 @@ MobileShell.GridView {
reuseItems: true reuseItems: true
layer.enabled: true layer.enabled: true
keyNavigationEnabled: true
highlightMoveDuration: 0
highlight: null // We supply our own highlight from the delegate
property var homeScreen property var homeScreen
property real headerHeight property real headerHeight
@ -84,6 +88,13 @@ MobileShell.GridView {
model: folio.ApplicationListSearchModel model: folio.ApplicationListSearchModel
// Keyboard focus on app delegate when it is the selected item
onCurrentItemChanged: {
if (currentItem) {
currentItem.keyboardFocus();
}
}
delegate: AppDelegate { delegate: AppDelegate {
id: appDelegate id: appDelegate

View file

@ -18,10 +18,31 @@ Item {
Kirigami.Theme.colorSet: Kirigami.Theme.Complementary Kirigami.Theme.colorSet: Kirigami.Theme.Complementary
Kirigami.Theme.inherit: false Kirigami.Theme.inherit: false
function addSearchText(text: string) {
searchField.text += text;
}
function clearSearchText(): void { function clearSearchText(): void {
searchField.text = ''; searchField.text = '';
} }
// Request to not focus on the search bar
signal releaseFocusRequested()
onFocusChanged: {
if (focus) {
searchField.focus = true;
}
}
// Keyboard navigation
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Escape || event.key === Qt.Key_Back) {
root.releaseFocusRequested();
event.accepted = true;
}
}
RowLayout { RowLayout {
anchors.topMargin: Kirigami.Units.largeSpacing anchors.topMargin: Kirigami.Units.largeSpacing
anchors.leftMargin: Kirigami.Units.gridUnit + Kirigami.Units.largeSpacing anchors.leftMargin: Kirigami.Units.gridUnit + Kirigami.Units.largeSpacing

View file

@ -437,17 +437,38 @@ Item {
y: (opacity > 0) ? animationY : parent.height y: (opacity > 0) ? animationY : parent.height
headerHeight: Math.round(Kirigami.Units.gridUnit * 4) headerHeight: Math.round(Kirigami.Units.gridUnit * 4)
headerItem: AppDrawerHeader { folio: root.folio } headerItem: AppDrawerHeader {
id: appDrawerHeader
folio: root.folio
// account for panels onReleaseFocusRequested: appDrawer.forceActiveFocus()
}
// Account for panels
topPadding: root.topMargin topPadding: root.topMargin
bottomPadding: root.bottomMargin bottomPadding: root.bottomMargin
leftPadding: root.leftMargin leftPadding: root.leftMargin
rightPadding: root.rightMargin rightPadding: root.rightMargin
// Forward keyboard text to the search bar
Keys.onPressed: (event) => {
if (event.text.trim().length > 0) {
appDrawerHeader.addSearchText(event.text);
appDrawerHeader.forceActiveFocus();
event.accepted = true;
} else if (event.key === Qt.Key_Left || event.key === Qt.Key_Right || event.key === Qt.Key_Up || event.key === Qt.Key_Down) {
appDrawerHeader.forceActiveFocus();
event.accepted = true;
}
}
Connections { Connections {
target: folio.HomeScreenState target: folio.HomeScreenState
function onAppDrawerOpened() {
appDrawer.forceActiveFocus();
}
function onAppDrawerClosed() { function onAppDrawerClosed() {
// reset app drawer position when closed // reset app drawer position when closed
appDrawer.flickable.contentY = 0; appDrawer.flickable.contentY = 0;

View file

@ -33,6 +33,10 @@ Folio.DelegateTouchArea {
property real scaleAmount: 1 property real scaleAmount: 1
property bool clickRequested: false property bool clickRequested: false
function keyboardFocus() {
delegateWrapper.forceActiveFocus();
}
NumberAnimation on scaleAmount { NumberAnimation on scaleAmount {
id: shrinkAnim id: shrinkAnim
running: false running: false
@ -70,17 +74,36 @@ Folio.DelegateTouchArea {
// trigger handled by press animation // trigger handled by press animation
onClicked: clickRequested = true; onClicked: clickRequested = true;
layer.enabled: root.shadow FocusScope {
layer.effect: DelegateShadow {}
Item {
id: delegateWrapper id: delegateWrapper
anchors.fill: parent anchors.fill: parent
// Select keyboard navigation
Keys.onPressed: (event) => {
switch (event.key) {
case Qt.Key_Enter:
case Qt.Key_Return:
case Qt.Key_Space:
root.afterClickAnimation();
event.accepted = true;
break;
default:
break;
}
}
KeyboardHighlight {
anchors.fill: parent
visible: delegateWrapper.activeFocus
}
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
spacing: 0 spacing: 0
layer.enabled: root.shadow
layer.effect: DelegateShadow {}
// transform is not on delegateWrapper because when it's zoomed in, it apparently // transform is not on delegateWrapper because when it's zoomed in, it apparently
// affects the delegate's x and y position, which messes up the starting drag and drop // affects the delegate's x and y position, which messes up the starting drag and drop
// position (for mapFromItem in HomeScreen.qml) // position (for mapFromItem in HomeScreen.qml)

View file

@ -0,0 +1,18 @@
// SPDX-FileCopyrightText: 2025 Devin Lin <devin@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
import QtQuick
import org.kde.ksvg as KSvg
import org.kde.kirigami as Kirigami
Rectangle {
id: background
radius: Kirigami.Units.cornerRadius
border.width: 1
border.color: Kirigami.Theme.highlightColor
border.pixelAligned: false
color: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.2)
}