plasmoides KDE Store/GitHub (gnome-pager, kMenu, kvitals, windowtitle, claude, launchpad)

This commit is contained in:
Josevi
2026-07-08 20:47:59 +02:00
parent ca606377ee
commit dd3b0f6ae8
136 changed files with 20239 additions and 0 deletions
@@ -0,0 +1,22 @@
import QtQuick
import org.kde.plasma.configuration
ConfigModel {
ConfigCategory {
name: i18n("General")
icon: "preferences-desktop-plasma"
source: "config/ConfigGeneral.qml"
}
ConfigCategory {
name: i18n("Edit Menu")
icon: "application-menu"
source: "config/ConfigMenuJson.qml"
}
ConfigCategory {
name: i18n("Edit JSON")
icon: "code-context"
source: "config/ConfigEditJson.qml"
}
}
@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8"?>
<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.kde.org/standards/kcfg/1.0
http://www.kde.org/standards/kcfg/1.0/kcfg.xsd" >
<kcfgfile name=""/>
<group name="General">
<entry name="icon" type="String">
<label>The name of the icon to use for the compact representation (e.g. on a small panel).</label>
<default>start-here-kde-symbolic</default>
</entry>
<entry name="useCustomButtonImage" type="Bool">
<label>Whether to use a custom image instead of an icon in the compact representation (e.g. on a small panel).</label>
<default>false</default>
</entry>
<entry name="customButtonImage" type="Url">
<label>The URL of the custom image to use instead of an icon in the compact representation (e.g. on a small panel).</label>
<default></default>
</entry>
<entry name="showIcons" type="Bool">
<label>Whether to show icons in the menu.</label>
<default>false</default>
</entry>
<entry name="menuWidth" type="Int">
<label>Width of the menu dropdown in pixels. Use 0 for default width.</label>
<default>240</default>
</entry>
<entry name="buttonHeight" type="Int">
<label>Height of each menu item (button) in pixels. Use 0 for default height.</label>
<default>28</default>
</entry>
<entry name="menuJson" type="String">
<label>JSON object defining the menu structure. Each item can have: name, icon, command, submenu (array of items), separator (bool).</label>
<default>[
{
"name": "About This Computer",
"icon": "help-hint",
"command": "kinfocenter"
},
{
"separator": true
},
{
"name": "System Preferences...",
"icon": "settings-configure",
"command": "systemsettings"
},
{
"name": "App Store...",
"icon": "update-none",
"command": "plasma-discover"
},
{
"separator": true
},
{
"name": "Force Quit...",
"icon": "error",
"command": "xkill"
},
{
"separator": true
},
{
"name": "Sleep",
"icon": "system-suspend",
"command": "systemctl suspend"
},
{
"name": "Restart...",
"icon": "system-reboot",
"command": "qdbus org.kde.LogoutPrompt /LogoutPrompt promptReboot"
},
{
"name": "Shut Down...",
"icon": "system-shutdown",
"command": "qdbus org.kde.LogoutPrompt /LogoutPrompt promptShutDown"
},
{
"separator": true
},
{
"name": "Lock Screen",
"icon": "system-lock-screen",
"command": "qdbus org.freedesktop.ScreenSaver /ScreenSaver Lock"
},
{
"name": "Log Out",
"icon": "system-log-out",
"command": "qdbus org.kde.LogoutPrompt /LogoutPrompt promptLogout"
}
]</default>
</entry>
</group>
</kcfg>
@@ -0,0 +1,251 @@
import QtQuick
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.ksvg as KSvg
import org.kde.plasma.components as PlasmaComponents3
/**
* ItemListDelegate - Delegate component that renders each individual menu item
*
* This component is used by ItemListView to display each menu item. It:
*
* 1. Renders the visual representation of menu items (icon, label, arrow for submenus)
* 2. Handles separator rendering with a visible line between menu sections
* 3. Executes commands when menu items are clicked
* 4. Handles keyboard navigation (Enter/Return key presses)
* 5. Manages hover states and visual feedback
* 6. Shows submenu indicators (arrows) for items with children
* 7. Provides accessibility roles and descriptions
*
* Used in ItemListView.qml as the delegate property, which means one instance
* of this component is created for each item in the menu model. Without this
* component, menu items would not be displayed at all.
*/
PlasmaComponents3.ItemDelegate {
id: item
width: ListView.view.width
// ListView uses itemSpacing between items (set by ItemListView)
property int itemSpacing: Kirigami.Units.smallSpacing
// 0 = no space (smallest), 0.1 = tiny, 0.25 = small, 0.5 = medium
readonly property int separatorVerticalMargin: Math.max(0, Math.round(Kirigami.Units.smallSpacing * 0))
// Text items: content height only. Separator: margin + line + margin
height: actualIsSeparator && !showSeparators ? 0 : (actualIsSeparator ? (separatorVerticalMargin * 2 + 2) : (buttonHeight > 0 ? buttonHeight : implicitHeight))
topPadding: 0
bottomPadding: 0
// if it's not disabled and is either a leaf node or a node with children
enabled: !actualIsSeparator && !disabled && (!isParent || (isParent && actualHasChildren))
required property int index
required property bool isSeparator
required property bool hasChildren
required property bool isParent
// Override isSeparator to read from source model if FunnelModel doesn't pass it through
property bool actualIsSeparator: {
// First try the model's isSeparator (works if FunnelModel passes it through)
if (isSeparator) {
return true;
}
// If not, try to get it from the source model directly
const sourceModel = ListView.view ? (ListView.view.model.sourceModel || ListView.view.model) : null;
if (sourceModel && sourceModel.get) {
try {
const modelItem = sourceModel.get(index);
if (modelItem && modelItem.isSeparator) {
return modelItem.isSeparator;
}
} catch (e) {
// Ignore errors
}
}
return isSeparator; // Fallback to the original value
}
// Override hasChildren to read from source model if FunnelModel doesn't pass it through
property bool actualHasChildren: {
// First try the model's hasChildren (works if FunnelModel passes it through)
if (hasChildren) {
return true;
}
// If not, try to get it from the source model directly
const sourceModel = ListView.view ? (ListView.view.model.sourceModel || ListView.view.model) : null;
if (sourceModel && sourceModel.get) {
try {
const modelItem = sourceModel.get(index);
if (modelItem && modelItem.hasChildren) {
return modelItem.hasChildren;
}
} catch (e) {
// Ignore errors
}
}
return hasChildren; // Fallback to the original value
}
required property bool disabled
required property url url
required property string description
required property string decoration
required property var model // for display, which would shadow ItemDelegate
required property bool showIcons
readonly property bool iconAndLabelsShouldlookSelected: pressed && !actualHasChildren
property bool showSeparators: true
/** When > 0, height of this menu item in pixels; set by ItemListView. 0 = use implicit height. */
property int buttonHeight: 0
property bool dialogDefaultRight: Application.layoutDirection !== Qt.RightToLeft
signal interactionConcluded
Accessible.role: actualIsSeparator ? Accessible.Separator : Accessible.ListItem
Accessible.description: isParent
? i18nc("@action:inmenu accessible description for opening submenu", "Open category")
: i18nc("@action:inmenu accessible description for opening app or file", "Launch")
text: model.display
icon.name: decoration
onActualHasChildrenChanged: {
if (!actualHasChildren && ListView.view.currentItem === item) {
ListView.view.currentIndex = -1;
}
}
onClicked: {
if (!item.actualHasChildren) {
// Try to get command from model and execute directly via executable
const model = item.ListView.view ? item.ListView.view.model : null;
if (model) {
// Get sourceModel from FunnelModel if it exists
const sourceModel = model.sourceModel || model;
// Get the item from the sourceModel
const modelItem = sourceModel && sourceModel.get ? sourceModel.get(index) : null;
if (modelItem) {
const cmd = modelItem.command ? String(modelItem.command) : null;
if (cmd) {
// Try to get executable from sourceModel and execute directly
if (sourceModel && sourceModel.executable && typeof sourceModel.executable.exec === 'function') {
sourceModel.executable.exec(cmd);
item.interactionConcluded();
return;
}
// Fallback: try model's executable (FunnelModel)
if (model.executable && typeof model.executable.exec === 'function') {
model.executable.exec(cmd);
item.interactionConcluded();
return;
}
}
}
}
// Fallback to trigger function
if (item.ListView.view && item.ListView.view.model) {
if (typeof item.ListView.view.model.trigger === 'function') {
const result = item.ListView.view.model.trigger(index, "", null);
if (result) {
item.interactionConcluded()
}
} else {
item.interactionConcluded()
}
} else {
item.interactionConcluded()
}
}
}
DragHandler {
target: null
onActiveChanged: {
if (active && item.url) {
// we need dragHelper and can't use attached Drag; submenus are destroyed too soon and Plasma crashes
dragHelper.startDrag(kicker, item.url, item.decoration)
}
}
}
contentItem: RowLayout {
id: row
spacing: Kirigami.Units.smallSpacing * 2
LayoutMirroring.enabled: (Application.layoutDirection === Qt.RightToLeft)
Kirigami.Icon {
id: icon
Layout.alignment: Qt.AlignVCenter
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: implicitWidth
visible: item.showIcons & !item.actualIsSeparator
animated: false
selected: item.iconAndLabelsShouldlookSelected
source: item.icon.name
}
PlasmaComponents3.Label {
id: label
enabled: !item.isParent || (item.isParent && item.actualHasChildren)
LayoutMirroring.enabled: (Application.layoutDirection === Qt.RightToLeft)
visible: !item.actualIsSeparator
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
Layout.leftMargin: Kirigami.Units.smallSpacing
verticalAlignment: Text.AlignVCenter
textFormat: Text.PlainText
wrapMode: Text.NoWrap
elide: Text.ElideRight
color: item.iconAndLabelsShouldlookSelected ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor
text: item.text
}
Kirigami.Icon {
id: arrow
Layout.alignment: Qt.AlignVCenter
implicitWidth: visible ? Kirigami.Units.iconSizes.small : 0
implicitHeight: implicitWidth
visible: item.actualHasChildren && !item.actualIsSeparator
opacity: (item.ListView.view.currentIndex === item.index) ? 1.0 : 0.4
selected: item.iconAndLabelsShouldlookSelected
source: item.dialogDefaultRight
? "go-next-symbolic"
: "go-next-rtl-symbolic"
}
}
// Separator line - outside RowLayout, directly in ItemDelegate
Rectangle {
id: separatorLine
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
height: (item.actualIsSeparator && item.showSeparators) ? 1.6 : 0
visible: item.actualIsSeparator && item.showSeparators
color: Qt.rgba(0, 0, 0, 0.10) // 12% opacity black like kMenu
z: 10 // Above contentItem
}
Keys.onReturnPressed: item.clicked()
Keys.onEnterPressed: Keys.returnPressed()
}
@@ -0,0 +1,99 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.plasma.core as PlasmaCore
import org.kde.plasma.private.kicker as Kicker
/**
* ItemListDialog - Component that creates submenus (nested menus) for items with children
*
* This component is used to display nested submenus when a menu item has children.
* It:
*
* 1. Wraps an ItemListView in a Kicker.SubMenu to create a floating submenu dialog
* 2. Handles the model for nested menu items via FunnelModel
* 3. Manages submenu positioning and layout mirroring (RTL support)
* 4. Propagates interaction signals (interactionConcluded) to close the entire menu
* 5. Handles window deactivation to close submenus when clicking outside
* 6. Maintains a reference to the parent ItemListView for signal propagation
*
* Used in ItemListView.qml via the itemListDialogComponent property. When a menu
* item with children is hovered or selected, ItemListView creates an instance of
* this component to display the submenu. Without this component, nested menus
* would not work.
*/
Kicker.SubMenu {
id: itemDialog
property alias model: funnelModel.sourceModel
property Component itemListDialogComponent: null
property int index: -1
property bool aboutToBeDestroyed: false
property ItemListView parentItemListView: null
property alias mainSearchField: itemListView.mainSearchField
signal interactionConcluded
visible: true
location: PlasmaCore.Types.Floating
offset: Kirigami.Units.smallSpacing
LayoutMirroring.enabled: dialogMirrored
onInteractionConcluded: itemDialog.interactionConcluded()
onWindowDeactivated: {
if (!aboutToBeDestroyed) {
interactionConcluded()
}
}
mainItem: ItemListView {
id: itemListView
height: implicitHeight
width: Math.min(Math.max(Layout.minimumWidth, implicitWidth), Layout.maximumWidth)
iconsEnabled: itemDialog.parentItemListView ? itemDialog.parentItemListView.iconsEnabled : false
buttonHeight: itemDialog.parentItemListView ? itemDialog.parentItemListView.buttonHeight : 0
itemSpacing: itemDialog.parentItemListView ? itemDialog.parentItemListView.itemSpacing : Kirigami.Units.smallSpacing
LayoutMirroring.enabled: itemDialog.LayoutMirroring.enabled
dialog: itemDialog
itemListDialogComponent: itemDialog.itemListDialogComponent
model: funnelModel
onInteractionConcluded: itemDialog.interactionConcluded()
Kicker.FunnelModel {
id: funnelModel
property bool sorted: sourceModel?.sorted ?? false
Component.onCompleted: {
// kicker.reset is not available in this context, skip connection
// funnelModel.reset will be called automatically when needed
}
onCountChanged: {
if (sourceModel && count === 0) {
itemDialog.delayedDestroy();
}
}
onSourceModelChanged: {
itemListView.currentIndex = -1;
itemListView.resetDelegateSizing();
}
}
}
function delayedDestroy() {
aboutToBeDestroyed = true;
Qt.callLater(() => itemDialog.destroy());
}
}
@@ -0,0 +1,391 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.plasma.components as PlasmaComponents3
import org.kde.plasma.extras as PlasmaExtras
import org.kde.plasma.private.kicker as Kicker
PlasmaComponents3.ScrollView {
id: itemList
signal exited
signal keyNavigationAtListEnd
signal navigateLeftRequested
signal navigateRightRequested
signal interactionConcluded
function propagateInteractionConcludedToRoot() {
// Find the root ItemListView and call interactionConcluded on it
let root = itemList;
while (root.dialog && root.dialog.parentItemListView) {
root = root.dialog.parentItemListView;
}
root.interactionConcluded();
}
readonly property var actualScrollBarWidth: (itemList.contentHeight > itemList.height ? PlasmaComponents3.ScrollBar.vertical.width : 0)
property Item mainSearchField: null
property Kicker.SubMenu dialog: null
property Kicker.SubMenu childDialog: null
property bool iconsEnabled: false
property Component itemListDialogComponent: null
property alias currentIndex: listView.currentIndex
property alias currentItem: listView.currentItem
property alias keyNavigationWraps: listView.keyNavigationWraps
property alias model: listView.model
property alias count: listView.count
property alias resetOnExitDelay: resetIndexTimer.interval
property alias showSeparators: listView.showSeparators
property alias listView: listView
/** When > 0, fixed width for the menu in pixels; 0 = use default width. */
property int menuWidth: 0
/** When > 0, height of each menu item (button) in pixels; 0 = use default (implicit) height. */
property int buttonHeight: 0
/** Vertical gap between every menu item (and around separators); one value for consistent spacing. */
property int itemSpacing: Kirigami.Units.smallSpacing
implicitWidth: listView.implicitWidth + actualScrollBarWidth
implicitHeight: listView.contentHeight
readonly property int _defaultMinWidth: Kirigami.Units.gridUnit * 14
Layout.minimumWidth: menuWidth > 0 ? menuWidth : _defaultMinWidth
Layout.maximumWidth: menuWidth > 0 ? menuWidth : Math.round(_defaultMinWidth * 1.5)
Layout.maximumHeight: contentHeight
PlasmaComponents3.ScrollBar.horizontal.policy: PlasmaComponents3.ScrollBar.AlwaysOff
function resetDelegateSizing() { // only needed when submenus are reused, called from ItemListDialog
listView.maxDelegateImplicitWidth = 0
}
function subMenuForCurrentItem(focusOnSpawn=false) {
// Menu is always expanded when shown, so we don't need to check kicker.expanded
if (!itemList.model || listView.currentIndex === -1) {
return;
}
const currentItem = listView.currentItem as ItemListDelegate;
if (!currentItem) {
return;
}
if (!currentItem.actualHasChildren) {
clearChildDialog();
return;
}
if (!itemList.childDialog) {
// Get the source model from FunnelModel if it exists, otherwise use model directly
const sourceModel = (model && model.sourceModel) ? model.sourceModel : model;
if (!sourceModel) {
return;
}
if (!sourceModel.modelForRow) {
return;
}
let subModel = null;
try {
subModel = sourceModel.modelForRow(listView.currentIndex);
} catch (e) {
return;
}
if (!subModel) {
return;
}
if (!itemList.itemListDialogComponent) {
return;
}
if (!subModel || typeof subModel.count === 'undefined' || subModel.count === 0) {
return;
}
// Create the dialog with the submenu model
// Kicker.SubMenu with Floating should be parented to null (top-level)
const dialog = itemList.itemListDialogComponent.createObject(null, {
visualParent: listView.currentItem,
model: subModel,
visible: true,
dialogMirrored: itemList.LayoutMirroring.enabled,
itemListDialogComponent: itemList.itemListDialogComponent
});
if (dialog) {
itemList.childDialog = dialog;
dialog.index = listView.currentIndex;
dialog.visible = true;
dialog.parentItemListView = itemList;
// Connect child dialog's interactionConcluded to propagate to root
// This ensures commands in child dialogs close the entire menu
dialog.interactionConcluded.connect(() => {
if (dialog.parentItemListView) {
dialog.parentItemListView.propagateInteractionConcludedToRoot();
}
});
if (focusOnSpawn && dialog.mainItem) {
const childListView = dialog.mainItem as ItemListView;
childListView.currentIndex = 0;
// Use Qt.callLater to ensure the dialog is fully initialized before setting focus
Qt.callLater(() => {
if (childListView && childListView.listView) {
childListView.forceActiveFocus(Qt.TabFocusReason);
// Also ensure the ListView inside gets focus
childListView.listView.forceActiveFocus(Qt.TabFocusReason);
}
});
}
}
} else {
const sourceModel = (model && model.sourceModel) ? model.sourceModel : model;
const subModel = (sourceModel && sourceModel.modelForRow) ? sourceModel.modelForRow(itemList.currentIndex) : null;
if (subModel) {
itemList.childDialog.model = subModel;
itemList.childDialog.visualParent = listView.currentItem;
itemList.childDialog.index = listView.currentIndex;
}
}
}
function clearChildDialog() {
if (childDialog) {
childDialog.delayedDestroy();
childDialog = null;
}
}
Keys.priority: Keys.AfterItem
Keys.forwardTo: [itemList.mainSearchField]
onHoveredChanged: {
if (hovered) {
resetIndexTimer.stop();
} else if (itemList.childDialog && listView.currentIndex != itemList.childDialog?.index) {
listView.currentIndex = childDialog.index
} else if ((!itemList.childDialog || !itemList.dialog)
&& (!itemList.currentItem || !(itemList.currentItem as ItemListDelegate).menu.opened)) {
resetIndexTimer.start();
}
}
ListView {
id: listView
width: itemList.availableWidth
implicitHeight: contentHeight
implicitWidth: itemList.Layout.minimumWidth
property int maxDelegateImplicitWidth: 0 // used to set implicitWidth
property bool showSeparators: !model.sorted // separators are mostly useless when sorted
Binding on implicitWidth {
value: listView.maxDelegateImplicitWidth
delayed: true // only resize once all delegates are loaded
when: listView.maxDelegateImplicitWidth > 0
}
currentIndex: -1
focus: true
clip: height < contentHeight + topMargin + bottomMargin
boundsBehavior: Flickable.StopAtBounds
snapMode: ListView.SnapToItem
// Same gap between every item (text and separator) for consistent margin
spacing: itemList.itemSpacing
keyNavigationEnabled: false
cacheBuffer: 10000 // try to load all delegates for sizing; krunner won't return too many anyway
Accessible.name: itemList.Accessible.name
Accessible.role: Accessible.List
function updateImplicitWidth () {
implicitWidth = maxDelegateImplicitWidth
}
delegate: ItemListDelegate {
showSeparators: listView.showSeparators
showIcons: itemList.iconsEnabled
buttonHeight: itemList.buttonHeight
itemSpacing: itemList.itemSpacing
dialogDefaultRight: !itemList.LayoutMirroring.enabled
hoverEnabled: itemList.hoverEnabled
onInteractionConcluded: {
// If we're in a submenu, propagate directly to root to ensure menu closes
if (itemList.dialog && itemList.dialog.parentItemListView) {
itemList.propagateInteractionConcludedToRoot();
} else {
// At root level, emit normally
itemList.interactionConcluded();
}
}
onHoveredChanged: {
if (hovered & !actualIsSeparator) {
listView.currentIndex = index
itemList.forceActiveFocus()
dialogSpawnTimer.restart()
} else if (listView.currentIndex === index) {
dialogSpawnTimer.stop()
}
}
Connections {
target: itemList.mainSearchField
function onTextChanged() {
listView.maxDelegateImplicitWidth = 0
}
}
onImplicitWidthChanged: {
listView.maxDelegateImplicitWidth = Math.max(listView.maxDelegateImplicitWidth, implicitWidth)
}
}
highlight: PlasmaExtras.Highlight {
width: listView.width
visible: !(listView.currentItem as ItemListDelegate)?.actualIsSeparator
pressed: !!((listView.currentItem as ItemListDelegate)?.pressed && (listView.currentItem as ItemListDelegate)?.actualHasChildren)
active: !!(listView.currentItem as ItemListDelegate)?.hovered
}
highlightMoveDuration: 0
onCountChanged: {
if (currentIndex == 0 && !itemList.mainSearchField.activeFocus) {
currentItem?.forceActiveFocus();
} else {
currentIndex = -1;
}
}
onCurrentIndexChanged: {
if (currentIndex === itemList.childDialog?.index) {
return;
}
const delegate = currentItem as ItemListDelegate;
if (currentIndex === -1 || (delegate && !delegate.actualHasChildren)) {
dialogSpawnTimer.stop();
itemList.clearChildDialog();
} else if (delegate && delegate.actualHasChildren) {
// Item has children, restart timer to show submenu
// Don't restart timer here - let the hover handler do it
// The timer will fire and call subMenuForCurrentItem
}
}
Connections {
target: (listView.currentItem as ItemListDelegate)?.menu ?? null
function onClosed() {
resetIndexTimer.restart()
}
}
function handleLeftRightArrowEnter(event: KeyEvent) : void {
let backArrowKey = (event.key === Qt.Key_Left && !itemList.LayoutMirroring.enabled) ||
(event.key === Qt.Key_Right && itemList.LayoutMirroring.enabled)
let forwardArrowKey = (event.key === Qt.Key_Right && !itemList.LayoutMirroring.enabled) ||
(event.key === Qt.Key_Left && itemList.LayoutMirroring.enabled)
if (backArrowKey) {
if (itemList.dialog != null) {
itemList.dialog.destroy();
} else {
itemList.navigateLeftRequested();
}
} else if (forwardArrowKey || event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (listView.currentItem !== null && (listView.currentItem as ItemListDelegate).actualHasChildren) {
if (itemList.childDialog === null) {
itemList.subMenuForCurrentItem(true);
} else {
const childListView = itemList.childDialog.mainItem as ItemListView;
if (childListView) {
childListView.forceActiveFocus(Qt.TabFocusReason);
// Ensure the ListView inside gets focus for keyboard navigation
if (childListView.listView) {
childListView.listView.forceActiveFocus(Qt.TabFocusReason);
}
childListView.currentIndex = 0;
}
}
} else if (forwardArrowKey) {
itemList.navigateRightRequested();
} else {
event.accepted = false;
}
}
}
function handleUpDownArrow(event: KeyEvent) : void {
let moveIndex = (event.key === Qt.Key_Up) ? listView.decrementCurrentIndex : listView.incrementCurrentIndex
if (!listView.keyNavigationWraps && ((event.key === Qt.Key_Up && listView.currentIndex == 0) ||
(event.key === Qt.Key_Down && listView.currentIndex == listView.count - 1))) {
itemList.keyNavigationAtListEnd();
} else {
moveIndex();
if (listView.currentItem !== null) {
if ((listView.currentItem as ItemListDelegate).actualIsSeparator) {
moveIndex();
}
listView.currentItem.forceActiveFocus(Qt.TabFocusReason);
}
}
}
Keys.onLeftPressed: event => handleLeftRightArrowEnter(event)
Keys.onRightPressed: event => handleLeftRightArrowEnter(event)
Keys.onEnterPressed: event => handleLeftRightArrowEnter(event)
Keys.onReturnPressed: event => handleLeftRightArrowEnter(event)
Keys.onUpPressed: event => handleUpDownArrow(event)
Keys.onDownPressed: event => handleUpDownArrow(event)
Keys.onEscapePressed: {
// If we're in a child dialog, propagate to root by traversing parent chain
if (itemList.dialog && itemList.dialog.parentItemListView) {
// We're in a child dialog, find root and call it
let root = itemList.dialog.parentItemListView;
while (root.dialog && root.dialog.parentItemListView) {
root = root.dialog.parentItemListView;
}
root.interactionConcluded();
} else {
// We're at root level, just emit the signal
itemList.interactionConcluded();
}
}
Keys.onPressed: event => {
if (event.key !== Qt.Key_Tab && event.text !== "") {
itemList.mainSearchField?.forceActiveFocus(Qt.ShortcutFocusReason);
}
}
Timer {
id: dialogSpawnTimer
interval: 100
repeat: false
onTriggered: {
itemList.subMenuForCurrentItem();
}
}
Timer {
id: resetIndexTimer
interval: (itemList.dialog != null) ? 50 : 150
repeat: false
onTriggered: {
if (itemList.focus && !(itemList.childDialog?.mainItem as ItemListView)?.hovered) {
itemList.currentIndex = -1;
itemList.exited();
}
}
}
}
}
@@ -0,0 +1,324 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQml
import org.kde.plasma.private.kicker as Kicker
/**
* JsonMenuModel - Data model that converts JSON menu configuration into a ListModel
*
* This component is the core data layer for the JSON-based menu system. It:
*
* 1. Parses the JSON menu data from Plasmoid.configuration.menuJson
* 2. Converts the JSON into a ListModel that ItemListView can display
* 3. Handles nested submenus by creating child models dynamically
* 4. Executes commands when menu items are clicked (via the trigger function)
* 5. Manages separators, icons, and other menu item properties
* 6. Menu order follows the JSON order (no alphabetical sorting)
*
* Used in main.qml as rootModel, which is passed to MenuRepresentation and then
* to ItemListView to display the menu. Without this component, the menu would
* not work - it's the bridge between JSON configuration and the UI.
*/
ListModel {
id: root
property string jsonData: ""
property bool sorted: false
property var executable: null
property var _submenuCache: ({}) // Cache submenu items by index
signal refreshed()
function refresh() {
root.clear();
root._submenuCache = {}; // Clear cache
if (!jsonData || jsonData === "") {
refreshed();
return;
}
try {
const menuItems = JSON.parse(jsonData);
populateModel(menuItems, root);
refreshed();
} catch (e) {
console.error("JsonMenuModel: Error parsing menu JSON:", e);
refreshed();
}
}
function populateModel(items, parentModel) {
if (!Array.isArray(items)) {
return;
}
if (!parentModel) {
return;
}
items.forEach((item, index) => {
if (item.separator) {
parentModel.append({
display: "",
decoration: "",
url: "",
isSeparator: true,
hasChildren: false,
isParent: false,
disabled: false,
description: "",
model: {}
});
} else {
const hasSubmenu = item.submenu && Array.isArray(item.submenu) && item.submenu.length > 0;
const command = item.command || "";
const url = command ? ("file://" + command) : "";
const submenuItems = item.submenu || [];
// Store submenu items in cache for later retrieval
if (parentModel === root && hasSubmenu) {
root._submenuCache[parentModel.count] = submenuItems;
}
parentModel.append({
display: item.name || "",
decoration: item.icon || "",
url: url,
isSeparator: false,
hasChildren: hasSubmenu,
isParent: hasSubmenu,
disabled: item.disabled || false,
description: item.description || item.name || "",
command: command,
submenuItems: submenuItems, // Store in model item too (might not be accessible)
model: {
display: item.name || "",
description: item.description || item.name || ""
}
});
}
});
}
// Helper function to execute commands - accessible from submenu models via root
function executeCommand(cmd) {
if (executable && typeof executable.exec === 'function') {
executable.exec(String(cmd));
return true;
}
console.error("JsonMenuModel.executeCommand: executable not available");
return false;
}
function createSubMenuModel(submenuItems, parentExecutable) {
// Create a cache for this submodel's submenu items
const submenuCache = {};
const subModel = Qt.createQmlObject(`
import QtQuick
import QtQml
ListModel {
property var executable: null
property bool sorted: false
property var _submenuCache: ({})
function trigger(index, actionId, actionArgument) {
if (index < 0 || index >= count) {
return false;
}
const item = get(index);
if (item.isSeparator || item.disabled || item.hasChildren) {
return false;
}
if (item.command && executable && typeof executable.exec === 'function') {
executable.exec(String(item.command));
return true;
}
return false;
}
function modelForRow(index) {
if (index < 0 || index >= count) {
return null;
}
const item = get(index);
if (!item.hasChildren) {
return null;
}
// Get submenuItems from cache first, then try item directly
let submenuItems = _submenuCache[index];
if (!submenuItems) {
submenuItems = item.submenuItems;
}
if (!submenuItems || !Array.isArray(submenuItems) || submenuItems.length === 0) {
return null;
}
// For nested submenus, recursively call the parent's createSubMenuModel
const execToPass = executable || (root && root.executable ? root.executable : null);
if (root && root.createSubMenuModel) {
return root.createSubMenuModel(submenuItems, execToPass);
}
return null;
}
}
`, root);
// Set properties with error handling
// Assign executable property
try {
if (parentExecutable) {
subModel.executable = parentExecutable;
} else if (root.executable) {
subModel.executable = root.executable;
}
} catch (e) {
console.error("JsonMenuModel.createSubMenuModel: Cannot assign executable:", e);
}
try {
subModel.sorted = root.sorted;
} catch (e) {
console.error("JsonMenuModel.createSubMenuModel: Cannot assign sorted:", e);
}
// Populate the model directly in the createSubMenuModel function
if (Array.isArray(submenuItems)) {
submenuItems.forEach((item, index) => {
if (item.separator) {
subModel.append({
display: "",
decoration: "",
url: "",
isSeparator: true,
hasChildren: false,
isParent: false,
disabled: false,
description: "",
model: {}
});
} else {
const hasSubmenu = item.submenu && Array.isArray(item.submenu) && item.submenu.length > 0;
const command = item.command || "";
const url = command ? ("file://" + command) : "";
const itemSubmenuItems = item.submenu || [];
// Store submenu items in cache for later retrieval
if (hasSubmenu) {
submenuCache[subModel.count] = itemSubmenuItems;
}
// Ensure hasChildren is explicitly a boolean
const hasChildrenValue = Boolean(hasSubmenu);
const isParentValue = Boolean(hasSubmenu);
subModel.append({
display: item.name || "",
decoration: item.icon || "",
url: url,
isSeparator: false,
hasChildren: hasChildrenValue,
isParent: isParentValue,
disabled: Boolean(item.disabled || false),
description: item.description || item.name || "",
command: command,
submenuItems: itemSubmenuItems, // Store in model item too (might not be accessible)
model: {
display: item.name || "",
description: item.description || item.name || ""
}
});
}
});
}
// Assign the cache to the submodel
try {
subModel._submenuCache = submenuCache;
} catch (e) {
console.error("JsonMenuModel.createSubMenuModel: Cannot assign _submenuCache:", e);
}
return subModel;
}
function modelForRow(index) {
if (index < 0 || index >= root.count) {
return null;
}
const item = root.get(index);
if (!item.hasChildren) {
return null;
}
// Get submenuItems from cache (since ListModel might not preserve arbitrary properties)
let submenuItems = root._submenuCache[index];
if (!submenuItems) {
// Fallback: try to get from item directly
if (item.submenuItems) {
submenuItems = item.submenuItems;
} else {
// Last resort: parse JSON again to get submenu items
try {
const menuItems = JSON.parse(root.jsonData);
if (Array.isArray(menuItems) && index < menuItems.length) {
const jsonItem = menuItems[index];
if (jsonItem && jsonItem.submenu) {
submenuItems = jsonItem.submenu;
}
}
} catch (e) {
console.error("JsonMenuModel.modelForRow: Error parsing JSON:", e);
}
}
}
if (!submenuItems || !Array.isArray(submenuItems) || submenuItems.length === 0) {
return null;
}
const subModel = createSubMenuModel(submenuItems, executable);
return subModel;
}
function trigger(index, actionId, actionArgument) {
if (index < 0 || index >= root.count) {
return false;
}
const item = root.get(index);
if (item.isSeparator || item.disabled) {
return false;
}
if (item.hasChildren) {
// Has submenu, don't close
return false;
}
if (item.command && executable) {
executable.exec(String(item.command));
return true;
}
return false;
}
function labelForRow(index) {
if (index < 0 || index >= root.count) {
return "";
}
return root.get(index).display;
}
onJsonDataChanged: {
refresh();
}
Component.onCompleted: {
refresh();
}
}
@@ -0,0 +1,132 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.plasma.components as PlasmaComponents3
import org.kde.plasma.core as PlasmaCore
import org.kde.plasma.plasmoid
/**
* MenuRepresentation - Main UI component that displays the menu
*
* This component is the primary visual representation of the menu. It:
*
* 1. Wraps the menu in a ScrollView for scrolling when content exceeds available space
* 2. Contains the main ItemListView that displays menu items from the JsonMenuModel
* 3. Handles keyboard focus and navigation
* 4. Manages hover blocking to prevent accidental hover activation when using keyboard
* 5. Connects signals between the model and the view (interactionConcluded, reset, etc.)
* 6. Handles layout and sizing constraints
*
* Used in main.qml as the fullRepresentation component, which displays when the
* plasmoid button is clicked. This is the main entry point for the menu UI.
*/
PlasmaComponents3.ScrollView {
id: root
required property var rootModel
required property Component itemListDialogComponent
signal interactionConcluded
focus: true
Layout.minimumWidth: Math.min(mainRow.width, mainRow.implicitWidth, Screen.width - Kirigami.Units.largeSpacing * 4)
Layout.maximumWidth: Layout.minimumWidth
contentWidth: mainRow.implicitWidth
Layout.minimumHeight: rootList.implicitHeight
Layout.maximumHeight: Layout.minimumHeight
function reset() {
rootList.currentIndex = -1;
hoverBlock.reset();
}
RowLayout {
id: mainRow
anchors.fill: parent
spacing: Kirigami.Units.smallSpacing
LayoutMirroring.enabled: ((Plasmoid.location === PlasmaCore.Types.RightEdge)
|| (Application.layoutDirection === Qt.RightToLeft && Plasmoid.location !== PlasmaCore.Types.LeftEdge))
ItemListView {
id: rootList
Layout.alignment: Qt.AlignTop
Layout.fillHeight: true
Layout.fillWidth: true
menuWidth: Plasmoid.configuration.menuWidth || 0
buttonHeight: Plasmoid.configuration.buttonHeight || 0
visible: true
iconsEnabled: Plasmoid.configuration.showIcons
hoverEnabled: !hoverBlock.enabled
itemListDialogComponent: root.itemListDialogComponent
model: root.rootModel
LayoutMirroring.enabled: mainRow.LayoutMirroring.enabled
showSeparators: true // keep even if sorted, the one between recents and categories works
onInteractionConcluded: root.interactionConcluded()
Component.onCompleted: {
rootList.exited.connect(root.reset);
}
}
}
Component.onCompleted: {
kicker.modelRefreshed.connect(() => {
root.reset()
});
kicker.reset.connect(reset)
rootModel.refresh();
// Give list focus when menu opens so keyboard navigation works without hovering first
if (root.visible) {
Qt.callLater(() => rootList.forceActiveFocus(Qt.PopupFocusReason));
}
}
Connections {
target: root
function onVisibleChanged() {
if (root.visible) {
Qt.callLater(() => rootList.forceActiveFocus(Qt.PopupFocusReason));
}
}
}
MouseArea {
id: hoverBlock // don't hover-activate until mouse is moved to not interfere with keyboard use
anchors.fill: parent
hoverEnabled: true
propagateComposedEvents: true // clicking should still work if hovering is blocked
property bool mouseMoved: false
function reset() {
mouseMoved = false
enabled = true
}
onPositionChanged: if (!mouseMoved) {
mouseMoved = true
} else {
enabled = false // this immediately triggers other hover events when bound to their hoverEnabled
}
}
}
@@ -0,0 +1,48 @@
/*
SPDX-FileCopyrightText: 2024
SPDX-License-Identifier: GPL-2.0-or-later
*/
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls as QQC2
import org.kde.kcmutils as KCM
import org.kde.kirigami as Kirigami
KCM.SimpleKCM {
id: configEditJson
property string cfg_menuJson: plasmoid.configuration.menuJson || "[]"
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
QQC2.Label {
text: i18n("Edit the menu structure as JSON. Each item can have: name, icon, command, submenu (array of items), or separator (true).")
wrapMode: Text.WordWrap
Layout.fillWidth: true
Layout.bottomMargin: Kirigami.Units.smallSpacing
}
QQC2.TextArea {
id: jsonField
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: Kirigami.Units.gridUnit * 15
text: configEditJson.cfg_menuJson
placeholderText: "[]"
wrapMode: TextArea.NoWrap
selectByMouse: true
font.family: "monospace"
onTextChanged: {
if (text !== configEditJson.cfg_menuJson) {
configEditJson.cfg_menuJson = text;
}
}
}
}
}
@@ -0,0 +1,166 @@
/*
SPDX-FileCopyrightText: 2014 Eike Hein <hein@kde.org>
SPDX-License-Identifier: GPL-2.0-or-later
*/
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls as QQC2
import org.kde.draganddrop as DragDrop
import org.kde.iconthemes as KIconThemes
import org.kde.kcmutils as KCM
import org.kde.kirigami as Kirigami
import org.kde.ksvg as KSvg
import org.kde.plasma.core as PlasmaCore
KCM.SimpleKCM {
id: configGeneral
property string cfg_icon: plasmoid.configuration.icon
property bool cfg_useCustomButtonImage: plasmoid.configuration.useCustomButtonImage
property string cfg_customButtonImage: plasmoid.configuration.customButtonImage
property alias cfg_showIcons: showIcons.checked
property alias cfg_menuWidth: menuWidthSpinBox.value
property alias cfg_buttonHeight: buttonHeightSpinBox.value
Kirigami.FormLayout {
anchors.left: parent.left
anchors.right: parent.right
QQC2.Button {
id: iconButton
Kirigami.FormData.label: i18n("Icon:")
implicitWidth: previewFrame.width + Kirigami.Units.smallSpacing * 2
implicitHeight: previewFrame.height + Kirigami.Units.smallSpacing * 2
checkable: true
checked: dropArea.containsAcceptableDrag
onPressed: iconMenu.opened ? iconMenu.close() : iconMenu.open()
DragDrop.DropArea {
id: dropArea
property bool containsAcceptableDrag: false
anchors.fill: parent
onDragEnter: event => {
const urlString = event.mimeData.url.toString();
const extensions = [".png", ".xpm", ".svg", ".svgz"];
containsAcceptableDrag = urlString.startsWith("file:///")
&& extensions.some(extension => urlString.endsWith(extension));
if (!containsAcceptableDrag) {
event.ignore();
}
}
onDragLeave: event => {
containsAcceptableDrag = false
}
onDrop: event => {
if (containsAcceptableDrag) {
iconDialog.setCustomButtonImage(event.mimeData.url.toString().substr("file://".length));
}
containsAcceptableDrag = false;
}
}
KIconThemes.IconDialog {
id: iconDialog
function setCustomButtonImage(image) {
configGeneral.cfg_customButtonImage = image || configGeneral.cfg_icon || "start-here-kde-symbolic"
configGeneral.cfg_useCustomButtonImage = true;
}
onIconNameChanged: (iconName) => {
if (iconName != "") {
setCustomButtonImage(iconName)
}
}
}
KSvg.FrameSvgItem {
id: previewFrame
anchors.centerIn: parent
imagePath: plasmoid.location === PlasmaCore.Types.Vertical || plasmoid.location === PlasmaCore.Types.Horizontal
? "widgets/panel-background" : "widgets/background"
width: Kirigami.Units.iconSizes.large + fixedMargins.left + fixedMargins.right
height: Kirigami.Units.iconSizes.large + fixedMargins.top + fixedMargins.bottom
Kirigami.Icon {
anchors.centerIn: parent
width: Kirigami.Units.iconSizes.large
height: width
source: configGeneral.cfg_useCustomButtonImage ? configGeneral.cfg_customButtonImage : configGeneral.cfg_icon
}
}
QQC2.Menu {
id: iconMenu
y: parent.height
onClosed: iconButton.checked = false;
QQC2.MenuItem {
text: i18nc("@item:inmenu Open icon chooser dialog", "Choose…")
icon.name: "document-open-folder"
onClicked: iconDialog.open()
}
QQC2.MenuItem {
text: i18nc("@item:inmenu Reset icon to default", "Clear Icon")
icon.name: "edit-clear"
onClicked: {
configGeneral.cfg_icon = "start-here-kde-symbolic"
configGeneral.cfg_customButtonImage = ""
configGeneral.cfg_useCustomButtonImage = false
}
}
}
}
Item {
Kirigami.FormData.isSection: true
}
QQC2.CheckBox {
id: showIcons
Kirigami.FormData.label: i18n("General:")
text: i18n("Show icons in the menu")
}
RowLayout {
spacing: 0
Rectangle { Layout.bottomMargin: 20 }
}
RowLayout {
Kirigami.FormData.label: i18n("Menu Width:")
QQC2.SpinBox {
id: menuWidthSpinBox
from: 0
// INT_MAX (2^31-1): max value for config Int type; effectively no upper limit for width
to: 2147483647
}
}
RowLayout {
Kirigami.FormData.label: i18n("Button Height:")
QQC2.SpinBox {
id: buttonHeightSpinBox
from: 0
// INT_MAX (2^31-1): max value for config Int type; effectively no upper limit
to: 2147483647
}
}
}
}
@@ -0,0 +1,43 @@
/*
SPDX-FileCopyrightText: 2024
SPDX-License-Identifier: GPL-2.0-or-later
*/
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls as QQC2
import org.kde.kcmutils as KCM
import org.kde.kirigami as Kirigami
KCM.SimpleKCM {
id: configMenuJson
property string cfg_menuJson
MenuBuilder {
id: menuBuilder
anchors.fill: parent
Connections {
target: menuBuilder
function onJsonOutputChanged() {
// Update configuration property when JSON changes
// This ensures the Apply button becomes enabled
cfg_menuJson = menuBuilder.jsonOutput;
}
}
Component.onCompleted: {
// Load JSON when MenuBuilder is ready
Qt.callLater(() => {
const jsonData = plasmoid.configuration.menuJson;
menuBuilder.loadFromJson(jsonData || "[]");
// Set initial value to match loaded data
cfg_menuJson = menuBuilder.jsonOutput;
});
}
}
}
@@ -0,0 +1,954 @@
/*
SPDX-FileCopyrightText: 2024
SPDX-License-Identifier: GPL-2.0-or-later
*/
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls as QQC2
import org.kde.kirigami as Kirigami
import org.kde.plasma.components as PlasmaComponents3
import org.kde.iconthemes as KIconThemes
/**
* MenuBuilder - Visual menu editor with hierarchical tree view and property editor
*
* This component provides a KDE Menu Editor-like interface for editing the JSON menu structure.
* It features:
* - Left pane: Hierarchical tree view with expandable/collapsible items
* - Right pane: Property editor for the selected menu item
* - Toolbar: Add, Delete, Move Up/Down buttons
* - Real-time JSON generation from the edited structure
*/
Item {
id: root
property var menuData: []
property string jsonOutput: ""
function loadFromJson(jsonString) {
try {
if (!jsonString || jsonString.trim() === "" || jsonString === "[]") {
// Use default menu from main.xml
jsonString = '[{"name":"About This Computer","icon":"help-hint","command":"kinfocenter"},{"separator":true},{"name":"System Preferences...","icon":"settings-configure","command":"systemsettings"},{"name":"App Store...","icon":"update-none","command":"plasma-discover"},{"separator":true},{"name":"Force Quit...","icon":"error","command":"xkill"},{"separator":true},{"name":"Sleep","icon":"system-suspend","command":"systemctl suspend"},{"name":"Restart...","icon":"system-reboot","command":"qdbus6 org.kde.LogoutPrompt /LogoutPrompt promptReboot"},{"name":"Shut Down...","icon":"system-shutdown","command":"qdbus6 org.kde.LogoutPrompt /LogoutPrompt promptShutDown"},{"separator":true},{"name":"Lock Screen","icon":"system-lock-screen","command":"qdbus6 org.freedesktop.ScreenSaver /ScreenSaver Lock"},{"name":"Log Out","icon":"system-log-out","command":"qdbus6 org.kde.LogoutPrompt /LogoutPrompt promptLogout"}]';
}
const parsed = JSON.parse(jsonString);
menuData = Array.isArray(parsed) ? parsed : [];
expandedPaths = [];
rebuildTreeModel();
jsonOutput = JSON.stringify(menuData, null, 2);
} catch (e) {
console.error("MenuBuilder: Failed to parse JSON:", e);
menuData = [];
expandedPaths = [];
treeModel.clear();
}
}
function buildTreeModel(items, parentItem, level) {
if (level === undefined) level = 0;
for (let i = 0; i < items.length; i++) {
const item = items[i];
// An item has children if it has a submenu property (even if empty)
// This allows empty submenus to show the expand/collapse chevron
const hasChildren = !!(item.submenu && Array.isArray(item.submenu));
const modelItem = {
itemData: item,
itemIndex: i,
parentItem: parentItem,
isExpanded: false,
level: level,
hasChildren: hasChildren,
parentArray: parentItem ? parentItem.itemData.submenu : menuData
};
treeModel.append(modelItem);
// Recursively add children if parent is expanded (but initially all collapsed)
// We'll rebuild when expanding
}
}
function rebuildTreeModel() {
treeModel.clear();
if (!menuData || menuData.length === 0) {
return;
}
buildTreeModelRecursive(menuData, null, 0, "");
}
function buildTreeModelRecursive(items, parentItem, level, parentPath) {
if (!items || items.length === 0) return;
if (parentPath === undefined) parentPath = "";
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (!item) continue;
// An item has children if it has a submenu property (even if empty)
// This allows empty submenus to show the expand/collapse chevron
const hasChildren = !!(item.submenu && Array.isArray(item.submenu));
const isLastChild = (i === items.length - 1);
const currentPath = parentPath === "" ? String(i) : parentPath + "/" + i;
const isExpanded = root.isItemExpanded(item, currentPath);
treeModel.append({
itemData: item,
itemIndex: i,
parentItem: parentItem,
isExpanded: isExpanded,
level: level,
hasChildren: hasChildren,
isLastChild: isLastChild,
parentArray: parentItem ? parentItem.itemData.submenu : menuData,
path: currentPath
});
// Add children if this item is expanded and has children
if (hasChildren && isExpanded && item.submenu.length > 0) {
const currentModelItem = treeModel.get(treeModel.count - 1);
buildTreeModelRecursive(item.submenu, currentModelItem, level + 1, currentPath);
}
}
}
property var expandedPaths: [] // Store paths instead of object references
property var collapsedPaths: [] // Store paths of explicitly collapsed items
property string selectedPath: ""
function isItemExpanded(itemData, path) {
// Use path to check if expanded, since object references change after rebuild
if (path === undefined || path === "") return false;
// If explicitly collapsed, return false
if (collapsedPaths.indexOf(path) >= 0) return false;
// By default, expand all items that have children
if (itemData && itemData.submenu && Array.isArray(itemData.submenu) && itemData.submenu.length > 0) {
return true;
}
// Otherwise check if explicitly in expandedPaths (for backwards compatibility)
return expandedPaths.indexOf(path) >= 0;
}
function setItemExpanded(itemData, expanded, path) {
if (path === undefined || path === "") return;
if (expanded) {
// Remove from collapsedPaths if present
const collapsedIndex = collapsedPaths.indexOf(path);
if (collapsedIndex >= 0) {
collapsedPaths.splice(collapsedIndex, 1);
// Force property change notification
collapsedPaths = collapsedPaths.slice();
}
// Add to expandedPaths if not present (for backwards compatibility)
const expandedIndex = expandedPaths.indexOf(path);
if (expandedIndex < 0) {
expandedPaths.push(path);
// Force property change notification
expandedPaths = expandedPaths.slice();
}
} else {
// Add to collapsedPaths if not present
const collapsedIndex = collapsedPaths.indexOf(path);
if (collapsedIndex < 0) {
collapsedPaths.push(path);
// Force property change notification
collapsedPaths = collapsedPaths.slice();
}
// Remove from expandedPaths if present (for backwards compatibility)
const expandedIndex = expandedPaths.indexOf(path);
if (expandedIndex >= 0) {
expandedPaths.splice(expandedIndex, 1);
// Force property change notification
expandedPaths = expandedPaths.slice();
}
}
}
function updateJson() {
jsonOutput = JSON.stringify(menuData, null, 2);
}
function findItemByPath(path) {
if (path === "" || path === "/") return null;
const parts = path.split("/").filter(p => p !== "");
let current = menuData;
for (let i = 0; i < parts.length; i++) {
const index = parseInt(parts[i]);
if (current && current[index]) {
if (i === parts.length - 1) {
return current[index];
} else {
current = current[index].submenu || [];
}
} else {
return null;
}
}
return null;
}
function updateItemInMenuData(path, property, value) {
if (path === "" || path === "/") return;
const parts = path.split("/").filter(p => p !== "");
let current = menuData;
for (let i = 0; i < parts.length; i++) {
const index = parseInt(parts[i]);
if (current && current[index]) {
if (i === parts.length - 1) {
// Found the item, update it
current[index][property] = value;
return;
} else {
current = current[index].submenu || [];
}
} else {
return;
}
}
}
function findItemInData(itemData, parentArray) {
if (!parentArray) parentArray = menuData;
for (let i = 0; i < parentArray.length; i++) {
if (parentArray[i] === itemData) {
return { array: parentArray, index: i };
}
if (parentArray[i].submenu) {
const found = findItemInData(itemData, parentArray[i].submenu);
if (found) return found;
}
}
return null;
}
function getPathForItem(itemData, parentArray, parentPath) {
if (!parentArray) parentArray = menuData;
if (parentPath === undefined) parentPath = "";
for (let i = 0; i < parentArray.length; i++) {
const currentPath = parentPath === "" ? String(i) : parentPath + "/" + i;
if (parentArray[i] === itemData) {
return currentPath;
}
if (parentArray[i].submenu) {
const found = getPathForItem(itemData, parentArray[i].submenu, currentPath);
if (found) return found;
}
}
return null;
}
function addItem(parentItemData, afterIndex) {
const newItem = {
name: "New Item",
icon: "",
command: ""
};
if (parentItemData && parentItemData.submenu) {
// Add to parent's submenu
const insertIndex = afterIndex !== undefined && afterIndex >= 0 ? afterIndex + 1 : parentItemData.submenu.length;
parentItemData.submenu.splice(insertIndex, 0, newItem);
} else if (parentItemData) {
// Parent exists but no submenu - create it
parentItemData.submenu = [newItem];
} else {
// Add to root level
const insertIndex = afterIndex !== undefined && afterIndex >= 0 ? afterIndex + 1 : menuData.length;
menuData.splice(insertIndex, 0, newItem);
}
rebuildTreeModel();
updateJson();
}
function deleteItem(itemData) {
if (!selectedPath || selectedPath === "") return;
const parts = selectedPath.split("/").filter(p => p !== "");
if (parts.length === 1) {
// Root level item
menuData.splice(parseInt(parts[0]), 1);
} else {
// Nested item: navigate to the parent array (same logic as moveItem)
let current = menuData;
for (let i = 0; i < parts.length - 1; i++) {
const index = parseInt(parts[i]);
if (current && current[index] && current[index].submenu) {
current = current[index].submenu;
} else {
return; // Invalid path
}
}
const targetIndex = parseInt(parts[parts.length - 1]);
current.splice(targetIndex, 1);
}
selectedItem = null;
selectedPath = "";
rebuildTreeModel();
updateJson();
}
function moveItem(itemData, direction) {
if (!selectedPath || selectedPath === "") return;
const parts = selectedPath.split("/").filter(p => p !== "");
let current = menuData;
let targetArray = menuData;
let currentIndex = 0;
// Navigate to the parent array containing the item
if (parts.length === 1) {
// Root level item
targetArray = menuData;
currentIndex = parseInt(parts[0]);
} else {
// Nested item - navigate to parent
for (let i = 0; i < parts.length - 1; i++) {
const index = parseInt(parts[i]);
if (current && current[index] && current[index].submenu) {
current = current[index].submenu;
} else {
return; // Invalid path
}
}
targetArray = current;
currentIndex = parseInt(parts[parts.length - 1]);
}
// Calculate new index
const newIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
if (newIndex < 0 || newIndex >= targetArray.length) return;
// Move the item
const item = targetArray.splice(currentIndex, 1)[0];
targetArray.splice(newIndex, 0, item);
// Force update to trigger change detection (same as other functions)
menuData = JSON.parse(JSON.stringify(menuData));
// Rebuild the tree model
rebuildTreeModel();
updateJson();
// Calculate new path after move
const newPathParts = parts.slice();
newPathParts[newPathParts.length - 1] = String(newIndex);
const newPath = newPathParts.join("/");
// Restore selection after rebuild
Qt.callLater(() => {
const restoredItem = findItemByPath(newPath);
if (restoredItem) {
selectedItem = restoredItem;
selectedPath = newPath;
}
});
}
property var selectedItem: null // Direct reference to item in menuData
// Computed property to check if selected item is a parent (has submenu)
// This needs to be reactive to changes in selectedItem and its submenu property
readonly property bool selectedItemIsParent: {
if (!selectedItem) return false;
// Check if submenu property exists and is an array (even if empty)
const hasSubmenu = selectedItem.hasOwnProperty('submenu') &&
selectedItem.submenu !== undefined &&
selectedItem.submenu !== null &&
Array.isArray(selectedItem.submenu);
return hasSubmenu;
}
// Helper function to get array and index from path
function getArrayAndIndexFromPath(path) {
if (!path || path === "") return null;
const parts = path.split("/").filter(p => p !== "");
let current = menuData;
for (let i = 0; i < parts.length - 1; i++) {
const index = parseInt(parts[i]);
if (current && current[index] && current[index].submenu) {
current = current[index].submenu;
} else {
return null;
}
}
const lastIndex = parseInt(parts[parts.length - 1]);
return { array: current, index: lastIndex };
}
// Computed property to check if selected item can be moved up
readonly property bool canMoveUp: {
if (!selectedItem || !selectedPath) return false;
const info = getArrayAndIndexFromPath(selectedPath);
if (!info) return false;
return info.index > 0;
}
// Computed property to check if selected item can be moved down
readonly property bool canMoveDown: {
if (!selectedItem || !selectedPath) return false;
const info = getArrayAndIndexFromPath(selectedPath);
if (!info) return false;
return info.index < info.array.length - 1;
}
ColumnLayout {
anchors.fill: parent
spacing: 0
// Toolbar
RowLayout {
Layout.fillWidth: true
Layout.preferredHeight: Kirigami.Units.gridUnit * 2
Layout.leftMargin: Kirigami.Units.smallSpacing
Layout.rightMargin: Kirigami.Units.smallSpacing
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.mediumSpacing
QQC2.Button {
icon.name: "list-add"
text: i18n("Add Item")
onClicked: {
const newItem = {
name: "New Item",
icon: "",
command: ""
};
// Save current selection path before making changes
const savedPath = selectedPath;
if (selectedItem && selectedPath) {
// Find the item in menuData using the path (not the stale reference)
const currentItem = root.findItemByPath(selectedPath);
if (currentItem) {
// Check if selected item is a parent (has submenu property)
// Even if submenu is empty, it's still a parent
if (currentItem.submenu !== undefined && Array.isArray(currentItem.submenu)) {
// Add as child of the parent
// Ensure submenu exists (create if it doesn't)
if (!currentItem.submenu) {
currentItem.submenu = [];
}
currentItem.submenu.push(newItem);
root.setItemExpanded(currentItem, true, selectedPath);
} else {
// Add as sibling after selected item
const found = root.findItemInData(currentItem);
if (found) {
found.array.splice(found.index + 1, 0, newItem);
} else {
menuData.push(newItem);
}
}
} else {
menuData.push(newItem);
}
} else {
// No item selected, add to root level
menuData.push(newItem);
}
// Force update to trigger change detection
root.menuData = JSON.parse(JSON.stringify(menuData));
// Restore selection after menuData update
if (savedPath) {
const restoredItem = root.findItemByPath(savedPath);
if (restoredItem) {
root.selectedItem = restoredItem;
root.selectedPath = savedPath;
}
}
root.rebuildTreeModel();
root.updateJson();
// Select the new item
Qt.callLater(() => {
const newItemPath = root.getPathForItem(newItem);
if (newItemPath) {
root.selectedItem = newItem;
root.selectedPath = newItemPath;
}
});
}
}
QQC2.Button {
icon.name: "folder-new"
text: i18n("Add Folder")
onClicked: {
const newItem = {
name: "New Folder",
icon: "folder",
submenu: []
};
// Save current selection path before making changes
const savedPath = selectedPath;
if (selectedItem && selectedPath) {
// Find the item in menuData using the path (not the stale reference)
const currentItem = root.findItemByPath(selectedPath);
if (currentItem) {
// Check if selected item is a parent (has submenu property)
// Even if submenu is empty, it's still a parent
if (currentItem.submenu !== undefined && Array.isArray(currentItem.submenu)) {
// Add as child of the parent
currentItem.submenu.push(newItem);
root.setItemExpanded(currentItem, true, selectedPath);
} else {
// Add as sibling after selected item
const found = root.findItemInData(currentItem);
if (found) {
found.array.splice(found.index + 1, 0, newItem);
} else {
menuData.push(newItem);
}
}
} else {
menuData.push(newItem);
}
} else {
// No item selected, add to root level
menuData.push(newItem);
}
// Force update to trigger change detection
root.menuData = JSON.parse(JSON.stringify(menuData));
// Restore selection after menuData update
if (savedPath) {
const restoredItem = root.findItemByPath(savedPath);
if (restoredItem) {
root.selectedItem = restoredItem;
root.selectedPath = savedPath;
}
}
root.rebuildTreeModel();
root.updateJson();
// Select and expand the new submenu item
Qt.callLater(() => {
const newItemPath = root.getPathForItem(newItem);
if (newItemPath) {
root.selectedItem = newItem;
root.selectedPath = newItemPath;
root.setItemExpanded(newItem, true, newItemPath);
root.rebuildTreeModel();
// Restore selection after rebuild
Qt.callLater(() => {
const restoredItem = root.findItemByPath(newItemPath);
if (restoredItem) {
root.selectedItem = restoredItem;
root.selectedPath = newItemPath;
}
});
}
});
}
}
QQC2.Button {
icon.name: "insert-horizontal-rule"
text: i18n("Add Separator")
onClicked: {
const newItem = {
separator: true
};
// Use path (like Add Item) so we insert below selection, not at end
const savedPath = selectedPath;
const info = savedPath ? root.getArrayAndIndexFromPath(savedPath) : null;
if (info) {
info.array.splice(info.index + 1, 0, newItem);
} else {
// No selection: add to root level at end
menuData.push(newItem);
}
root.menuData = JSON.parse(JSON.stringify(menuData));
if (savedPath) {
const restoredItem = root.findItemByPath(savedPath);
if (restoredItem) {
root.selectedItem = restoredItem;
root.selectedPath = savedPath;
}
}
root.rebuildTreeModel();
root.updateJson();
}
}
QQC2.Button {
icon.name: "go-up"
// text: i18n("Move Up")
enabled: root.canMoveUp
onClicked: {
if (selectedItem) {
root.moveItem(selectedItem, "up");
}
}
}
QQC2.Button {
icon.name: "go-down"
// text: i18n("Move Down")
enabled: root.canMoveDown
onClicked: {
if (selectedItem) {
root.moveItem(selectedItem, "down");
}
}
}
QQC2.Button {
icon.name: "edit-delete"
// text: i18n("Delete")
enabled: selectedItem !== null
onClicked: {
if (selectedItem) {
root.deleteItem(selectedItem);
}
}
}
Item { Layout.fillWidth: true }
}
// Main content area
RowLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: Kirigami.Units.smallSpacing
// Left pane: Tree view
QQC2.Frame {
Layout.fillHeight: true
Layout.preferredWidth: Kirigami.Units.gridUnit * 15
Layout.minimumWidth: Kirigami.Units.gridUnit * 12
ColumnLayout {
anchors.fill: parent
anchors.margins: Kirigami.Units.smallSpacing
spacing: 0
// Tree view using ListView (TreeView requires C++ models)
QQC2.ScrollView {
id: treeScrollView
Layout.fillWidth: true
Layout.fillHeight: true
ListView {
id: treeView
width: treeScrollView.availableWidth
implicitHeight: contentHeight
model: treeModel
clip: true
// Draw tree lines in the background
// This is a workaround since TreeView requires C++ models
Rectangle {
anchors.fill: parent
color: "transparent"
z: 0
// We'll draw lines in each delegate instead
// This rectangle is just a placeholder
}
delegate: TreeItemDelegate {
id: delegate
width: treeView.width
// Access ListModel properties directly - they're available in delegate scope
property var modelRow: treeModel.get(index)
// Pass them to TreeItemDelegate properties using the model row
itemData: modelRow ? modelRow.itemData : null
level: modelRow ? modelRow.level : 0
hasChildren: modelRow ? modelRow.hasChildren : false
isExpanded: modelRow ? modelRow.isExpanded : false
isLastChild: modelRow ? modelRow.isLastChild : false
isSelected: root.selectedPath && modelRow && root.selectedPath === modelRow.path
// Check if this is the first top-level item (path === "0")
isFirstTopLevel: modelRow && modelRow.level === 0 && modelRow.path === "0"
onSelected: {
// Store direct reference to item in menuData and path
if (modelRow && modelRow.itemData) {
root.selectedItem = modelRow.itemData;
root.selectedPath = modelRow.path || "";
}
}
onToggleExpanded: {
if (modelRow && modelRow.itemData && modelRow.path) {
// Use the model row's isExpanded property directly
const currentExpanded = modelRow.isExpanded;
root.setItemExpanded(modelRow.itemData, !currentExpanded, modelRow.path);
root.rebuildTreeModel();
// Restore selection after rebuild
if (root.selectedPath) {
Qt.callLater(() => {
const restoredItem = root.findItemByPath(root.selectedPath);
if (restoredItem) {
root.selectedItem = restoredItem;
}
});
}
}
}
}
}
}
}
}
// Right pane: Editor
QQC2.Frame {
Layout.fillHeight: true
Layout.fillWidth: true
QQC2.ScrollView {
id: scrollView
anchors.fill: parent
anchors.margins: Kirigami.Units.smallSpacing
anchors.topMargin: Kirigami.Units.mediumSpacing
Kirigami.FormLayout {
id: formLayout
width: scrollView.contentItem ? scrollView.contentItem.width : parent.width
enabled: root.selectedItem !== null
QQC2.Label {
text: i18n("No item selected")
visible: root.selectedItem === null
color: Kirigami.Theme.disabledTextColor
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
}
QQC2.Label {
text: i18n("Separator selected")
visible: root.selectedItem !== null && root.selectedItem.hasOwnProperty("separator") && root.selectedItem.separator === true
color: Kirigami.Theme.disabledTextColor
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
}
RowLayout {
Kirigami.FormData.label: i18n("Name:")
Layout.fillWidth: true
visible: root.selectedItem !== null && !root.selectedItem.separator
enabled: root.selectedItem !== null && !root.selectedItem.separator
QQC2.TextField {
id: nameField
Layout.fillWidth: true
Component.onCompleted: updateText()
function updateText() {
if (root.selectedItem && !root.selectedItem.separator) {
text = root.selectedItem.name || "";
} else {
text = "";
}
}
onTextChanged: {
if (root.selectedItem && root.selectedPath && text !== (root.selectedItem.name || "")) {
root.selectedItem.name = text;
root.updateItemInMenuData(root.selectedPath, "name", text);
root.updateJson();
// Refresh tree to show updated name
const savedPath = root.selectedPath;
Qt.callLater(() => {
root.rebuildTreeModel();
// Restore selection after rebuild
if (savedPath) {
const restoredItem = root.findItemByPath(savedPath);
if (restoredItem) {
root.selectedItem = restoredItem;
root.selectedPath = savedPath;
nameField.updateText();
}
}
});
}
}
Connections {
target: root
function onSelectedItemChanged() {
nameField.updateText();
}
}
}
QQC2.Button {
id: iconButton
Layout.alignment: Qt.AlignVCenter
implicitWidth: Kirigami.Units.iconSizes.medium + Kirigami.Units.smallSpacing * 2
height: nameField.height
checkable: true
onPressed: iconMenu.opened ? iconMenu.close() : iconMenu.open()
icon.name: {
if (root.selectedItem && !root.selectedItem.separator) {
return root.selectedItem.icon || "";
}
return "";
}
KIconThemes.IconDialog {
id: iconDialog
onIconNameChanged: (iconName) => {
if (iconName != "" && root.selectedItem && root.selectedPath) {
root.selectedItem.icon = iconName;
root.updateItemInMenuData(root.selectedPath, "icon", iconName);
root.updateJson();
// Refresh tree to show updated icon
const savedPath = root.selectedPath;
Qt.callLater(() => {
root.rebuildTreeModel();
// Restore selection after rebuild
if (savedPath) {
const restoredItem = root.findItemByPath(savedPath);
if (restoredItem) {
root.selectedItem = restoredItem;
root.selectedPath = savedPath;
}
}
});
}
}
}
QQC2.Menu {
id: iconMenu
y: parent.height
onClosed: iconButton.checked = false;
QQC2.MenuItem {
text: i18nc("@item:inmenu Open icon chooser dialog", "Choose…")
icon.name: "document-open-folder"
onClicked: iconDialog.open()
}
QQC2.MenuItem {
text: i18nc("@item:inmenu Reset icon to default", "Clear Icon")
icon.name: "edit-clear"
onClicked: {
if (root.selectedItem && root.selectedPath) {
root.selectedItem.icon = "";
root.updateItemInMenuData(root.selectedPath, "icon", "");
root.updateJson();
// Refresh tree to show updated icon
const savedPath = root.selectedPath;
Qt.callLater(() => {
root.rebuildTreeModel();
// Restore selection after rebuild
if (savedPath) {
const restoredItem = root.findItemByPath(savedPath);
if (restoredItem) {
root.selectedItem = restoredItem;
root.selectedPath = savedPath;
}
}
});
}
}
}
}
}
}
Item {
id: commandFieldContainer
Kirigami.FormData.label: i18n("Command:")
Layout.fillWidth: true
// Get fresh reference to selected item to ensure submenu property is available
property var currentItem: root.selectedPath ? root.findItemByPath(root.selectedPath) : null
property bool isParent: {
if (!currentItem) return false;
const hasSubmenu = currentItem.hasOwnProperty('submenu') &&
currentItem.submenu !== undefined &&
currentItem.submenu !== null &&
Array.isArray(currentItem.submenu);
return hasSubmenu;
}
visible: {
if (!root.selectedItem || !root.selectedPath) return false;
if (currentItem && currentItem.separator === true) return false;
return !isParent;
}
enabled: visible
implicitWidth: Kirigami.Units.gridUnit * 25
implicitHeight: Kirigami.Units.gridUnit * 6
height: implicitHeight
QQC2.ScrollView {
anchors.fill: parent
clip: true
QQC2.TextArea {
id: commandField
width: commandFieldContainer.width
wrapMode: TextArea.Wrap
selectByMouse: true
onTextChanged: {
if (commandFieldContainer.currentItem && root.selectedPath && text !== (commandFieldContainer.currentItem.command || "")) {
// Only update if not a parent item
if (!commandFieldContainer.isParent) {
commandFieldContainer.currentItem.command = text;
root.updateItemInMenuData(root.selectedPath, "command", text);
root.updateJson();
}
}
}
}
}
Component.onCompleted: updateText()
function updateText() {
if (currentItem && !currentItem.separator && !isParent) {
commandField.text = currentItem.command || "";
} else {
commandField.text = "";
}
}
Connections {
target: root
function onSelectedItemChanged() {
commandFieldContainer.updateText();
}
function onSelectedPathChanged() {
commandFieldContainer.updateText();
}
}
}
}
}
}
}
}
ListModel {
id: treeModel
}
}
@@ -0,0 +1,273 @@
/*
SPDX-FileCopyrightText: 2024
SPDX-License-Identifier: GPL-2.0-or-later
*/
import QtQuick
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.plasma.components as PlasmaComponents3
/**
* TreeItemDelegate - Delegate for individual items in the menu tree view
*
* Displays a menu item in the hierarchical tree with:
* - Expand/collapse indicator for items with children
* - Visual tree lines (vertical and horizontal connectors)
* - Indentation based on nesting level
* - Selection highlighting
* - Icon and name display (or separator indicator)
*/
Rectangle {
id: delegate
// Accept either itemModel (for backwards compatibility) or direct properties
property var itemModel: null
// Direct properties - these override itemModel if set
property var itemData: null
property int level: 0
property bool hasChildren: false
property bool isExpanded: false
property bool isSelected: false
property bool isLastChild: false // Whether this is the last child at its level
property bool isFirstTopLevel: false // Whether this is the first top-level item
property var parentPaths: [] // Array of parent paths to determine tree line drawing
// Resolve itemData - use direct property if set (check for undefined too), otherwise use itemModel
readonly property var resolvedItemData: (itemData !== null && itemData !== undefined) ? itemData : (itemModel ? itemModel.itemData : null)
readonly property int resolvedLevel: (itemData !== null && itemData !== undefined) ? level : (itemModel ? itemModel.level : 0)
readonly property bool resolvedHasChildren: (itemData !== null && itemData !== undefined) ? hasChildren : (itemModel ? itemModel.hasChildren : false)
readonly property bool resolvedIsExpanded: (itemData !== null && itemData !== undefined) ? isExpanded : (itemModel ? itemModel.isExpanded : false)
readonly property bool resolvedIsLastChild: (itemData !== null && itemData !== undefined) ? isLastChild : (itemModel ? itemModel.isLastChild : false)
readonly property bool isSeparator: resolvedItemData ? resolvedItemData.separator : false
signal selected()
signal toggleExpanded()
height: isSeparator ? (Kirigami.Units.gridUnit * 0.75) : (Kirigami.Units.gridUnit * 2)
// Darker selection background so text (highlightedTextColor) is easier to read
color: isSelected ? Qt.darker(Kirigami.Theme.highlightColor, 2) : (mouseArea.containsMouse ? Kirigami.Theme.alternateBackgroundColor : "transparent")
// Draw tree lines - make them clearly visible
// Vertical lines for each parent level (these connect siblings at that level)
// Children draw lines for levels 0 to resolvedLevel-1 (all their parent levels)
Repeater {
model: resolvedLevel
delegate: Rectangle {
// Position at the center of the expand button for this level
x: {
// All vertical lines should be evenly spaced gridUnit apart from base position
// Base position: smallSpacing + iconSize/2
// modelData represents the parent level
// modelData === 0: parent is level 0, so draw at level 0 chevron center (base + gridUnit)
// modelData === 1: parent is level 1, so draw at level 1 chevron center (base + 2*gridUnit)
// modelData === 2: parent is level 2, so draw at level 2 chevron center (base + 3*gridUnit)
// etc.
var baseX = Kirigami.Units.smallSpacing + (Kirigami.Units.iconSizes.small / 2);
// Parent level is modelData, so parent's chevron center is at base + (modelData + 1) * gridUnit
return baseX + ((modelData + 1) * Kirigami.Units.gridUnit) - 0.5;
}
y: 0
width: 1
// If this is the last child at its immediate parent level, only draw to middle; otherwise full height
// However, if this item has children, the line for the immediate parent level should continue through full height
height: {
if (resolvedIsLastChild && modelData === resolvedLevel - 1) {
// Last child at immediate parent level: draw to middle, unless this item has children (then full height)
return resolvedHasChildren ? parent.height : (parent.height / 2);
} else {
// Not last child: full height
return parent.height;
}
}
// Use a visible gray color
color: "#888888"
z: 0
}
}
// Vertical line for top-level items (level 0) - connects siblings at root level
// Also drawn for all descendants of top-level items (level > 0) to continue the line
Rectangle {
// Draw for level 0 items and all their descendants (level > 0)
visible: true // Draw for all items to continue the top-level line
// Top-level vertical line at base position (left edge)
x: Kirigami.Units.smallSpacing + (Kirigami.Units.iconSizes.small / 2) - 0.5
y: isFirstTopLevel ? ((parent.height / 2) - 1) : 0 // Start from middle minus 1px for first item, top for others
width: 1
// For level 0: draw full height for all top-level items except last (if no children)
// For first top-level item: start from middle minus 1px, so height is half plus 1px
// For level > 0: always draw full height to continue the line through all descendants
height: {
if (resolvedLevel === 0) {
if (isFirstTopLevel) {
// First top-level item: start from middle minus 1px, so draw half height plus 1px
return (parent.height / 2) + 1;
} else {
// Level 0: only stop at middle for the very last top-level item (if it has no children)
return (resolvedIsLastChild && !resolvedHasChildren) ? (parent.height / 2) : parent.height;
}
} else {
// Level > 0: always full height to continue the top-level line through all nested descendants
return parent.height;
}
}
color: "#888888"
z: 0
}
// Horizontal line for top-level items (level 0) - connects from vertical line to item
Rectangle {
visible: resolvedLevel === 0 && !isSeparator
// Start from the top-level vertical line position
x: Kirigami.Units.smallSpacing + (Kirigami.Units.iconSizes.small / 2)
y: parent.height / 2 - 0.5
// Extend from vertical line to just before chevron
// For items with icons: shorter line to avoid touching
// For submenus with arrows: longer line (original width)
width: resolvedHasChildren
? (Kirigami.Units.gridUnit - (Kirigami.Units.iconSizes.small / 2)) // Submenu with arrow: original width
: (Kirigami.Units.gridUnit - (Kirigami.Units.iconSizes.small / 2) - Kirigami.Units.smallSpacing) // Item with icon: shorter
height: 1
color: "#888888"
z: 0
}
// Vertical line for this item's own level (if it has children and is expanded)
// This creates the consistent line that extends from this item through all its children
// For level 0 items with children, this draws the vertical line at the base position (continuing from main vertical line)
// For level 1+ items, this draws the vertical line at their indented position
// This line starts from the chevron (middle) and extends down, not above the chevron
Rectangle {
visible: resolvedHasChildren && resolvedIsExpanded
x: {
// Align with chevron center
// Content leftMargin: smallSpacing + (level + 1) * gridUnit
// Chevron center: leftMargin + iconSize/2
// = smallSpacing + (level + 1) * gridUnit + iconSize/2
var leftMargin = Kirigami.Units.smallSpacing + ((resolvedLevel + 1) * Kirigami.Units.gridUnit);
return leftMargin + (Kirigami.Units.iconSizes.small / 2) - 0.5;
}
y: parent.height / 2 // Start from middle (where chevron is), not from top
width: 1
height: parent.height / 2 // Extend to bottom of item
// This line will be continued by children drawing their portion for this level
color: "#888888"
z: 0
}
// Horizontal line connecting this item to its parent's vertical line (omit for separators)
Rectangle {
visible: resolvedLevel > 0 && !isSeparator
// Start from parent's vertical line (at base position for level 0 parents, indented for level 1+ parents)
x: {
// Connect from parent's vertical line
// Parent's vertical line is at: base + (parentLevel + 1) * gridUnit
// Parent level is resolvedLevel - 1
var baseX = Kirigami.Units.smallSpacing + (Kirigami.Units.iconSizes.small / 2);
var parentLevel = resolvedLevel - 1;
return baseX + ((parentLevel + 1) * Kirigami.Units.gridUnit);
}
y: parent.height / 2 - 0.5
// Extend to this item's indented content position (same width whether has children or not)
// The line should stop well before the icon/chevron area to avoid hitting icons
width: {
// Extend from parent's vertical line to just before chevron
// Parent's vertical line: base + (parentLevel + 1) * gridUnit
// This item's leftMargin: smallSpacing + (level + 1) * gridUnit
// Chevron center: leftMargin + iconSize/2 = smallSpacing + (level + 1) * gridUnit + iconSize/2
// Parent's vertical line: base + (parentLevel + 1) * gridUnit = smallSpacing + iconSize/2 + (parentLevel + 1) * gridUnit
// Distance: (level + 1) * gridUnit + iconSize/2 - (parentLevel + 1) * gridUnit - iconSize/2
// = (level - parentLevel) * gridUnit = gridUnit (since level = parentLevel + 1)
// For items with icons: shorter line to avoid touching
// For submenus with arrows: longer line (original width)
return resolvedHasChildren
? (Kirigami.Units.gridUnit - (Kirigami.Units.iconSizes.small / 2)) // Submenu with arrow: original width
: (Kirigami.Units.gridUnit - (Kirigami.Units.iconSizes.small / 2) - Kirigami.Units.smallSpacing); // Item with icon: shorter
}
height: 1
color: "#888888"
z: 0
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
delegate.selected();
}
}
RowLayout {
anchors.fill: parent
// Indent all items to make space for the top-level vertical line
anchors.leftMargin: {
// Align chevrons with vertical lines
// Vertical lines are at: base + (level + 1) * gridUnit
// Chevron center should be at vertical line position
// So leftMargin = vertical line position - iconSize/2
// = smallSpacing + iconSize/2 + (level + 1) * gridUnit - iconSize/2
// = smallSpacing + (level + 1) * gridUnit
return Kirigami.Units.smallSpacing + ((resolvedLevel + 1) * Kirigami.Units.gridUnit);
}
anchors.rightMargin: Kirigami.Units.smallSpacing
spacing: Kirigami.Units.smallSpacing
z: 2 // Ensure content is above tree lines
// Expand/collapse button with background to prevent lines from touching
Rectangle {
visible: resolvedHasChildren
Layout.preferredWidth: Kirigami.Units.iconSizes.small
Layout.preferredHeight: Kirigami.Units.iconSizes.small
// Always use theme background to create visible spacing from tree lines
color: Kirigami.Theme.backgroundColor
radius: 2
z: 1 // Ensure it's above the tree lines
PlasmaComponents3.ToolButton {
anchors.fill: parent
icon.name: resolvedIsExpanded ? "arrow-down" : "arrow-right"
onClicked: {
delegate.toggleExpanded();
}
}
}
// Icon (positioned where chevron would be for alignment, hidden for parent items with children)
Kirigami.Icon {
source: isSeparator ? "" : (resolvedItemData ? (resolvedItemData.icon || "application-x-executable") : "application-x-executable")
Layout.preferredWidth: isSeparator ? 0 : Kirigami.Units.iconSizes.small
Layout.preferredHeight: isSeparator ? 0 : Kirigami.Units.iconSizes.small
visible: !isSeparator && !resolvedHasChildren
}
// Name label (for regular items)
PlasmaComponents3.Label {
visible: !isSeparator
Layout.fillWidth: true
text: resolvedItemData ? (resolvedItemData.name || i18n("Unnamed")) : i18n("Unnamed")
elide: Text.ElideRight
color: isSelected ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor
}
}
// Separator line (for separator items) - start at vertical tree line so there is no gap
Rectangle {
visible: isSeparator
// Align left edge with the vertical line that runs down the left of this item's group.
// Level 0: top-level vertical line at baseX - 0.5.
// Level L>0: that line is the parent's vertical (Repeater modelData 0..L-1); the left edge
// for our group is at baseX + L*gridUnit - 0.5 (same as Repeater with modelData = L-1).
x: Kirigami.Units.smallSpacing + (Kirigami.Units.iconSizes.small / 2) + (resolvedLevel * Kirigami.Units.gridUnit) - 0.5
width: parent.width - x - Kirigami.Units.smallSpacing
anchors.verticalCenter: parent.verticalCenter
height: 1
color: Kirigami.Theme.separatorColor || "#888888"
z: 2 // Above tree lines
}
}
@@ -0,0 +1,108 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.plasma.components as PlasmaComponents3
import org.kde.plasma.core as PlasmaCore
import org.kde.plasma.plasmoid
import org.kde.plasma.plasma5support as Plasma5Support
import org.kde.plasma.private.kicker as Kicker
PlasmoidItem {
id: kicker
anchors.fill: parent
signal reset
signal modelRefreshed
switchWidth: !fullRepresentationItem ? 0 : fullRepresentationItem.Layout.minimumWidth
switchHeight: !fullRepresentationItem ? 0 : fullRepresentationItem.Layout.minimumHeight
fullRepresentation: menuRepresentationComponent
readonly property Component itemListDialogComponent: Component {
ItemListDialog {}
}
property Item dragSource: null
Plasmoid.icon: Plasmoid.configuration.useCustomButtonImage ? Plasmoid.configuration.customButtonImage : Plasmoid.configuration.icon
Component {
id: menuRepresentationComponent
MenuRepresentation {
rootModel: rootModel
itemListDialogComponent: kicker.itemListDialogComponent
onInteractionConcluded: kicker.expanded = false
}
}
JsonMenuModel {
id: rootModel
jsonData: Plasmoid.configuration.menuJson || ""
sorted: false
executable: executable
onRefreshed: {
kicker.modelRefreshed();
}
}
Connections {
target: Plasmoid.configuration
function onMenuJsonChanged() {
rootModel.jsonData = Plasmoid.configuration.menuJson;
}
}
Kicker.DragHelper {
id: dragHelper
dragIconSize: Kirigami.Units.iconSizes.medium
}
Plasma5Support.DataSource {
id: executable
engine: "executable"
connectedSources: []
onNewData: (sourceName) => disconnectSource(sourceName)
function exec(cmd) {
executable.connectSource(cmd)
}
}
Kicker.WindowSystem { // only for X11; TODO Plasma 6.8: remove (also from plasma-workspace)
id: windowSystem
}
Connections {
target: kicker
function onExpandedChanged(expanded) {
if (!expanded) {
kicker.reset();
}
}
}
function resetDragSource() {
dragSource = null;
}
Component.onCompleted: {
if (Plasmoid.hasOwnProperty("activationTogglesExpanded")) {
Plasmoid.activationTogglesExpanded = true
}
rootModel.refreshed.connect(modelRefreshed);
dragHelper.dropped.connect(resetDragSource);
}
}
@@ -0,0 +1,22 @@
{
"KPackageStructure": "Plasma/Applet",
"KPlugin": {
"Authors": [
{
"Name": "51n7"
}
],
"Category": "Application Launchers",
"Description": "A plasmoid (desktop widget) that provides a system menu with essential options, not unlike the apple menu found in MacOS.",
"Icon": "start-here-kde",
"Id": "org.51n7.kMenu",
"License": "GPL-2.0+",
"Name": "kMenu",
"ServiceTypes": [
"Plasma/Applet"
],
"Version": "1.0",
"Website": "https://github.com/51n7/kMenu"
},
"X-Plasma-API-Minimum-Version": "6.0"
}
@@ -0,0 +1,130 @@
# Header entry was created by Lokalize.
#
#: ../contents/config/config.qml:8
# SPDX-FileCopyrightText: 2024 Max le Fou <buttbadger@gmail.com>
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Last-Translator: Max le Fou <buttbadger@gmail.com>\n"
"PO-Revision-Date: 2024-05-15 12:13+0200\n"
"Project-Id-Version: \n"
"Language-Team: French <kde-francophone@kde.org>\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Generator: Lokalize 24.02.2\n"
#: ../contents/config/config.qml:8
msgid "General"
msgstr "Général"
#: ../contents/config/config.qml:14
msgid "Overrides"
msgstr "Remplacements"
#: ../contents/ui/config/ConfigGeneral.qml:49
msgid "Toolbar Icon:"
msgstr "Icône:"
#: ../contents/ui/config/ConfigGeneral.qml:87
msgid "Choose..."
msgstr "Choisir..."
#: ../contents/ui/config/ConfigGeneral.qml:92
msgid "Clear Icon..."
msgstr "Supprimer l'icône"
#: ../contents/ui/config/ConfigGeneral.qml:130
msgid "Show List Icons"
msgstr "Voir la liste des icônes"
#: ../contents/ui/config/ConfigGeneral.qml:136
msgid "Show Menu Items:"
msgstr "Voir les éléments du menu"
#: ../contents/ui/config/ConfigGeneral.qml:140
#: ../contents/ui/main.qml:74
msgid "About This Computer"
msgstr "À propos de l'ordinateur"
#: ../contents/ui/config/ConfigOverrides.qml:26
msgid "About This Computer:"
msgstr "À propos de l'ordinateur :"
#: ../contents/ui/config/ConfigGeneral.qml:144
#: ../contents/ui/main.qml:94
msgid "System Preferences"
msgstr "Préférences"
#: ../contents/ui/config/ConfigOverrides.qml:34
msgid "System Preferences:"
msgstr "Préférences :"
#: ../contents/ui/config/ConfigGeneral.qml:148
#: ../contents/ui/main.qml:102
msgid "App Store"
msgstr "App Store"
#: ../contents/ui/config/ConfigOverrides.qml:42
msgid "App Store:"
msgstr "App Store :"
#: ../contents/ui/config/ConfigGeneral.qml:152
#: ../contents/ui/main.qml:120
msgid "Force Quit"
msgstr "Forcer à quitter"
#: ../contents/ui/config/ConfigOverrides.qml:50
msgid "Force Quit:"
msgstr "Forcer à quitter :"
#: ../contents/ui/config/ConfigGeneral.qml:156
#: ../contents/ui/main.qml:138
msgid "Sleep"
msgstr "Mise en veille"
#: ../contents/ui/config/ConfigOverrides.qml:58
msgid "Sleep:"
msgstr "Mise en veille :"
#: ../contents/ui/config/ConfigGeneral.qml:160
#: ../contents/ui/main.qml:146
msgid "Restart"
msgstr "Redémarrer"
#: ../contents/ui/config/ConfigOverrides.qml:66
msgid "Restart:"
msgstr "Redémarrer :"
#: ../contents/ui/config/ConfigGeneral.qml:164
#: ../contents/ui/main.qml:154
msgid "Shut Down"
msgstr "Éteindre"
#: ../contents/ui/config/ConfigOverrides.qml:74
msgid "Shut Down:"
msgstr "Éteindre :"
#: ../contents/ui/config/ConfigGeneral.qml:168
#: ../contents/ui/main.qml:172
msgid "Lock Screen"
msgstr "Verrouillage écran"
#: ../contents/ui/config/ConfigOverrides.qml:82
msgid "Lock Screen:"
msgstr "Verrouillage écran :"
#: ../contents/ui/config/ConfigGeneral.qml:172
#: ../contents/ui/main.qml:180
msgid "Log Out"
msgstr "Déconnexion"
#: ../contents/ui/config/ConfigOverrides.qml:90
msgid "Log Out:"
msgstr "Déconnexion :"
#: ../contents/ui/config/ConfigGeneral.qml:180
msgid "Size:"
msgstr "Taille :"
@@ -0,0 +1,112 @@
#: ../contents/config/config.qml:8
msgid "General"
msgstr ""
#: ../contents/config/config.qml:14
msgid "Overrides"
msgstr ""
#: ../contents/ui/config/ConfigGeneral.qml:49
msgid "Toolbar Icon:"
msgstr ""
#: ../contents/ui/config/ConfigGeneral.qml:87
msgid "Choose..."
msgstr ""
#: ../contents/ui/config/ConfigGeneral.qml:92
msgid "Clear Icon..."
msgstr ""
#: ../contents/ui/config/ConfigGeneral.qml:130
msgid "Show List Icons"
msgstr ""
#: ../contents/ui/config/ConfigGeneral.qml:136
msgid "Show Menu Items:"
msgstr ""
#: ../contents/ui/config/ConfigGeneral.qml:140
#: ../contents/ui/main.qml:74
msgid "About This Computer"
msgstr ""
#: ../contents/ui/config/ConfigOverrides.qml:26
msgid "About This Computer:"
msgstr ""
#: ../contents/ui/config/ConfigGeneral.qml:144
#: ../contents/ui/main.qml:94
msgid "System Preferences"
msgstr ""
#: ../contents/ui/config/ConfigOverrides.qml:34
msgid "System Preferences:"
msgstr ""
#: ../contents/ui/config/ConfigGeneral.qml:148
#: ../contents/ui/main.qml:102
msgid "App Store"
msgstr ""
#: ../contents/ui/config/ConfigOverrides.qml:42
msgid "App Store:"
msgstr ""
#: ../contents/ui/config/ConfigGeneral.qml:152
#: ../contents/ui/main.qml:120
msgid "Force Quit"
msgstr ""
#: ../contents/ui/config/ConfigOverrides.qml:50
msgid "Force Quit:"
msgstr ""
#: ../contents/ui/config/ConfigGeneral.qml:156
#: ../contents/ui/main.qml:138
msgid "Sleep"
msgstr ""
#: ../contents/ui/config/ConfigOverrides.qml:58
msgid "Sleep:"
msgstr ""
#: ../contents/ui/config/ConfigGeneral.qml:160
#: ../contents/ui/main.qml:146
msgid "Restart"
msgstr ""
#: ../contents/ui/config/ConfigOverrides.qml:66
msgid "Restart:"
msgstr ""
#: ../contents/ui/config/ConfigGeneral.qml:164
#: ../contents/ui/main.qml:154
msgid "Shut Down"
msgstr ""
#: ../contents/ui/config/ConfigOverrides.qml:74
msgid "Shut Down:"
msgstr ""
#: ../contents/ui/config/ConfigGeneral.qml:168
#: ../contents/ui/main.qml:172
msgid "Lock Screen"
msgstr ""
#: ../contents/ui/config/ConfigOverrides.qml:82
msgid "Lock Screen:"
msgstr ""
#: ../contents/ui/config/ConfigGeneral.qml:172
#: ../contents/ui/main.qml:180
msgid "Log Out"
msgstr ""
#: ../contents/ui/config/ConfigOverrides.qml:90
msgid "Log Out:"
msgstr ""
#: ../contents/ui/config/ConfigGeneral.qml:180
msgid "Size:"
msgstr ""