plasmoides KDE Store/GitHub (gnome-pager, kMenu, kvitals, windowtitle, claude, launchpad)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+273
@@ -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 ""
|
||||
Reference in New Issue
Block a user