1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-24 13:32:50 -05:00

processlist: add full keyboard navigation

This commit is contained in:
bbedward
2026-01-18 21:03:34 -05:00
parent 0f6ae11c3d
commit ac68451cdf
5 changed files with 507 additions and 154 deletions

View File

@@ -71,6 +71,14 @@ FloatingWindow {
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB/s";
}
function nextTab() {
currentTab = (currentTab + 1) % 4;
}
function previousTab() {
currentTab = (currentTab - 1 + 4) % 4;
}
objectName: "processListModal"
title: I18n.tr("System Monitor", "sysmon window title")
minimumSize: Qt.size(750, 550)
@@ -79,16 +87,25 @@ FloatingWindow {
color: Theme.surfaceContainer
visible: false
onCurrentTabChanged: {
if (visible && currentTab === 0 && searchField.visible)
searchField.forceActiveFocus();
}
onVisibleChanged: {
if (!visible) {
closingModal();
searchText = "";
expandedPid = "";
if (processesTabLoader.item)
processesTabLoader.item.reset();
DgopService.removeRef(["cpu", "memory", "network", "disk", "system"]);
} else {
DgopService.addRef(["cpu", "memory", "network", "disk", "system"]);
Qt.callLater(() => {
if (contentFocusScope)
if (currentTab === 0 && searchField.visible)
searchField.forceActiveFocus();
else if (contentFocusScope)
contentFocusScope.forceActiveFocus();
});
}
@@ -96,6 +113,11 @@ FloatingWindow {
ProcessContextMenu {
id: processContextMenu
parentFocusItem: contentFocusScope
onProcessKilled: {
if (processesTabLoader.item)
processesTabLoader.item.forceRefresh(3);
}
}
FocusScope {
@@ -108,6 +130,9 @@ FloatingWindow {
focus: true
Keys.onPressed: event => {
if (processContextMenu.visible)
return;
switch (event.key) {
case Qt.Key_1:
currentTab = 0;
@@ -125,12 +150,25 @@ FloatingWindow {
currentTab = 3;
event.accepted = true;
return;
case Qt.Key_Tab:
nextTab();
event.accepted = true;
return;
case Qt.Key_Backtab:
previousTab();
event.accepted = true;
return;
case Qt.Key_Escape:
if (searchText.length > 0) {
searchText = "";
event.accepted = true;
return;
}
if (currentTab === 0 && processesTabLoader.item?.keyboardNavigationActive) {
processesTabLoader.item.reset();
event.accepted = true;
return;
}
hide();
event.accepted = true;
return;
@@ -142,6 +180,9 @@ FloatingWindow {
}
break;
}
if (currentTab === 0 && processesTabLoader.item)
processesTabLoader.item.handleKey(event);
}
Rectangle {
@@ -336,6 +377,8 @@ FloatingWindow {
text: searchText
visible: currentTab === 0
onTextChanged: searchText = text
ignoreUpDownKeys: true
keyForwardTargets: [contentFocusScope]
}
}

View File

@@ -8,8 +8,61 @@ Popup {
id: processContextMenu
property var processData: null
property int selectedIndex: -1
property bool keyboardNavigation: false
property var parentFocusItem: null
function show(x, y) {
signal menuClosed
signal processKilled
readonly property var menuItems: [
{
text: I18n.tr("Copy PID"),
icon: "tag",
action: copyPid,
enabled: true
},
{
text: I18n.tr("Copy Name"),
icon: "content_copy",
action: copyName,
enabled: true
},
{
text: I18n.tr("Copy Full Command"),
icon: "code",
action: copyFullCommand,
enabled: true
},
{
type: "separator"
},
{
text: I18n.tr("Kill Process"),
icon: "close",
action: killProcess,
enabled: true,
dangerous: true
},
{
text: I18n.tr("Force Kill (SIGKILL)"),
icon: "dangerous",
action: forceKillProcess,
enabled: processData && processData.pid > 1000,
dangerous: true
}
]
readonly property int visibleItemCount: {
let count = 0;
for (let i = 0; i < menuItems.length; i++) {
if (menuItems[i].type !== "separator")
count++;
}
return count;
}
function show(x, y, fromKeyboard) {
let finalX = x;
let finalY = y;
@@ -27,17 +80,98 @@ Popup {
processContextMenu.x = finalX;
processContextMenu.y = finalY;
keyboardNavigation = fromKeyboard || false;
selectedIndex = fromKeyboard ? 0 : -1;
open();
}
function selectNext() {
if (visibleItemCount === 0)
return;
let current = selectedIndex;
let next = current;
do {
next = (next + 1) % menuItems.length;
} while (menuItems[next].type === "separator" && next !== current)
selectedIndex = next;
}
function selectPrevious() {
if (visibleItemCount === 0)
return;
let current = selectedIndex;
let prev = current;
do {
prev = (prev - 1 + menuItems.length) % menuItems.length;
} while (menuItems[prev].type === "separator" && prev !== current)
selectedIndex = prev;
}
function activateSelected() {
if (selectedIndex < 0 || selectedIndex >= menuItems.length)
return;
const item = menuItems[selectedIndex];
if (item.type === "separator" || !item.enabled)
return;
item.action();
}
function copyPid() {
if (processData)
Quickshell.execDetached(["dms", "cl", "copy", processData.pid.toString()]);
close();
}
function copyName() {
if (processData) {
const name = processData.command || "";
Quickshell.execDetached(["dms", "cl", "copy", name]);
}
close();
}
function copyFullCommand() {
if (processData) {
const fullCmd = processData.fullCommand || processData.command || "";
Quickshell.execDetached(["dms", "cl", "copy", fullCmd]);
}
close();
}
function killProcess() {
if (processData)
Quickshell.execDetached(["kill", processData.pid.toString()]);
processKilled();
close();
}
function forceKillProcess() {
if (processData)
Quickshell.execDetached(["kill", "-9", processData.pid.toString()]);
processKilled();
close();
}
width: 200
height: menuColumn.implicitHeight + Theme.spacingS * 2
padding: 0
modal: false
closePolicy: Popup.CloseOnEscape
onClosed: closePolicy = Popup.CloseOnEscape
onOpened: outsideClickTimer.start()
onClosed: {
closePolicy = Popup.CloseOnEscape;
keyboardNavigation = false;
selectedIndex = -1;
menuClosed();
if (parentFocusItem)
Qt.callLater(() => parentFocusItem.forceActiveFocus());
}
onOpened: {
outsideClickTimer.start();
if (keyboardNavigation)
Qt.callLater(() => keyboardHandler.forceActiveFocus());
}
Timer {
id: outsideClickTimer
@@ -55,142 +189,145 @@ Popup {
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: 1
Item {
id: keyboardHandler
anchors.fill: parent
focus: keyboardNavigation
Keys.onPressed: event => {
switch (event.key) {
case Qt.Key_Down:
case Qt.Key_J:
keyboardNavigation = true;
selectNext();
event.accepted = true;
return;
case Qt.Key_Up:
case Qt.Key_K:
keyboardNavigation = true;
selectPrevious();
event.accepted = true;
return;
case Qt.Key_Return:
case Qt.Key_Enter:
case Qt.Key_Space:
activateSelected();
event.accepted = true;
return;
case Qt.Key_Escape:
case Qt.Key_Left:
case Qt.Key_H:
close();
event.accepted = true;
return;
}
}
}
Column {
id: menuColumn
anchors.fill: parent
anchors.margins: Theme.spacingS
spacing: 1
MenuItem {
text: I18n.tr("Copy PID")
iconName: "tag"
onClicked: {
if (processContextMenu.processData)
Quickshell.execDetached(["dms", "cl", "copy", processContextMenu.processData.pid.toString()]);
processContextMenu.close();
}
}
Repeater {
model: menuItems
MenuItem {
text: I18n.tr("Copy Name")
iconName: "content_copy"
onClicked: {
if (processContextMenu.processData) {
const name = processContextMenu.processData.command || "";
Quickshell.execDetached(["dms", "cl", "copy", name]);
Item {
width: parent.width
height: modelData.type === "separator" ? 5 : 32
visible: modelData.type !== "separator" || index > 0
property int itemVisibleIndex: {
let count = 0;
for (let i = 0; i < index; i++) {
if (menuItems[i].type !== "separator")
count++;
}
return count;
}
processContextMenu.close();
}
}
MenuItem {
text: I18n.tr("Copy Full Command")
iconName: "code"
onClicked: {
if (processContextMenu.processData) {
const fullCmd = processContextMenu.processData.fullCommand || processContextMenu.processData.command || "";
Quickshell.execDetached(["dms", "cl", "copy", fullCmd]);
Rectangle {
visible: modelData.type === "separator"
width: parent.width - Theme.spacingS * 2
height: 1
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.15)
}
Rectangle {
id: menuItem
visible: modelData.type !== "separator"
width: parent.width
height: 32
radius: Theme.cornerRadius
color: {
if (!modelData.enabled)
return "transparent";
const isSelected = keyboardNavigation && selectedIndex === index;
if (modelData.dangerous) {
if (isSelected)
return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.2);
return menuItemArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent";
}
if (isSelected)
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2);
return menuItemArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent";
}
opacity: modelData.enabled ? 1 : 0.5
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
name: modelData.icon || ""
size: 16
color: {
if (!modelData.enabled)
return Theme.surfaceVariantText;
const isSelected = keyboardNavigation && selectedIndex === index;
if (modelData.dangerous && (menuItemArea.containsMouse || isSelected))
return Theme.error;
return Theme.surfaceText;
}
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: modelData.text || ""
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Normal
color: {
if (!modelData.enabled)
return Theme.surfaceVariantText;
const isSelected = keyboardNavigation && selectedIndex === index;
if (modelData.dangerous && (menuItemArea.containsMouse || isSelected))
return Theme.error;
return Theme.surfaceText;
}
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: menuItemArea
anchors.fill: parent
hoverEnabled: true
cursorShape: modelData.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
enabled: modelData.enabled
onEntered: {
keyboardNavigation = false;
selectedIndex = index;
}
onClicked: modelData.action()
}
}
processContextMenu.close();
}
}
Rectangle {
width: parent.width - Theme.spacingS * 2
height: 1
anchors.horizontalCenter: parent.horizontalCenter
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.15)
}
MenuItem {
text: I18n.tr("Kill Process")
iconName: "close"
dangerous: true
enabled: processContextMenu.processData
onClicked: {
if (processContextMenu.processData)
Quickshell.execDetached(["kill", processContextMenu.processData.pid.toString()]);
processContextMenu.close();
}
}
MenuItem {
text: I18n.tr("Force Kill (SIGKILL)")
iconName: "dangerous"
dangerous: true
enabled: processContextMenu.processData && processContextMenu.processData.pid > 1000
onClicked: {
if (processContextMenu.processData)
Quickshell.execDetached(["kill", "-9", processContextMenu.processData.pid.toString()]);
processContextMenu.close();
}
}
}
}
component MenuItem: Rectangle {
id: menuItem
property string text: ""
property string iconName: ""
property bool dangerous: false
property bool enabled: true
signal clicked
width: parent.width
height: 32
radius: Theme.cornerRadius
color: {
if (!enabled)
return "transparent";
if (dangerous)
return menuItemArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent";
return menuItemArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent";
}
opacity: enabled ? 1 : 0.5
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
name: menuItem.iconName
size: 16
color: {
if (!menuItem.enabled)
return Theme.surfaceVariantText;
if (menuItem.dangerous && menuItemArea.containsMouse)
return Theme.error;
return Theme.surfaceText;
}
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: menuItem.text
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Normal
color: {
if (!menuItem.enabled)
return Theme.surfaceVariantText;
if (menuItem.dangerous && menuItemArea.containsMouse)
return Theme.error;
return Theme.surfaceText;
}
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: menuItemArea
anchors.fill: parent
hoverEnabled: true
cursorShape: menuItem.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
enabled: menuItem.enabled
onClicked: menuItem.clicked()
}
}
}

View File

@@ -42,6 +42,8 @@ DankPopout {
if (!shouldBeVisible) {
searchText = "";
expandedPid = "";
if (processesView)
processesView.reset();
}
}
@@ -69,9 +71,13 @@ DankPopout {
if (processListPopout.shouldBeVisible)
forceActiveFocus();
processContextMenu.parent = processListContent;
processContextMenu.parentFocusItem = processListContent;
}
Keys.onPressed: event => {
if (processContextMenu.visible)
return;
switch (event.key) {
case Qt.Key_Escape:
if (processListPopout.searchText.length > 0) {
@@ -79,6 +85,11 @@ DankPopout {
event.accepted = true;
return;
}
if (processesView.keyboardNavigationActive) {
processesView.reset();
event.accepted = true;
return;
}
processListPopout.close();
event.accepted = true;
return;
@@ -90,14 +101,15 @@ DankPopout {
}
break;
}
processesView.handleKey(event);
}
Connections {
target: processListPopout
function onShouldBeVisibleChanged() {
if (processListPopout.shouldBeVisible) {
if (processListPopout.shouldBeVisible)
Qt.callLater(() => processListContent.forceActiveFocus());
}
}
}
@@ -142,6 +154,8 @@ DankPopout {
showClearButton: true
text: processListPopout.searchText
onTextChanged: processListPopout.searchText = text
ignoreUpDownKeys: true
keyForwardTargets: [processListContent]
}
}
@@ -314,6 +328,7 @@ DankPopout {
clip: true
ProcessesView {
id: processesView
anchors.fill: parent
anchors.margins: Theme.spacingS
searchText: processListPopout.searchText

View File

@@ -11,18 +11,27 @@ Item {
property string searchText: ""
property string expandedPid: ""
property var contextMenu: null
property bool hoveringExpandedItem: false
readonly property bool pauseUpdates: hoveringExpandedItem || (contextMenu?.visible ?? false)
property int selectedIndex: -1
property bool keyboardNavigationActive: false
property int forceRefreshCount: 0
readonly property bool pauseUpdates: (contextMenu?.visible ?? false) || expandedPid.length > 0
readonly property bool shouldUpdate: !pauseUpdates || forceRefreshCount > 0
property var cachedProcesses: []
signal openContextMenuRequested(int index, real x, real y, bool fromKeyboard)
onFilteredProcessesChanged: {
if (!pauseUpdates)
cachedProcesses = filteredProcesses;
if (!shouldUpdate)
return;
cachedProcesses = filteredProcesses;
if (forceRefreshCount > 0)
forceRefreshCount--;
}
onPauseUpdatesChanged: {
if (!pauseUpdates)
onShouldUpdateChanged: {
if (shouldUpdate)
cachedProcesses = filteredProcesses;
}
@@ -75,6 +84,135 @@ Item {
return procs;
}
function selectNext() {
if (cachedProcesses.length === 0)
return;
keyboardNavigationActive = true;
selectedIndex = Math.min(selectedIndex + 1, cachedProcesses.length - 1);
ensureVisible();
}
function selectPrevious() {
if (cachedProcesses.length === 0)
return;
keyboardNavigationActive = true;
if (selectedIndex <= 0) {
selectedIndex = -1;
keyboardNavigationActive = false;
return;
}
selectedIndex = selectedIndex - 1;
ensureVisible();
}
function selectFirst() {
if (cachedProcesses.length === 0)
return;
keyboardNavigationActive = true;
selectedIndex = 0;
ensureVisible();
}
function selectLast() {
if (cachedProcesses.length === 0)
return;
keyboardNavigationActive = true;
selectedIndex = cachedProcesses.length - 1;
ensureVisible();
}
function toggleExpand() {
if (selectedIndex < 0 || selectedIndex >= cachedProcesses.length)
return;
const process = cachedProcesses[selectedIndex];
const pidStr = (process?.pid ?? -1).toString();
expandedPid = (expandedPid === pidStr) ? "" : pidStr;
}
function openContextMenu() {
if (selectedIndex < 0 || selectedIndex >= cachedProcesses.length)
return;
const delegate = processListView.itemAtIndex(selectedIndex);
if (!delegate)
return;
const process = cachedProcesses[selectedIndex];
if (!process || !contextMenu)
return;
contextMenu.processData = process;
const itemPos = delegate.mapToItem(contextMenu.parent, delegate.width / 2, delegate.height / 2);
contextMenu.parentFocusItem = root;
contextMenu.show(itemPos.x, itemPos.y, true);
}
function reset() {
selectedIndex = -1;
keyboardNavigationActive = false;
expandedPid = "";
}
function forceRefresh(count) {
forceRefreshCount = count || 3;
}
function ensureVisible() {
if (selectedIndex < 0)
return;
processListView.positionViewAtIndex(selectedIndex, ListView.Contain);
}
function handleKey(event) {
switch (event.key) {
case Qt.Key_Down:
selectNext();
event.accepted = true;
return;
case Qt.Key_Up:
selectPrevious();
event.accepted = true;
return;
case Qt.Key_J:
if (event.modifiers & Qt.ControlModifier) {
selectNext();
event.accepted = true;
}
return;
case Qt.Key_K:
if (event.modifiers & Qt.ControlModifier) {
selectPrevious();
event.accepted = true;
}
return;
case Qt.Key_Home:
selectFirst();
event.accepted = true;
return;
case Qt.Key_End:
selectLast();
event.accepted = true;
return;
case Qt.Key_Space:
if (keyboardNavigationActive) {
toggleExpand();
event.accepted = true;
}
return;
case Qt.Key_Return:
case Qt.Key_Enter:
if (keyboardNavigationActive) {
toggleExpand();
event.accepted = true;
}
return;
case Qt.Key_Menu:
case Qt.Key_F10:
if (keyboardNavigationActive && selectedIndex >= 0) {
openContextMenu();
event.accepted = true;
}
return;
}
}
Component.onCompleted: {
DgopService.addRef(["processes", "cpu", "memory", "system"]);
cachedProcesses = filteredProcesses;
@@ -163,22 +301,28 @@ Item {
delegate: ProcessItem {
required property var modelData
required property int index
width: processListView.width
process: modelData
isExpanded: root.expandedPid === (modelData?.pid ?? -1).toString()
isSelected: root.keyboardNavigationActive && root.selectedIndex === index
contextMenu: root.contextMenu
onToggleExpand: {
const pidStr = (modelData?.pid ?? -1).toString();
root.expandedPid = (root.expandedPid === pidStr) ? "" : pidStr;
}
onHoveringExpandedChanged: {
if (hoveringExpanded)
root.hoveringExpandedItem = true;
else
Qt.callLater(() => {
root.hoveringExpandedItem = false;
});
onClicked: {
root.keyboardNavigationActive = true;
root.selectedIndex = index;
}
onContextMenuRequested: (mouseX, mouseY) => {
if (root.contextMenu) {
root.contextMenu.processData = modelData;
root.contextMenu.parentFocusItem = root;
const globalPos = mapToItem(root.contextMenu.parent, mouseX, mouseY);
root.contextMenu.show(globalPos.x, globalPos.y, false);
}
}
}
@@ -288,10 +432,12 @@ Item {
property var process: null
property bool isExpanded: false
property bool isSelected: false
property var contextMenu: null
readonly property bool hoveringExpanded: (isExpanded && processMouseArea.containsMouse) || copyMouseArea.containsMouse
signal toggleExpand
signal clicked
signal contextMenuRequested(real mouseX, real mouseY)
readonly property int processPid: process?.pid ?? 0
readonly property real processCpu: process?.cpu ?? 0
@@ -301,8 +447,16 @@ Item {
height: isExpanded ? (44 + expandedRect.height + Theme.spacingXS) : 44
radius: Theme.cornerRadius
color: processMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.06) : "transparent"
border.color: processMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
color: {
if (isSelected)
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.15);
return processMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.06) : "transparent";
}
border.color: {
if (isSelected)
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3);
return processMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent";
}
border.width: 1
clip: true
@@ -327,14 +481,10 @@ Item {
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: mouse => {
if (mouse.button === Qt.RightButton) {
if (processItemRoot.processPid > 0 && processItemRoot.contextMenu) {
processItemRoot.contextMenu.processData = processItemRoot.process;
const globalPos = processMouseArea.mapToGlobal(mouse.x, mouse.y);
const localPos = processItemRoot.contextMenu.parent ? processItemRoot.contextMenu.parent.mapFromGlobal(globalPos.x, globalPos.y) : globalPos;
processItemRoot.contextMenu.show(localPos.x, localPos.y);
}
processItemRoot.contextMenuRequested(mouse.x, mouse.y);
return;
}
processItemRoot.clicked();
processItemRoot.toggleExpand();
}
}

View File

@@ -53,6 +53,7 @@ StyledRect {
property real topPadding: Theme.spacingM
property real bottomPadding: Theme.spacingM
property bool ignoreLeftRightKeys: false
property bool ignoreUpDownKeys: false
property bool ignoreTabKeys: false
property var keyForwardTargets: []
property Item keyNavigationTab: null
@@ -145,9 +146,16 @@ StyledRect {
if (root.ignoreTabKeys && (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab)) {
event.accepted = false;
for (var i = 0; i < root.keyForwardTargets.length; i++) {
if (root.keyForwardTargets[i]) {
if (root.keyForwardTargets[i])
root.keyForwardTargets[i].Keys.pressed(event);
}
return;
}
if (root.ignoreUpDownKeys && (event.key === Qt.Key_Up || event.key === Qt.Key_Down)) {
event.accepted = false;
for (var i = 0; i < root.keyForwardTargets.length; i++) {
if (root.keyForwardTargets[i])
root.keyForwardTargets[i].Keys.pressed(event);
}
}
}
}