From c31c56ff48f2f62151c96e22524e32cec7f8e1fd Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Sun, 30 Nov 2025 20:31:55 -0500 Subject: [PATCH 01/28] PluginSystem: basic implementation with an hello-world bar widget --- Assets/Translations/en.json | 61 ++ Assets/settings-default.json | 16 +- Commons/IconsTabler.qml | 3 +- Commons/Settings.qml | 7 + Modules/MainScreen/AllScreens.qml | 3 +- .../MainScreen/Backgrounds/AllBackgrounds.qml | 14 + Modules/MainScreen/MainScreen.qml | 37 +- Modules/Panels/Plugins/PluginPanelSlot.qml | 133 ++++ Modules/Panels/Settings/SettingsPanel.qml | 11 + Modules/Panels/Settings/Tabs/PluginsTab.qml | 588 ++++++++++++++++++ Services/Control/IPCService.qml | 41 ++ Services/Noctalia/PluginRegistry.qml | 371 +++++++++++ Services/Noctalia/PluginService.qml | 549 ++++++++++++++++ Services/UI/BarWidgetRegistry.qml | 56 ++ shell.qml | 3 + 15 files changed, 1871 insertions(+), 22 deletions(-) create mode 100644 Modules/Panels/Plugins/PluginPanelSlot.qml create mode 100644 Modules/Panels/Settings/Tabs/PluginsTab.qml create mode 100644 Services/Noctalia/PluginRegistry.qml create mode 100644 Services/Noctalia/PluginService.qml diff --git a/Assets/Translations/en.json b/Assets/Translations/en.json index 9e640a46b..47926d856 100644 --- a/Assets/Translations/en.json +++ b/Assets/Translations/en.json @@ -3,6 +3,10 @@ "error": "Authentication error", "failed": "Authentication failed" }, + "common": { + "add": "Add", + "cancel": "Cancel" + }, "bar": { "widget-settings": { "active-window": { @@ -1728,6 +1732,63 @@ } } }, + "plugins": { + "installed": { + "description": "Manage and configure all locally installed plugins.", + "label": "Installed plugins", + "no-plugins": "No plugins installed", + "no-plugins.description": "Install plugins from the Available Plugins section below." + }, + "available": { + "description": "Browse and install plugins from configured sources.", + "label": "Available plugins", + "no-plugins": "No plugins available", + "no-plugins.description": "Check your plugin sources or refresh the list." + }, + "filter": { + "all": "All", + "downloaded": "Downloaded", + "not-downloaded": "Not Downloaded" + }, + "install": "Install", + "install-error": "Failed to install: %1", + "install-success": "Successfully installed %1", + "installing": "Installing %1...", + "refresh": { + "refreshing": "Refreshing plugin list...", + "tooltip": "Refresh available plugins" + }, + "settings": { + "tooltip": "Plugin settings" + }, + "sources": { + "add-custom": "Add custom repository", + "add-dialog": { + "description": "Add a GitHub repository as a plugin source.", + "error": "Failed to add plugin source", + "name": "Repository name", + "name.placeholder": "My Custom Plugins", + "success": "Plugin source added successfully", + "title": "Add plugin source", + "url": "Repository URL" + }, + "description": "Manage plugin repositories where plugins are downloaded from.", + "label": "Plugin sources", + "remove": { + "tooltip": "Remove plugin source" + } + }, + "title": "Plugins", + "uninstall": "Uninstall", + "uninstall-dialog": { + "description": "Are you sure you want to uninstall %1? This will remove all plugin data.", + "title": "Uninstall plugin" + }, + "uninstall-error": "Failed to uninstall: %1", + "uninstall-success": "Successfully uninstalled %1", + "uninstall.tooltip": "Uninstall plugin", + "uninstalling": "Uninstalling %1..." + }, "screen-recorder": { "audio": { "audio-codec": { diff --git a/Assets/settings-default.json b/Assets/settings-default.json index fdc6f7f8a..a5341c6b9 100644 --- a/Assets/settings-default.json +++ b/Assets/settings-default.json @@ -1,5 +1,5 @@ { - "settingsVersion": 25, + "settingsVersion": 26, "bar": { "position": "top", "backgroundOpacity": 1, @@ -15,7 +15,13 @@ "widgets": { "left": [ { - "id": "ControlCenter" + "icon": "rocket", + "id": "CustomButton", + "leftClickExec": "qs -c noctalia-shell ipc call launcher toggle" + }, + { + "id": "Clock", + "usePrimaryColor": false }, { "id": "SystemMonitor" @@ -52,7 +58,7 @@ "id": "Brightness" }, { - "id": "Clock" + "id": "ControlCenter" } ] } @@ -103,11 +109,11 @@ "cards": [ { "enabled": true, - "id": "banner-card" + "id": "calendar-header-card" }, { "enabled": true, - "id": "calendar-card" + "id": "calendar-month-card" }, { "enabled": true, diff --git a/Commons/IconsTabler.qml b/Commons/IconsTabler.qml index 21c7aa671..cb5bd33c1 100644 --- a/Commons/IconsTabler.qml +++ b/Commons/IconsTabler.qml @@ -168,7 +168,8 @@ Singleton { "filepicker-text": "file-text", "filepicker-eye": "eye", "filepicker-eye-off": "eye-off", - "filepicker-folder-current": "checks" + "filepicker-folder-current": "checks", + "plugin": "plug-connected" } // Fonts Codepoints - do not change! diff --git a/Commons/Settings.qml b/Commons/Settings.qml index 29cc6dd6c..8a19b02c4 100644 --- a/Commons/Settings.qml +++ b/Commons/Settings.qml @@ -7,6 +7,7 @@ import "../Helpers/QtObj2JS.js" as QtObj2JS import qs.Commons import qs.Commons.Migrations import qs.Modules.OSD +import qs.Services.Noctalia import qs.Services.UI Singleton { @@ -714,6 +715,12 @@ Singleton { return; } + // Wait for PluginService to finish loading plugin widgets + if (!PluginService.pluginsFullyLoaded) { + Qt.callLater(upgradeSettingsData); + return; + } + const sections = ["left", "center", "right"]; // ----------------- diff --git a/Modules/MainScreen/AllScreens.qml b/Modules/MainScreen/AllScreens.qml index 00d5406ba..78e6949b8 100644 --- a/Modules/MainScreen/AllScreens.qml +++ b/Modules/MainScreen/AllScreens.qml @@ -4,6 +4,7 @@ import Quickshell.Wayland import qs.Commons import qs.Modules.MainScreen +import qs.Services.Noctalia import qs.Services.UI // ------------------------------ @@ -37,7 +38,7 @@ Variants { // Main Screen loader - Bar and panels backgrounds Loader { id: windowLoader - active: parent.shouldBeActive + active: parent.shouldBeActive && PluginService.pluginsFullyLoaded asynchronous: false property ShellScreen loaderScreen: modelData diff --git a/Modules/MainScreen/Backgrounds/AllBackgrounds.qml b/Modules/MainScreen/Backgrounds/AllBackgrounds.qml index 58dfcdf9c..f7895531e 100644 --- a/Modules/MainScreen/Backgrounds/AllBackgrounds.qml +++ b/Modules/MainScreen/Backgrounds/AllBackgrounds.qml @@ -166,6 +166,20 @@ Item { shapeContainer: backgroundsShape backgroundColor: panelBackgroundColor } + + // Plugin Panel Slot 1 + PanelBackground { + panel: root.windowRoot.pluginPanel1Placeholder + shapeContainer: backgroundsShape + backgroundColor: panelBackgroundColor + } + + // Plugin Panel Slot 2 + PanelBackground { + panel: root.windowRoot.pluginPanel2Placeholder + shapeContainer: backgroundsShape + backgroundColor: panelBackgroundColor + } } // Apply shadow to the cached layer diff --git a/Modules/MainScreen/MainScreen.qml b/Modules/MainScreen/MainScreen.qml index 25cebebda..e4e999730 100644 --- a/Modules/MainScreen/MainScreen.qml +++ b/Modules/MainScreen/MainScreen.qml @@ -19,6 +19,7 @@ import qs.Modules.Panels.Clock import qs.Modules.Panels.ControlCenter import qs.Modules.Panels.Launcher import qs.Modules.Panels.NotificationHistory +import qs.Modules.Panels.Plugins import qs.Modules.Panels.SessionMenu import qs.Modules.Panels.Settings import qs.Modules.Panels.SetupWizard @@ -50,6 +51,8 @@ PanelWindow { readonly property alias trayDrawerPanel: trayDrawerPanel readonly property alias wallpaperPanel: wallpaperPanel readonly property alias wifiPanel: wifiPanel + readonly property alias pluginPanel1: pluginPanel1 + readonly property alias pluginPanel2: pluginPanel2 // Expose panel backgrounds for AllBackgrounds readonly property var audioPanelPlaceholder: audioPanel.panelRegion @@ -67,6 +70,8 @@ PanelWindow { readonly property var trayDrawerPanelPlaceholder: trayDrawerPanel.panelRegion readonly property var wallpaperPanelPlaceholder: wallpaperPanel.panelRegion readonly property var wifiPanelPlaceholder: wifiPanel.panelRegion + readonly property var pluginPanel1Placeholder: pluginPanel1.panelRegion + readonly property var pluginPanel2Placeholder: pluginPanel2.panelRegion Component.onCompleted: { Logger.d("MainScreen", "Initialized for screen:", screen?.name, "- Dimensions:", screen?.width, "x", screen?.height, "- Position:", screen?.x, ",", screen?.y); @@ -202,105 +207,107 @@ PanelWindow { id: audioPanel objectName: "audioPanel-" + (root.screen?.name || "unknown") screen: root.screen - z: 50 } BatteryPanel { id: batteryPanel objectName: "batteryPanel-" + (root.screen?.name || "unknown") screen: root.screen - z: 50 } BluetoothPanel { id: bluetoothPanel objectName: "bluetoothPanel-" + (root.screen?.name || "unknown") screen: root.screen - z: 50 } BrightnessPanel { id: brightnessPanel objectName: "brightnessPanel-" + (root.screen?.name || "unknown") screen: root.screen - z: 50 } ControlCenterPanel { id: controlCenterPanel objectName: "controlCenterPanel-" + (root.screen?.name || "unknown") screen: root.screen - z: 50 } ChangelogPanel { id: changelogPanel objectName: "changelogPanel-" + (root.screen?.name || "unknown") screen: root.screen - z: 50 } ClockPanel { id: clockPanel objectName: "clockPanel-" + (root.screen?.name || "unknown") screen: root.screen - z: 50 } Launcher { id: launcherPanel objectName: "launcherPanel-" + (root.screen?.name || "unknown") screen: root.screen - z: 50 } NotificationHistoryPanel { id: notificationHistoryPanel objectName: "notificationHistoryPanel-" + (root.screen?.name || "unknown") screen: root.screen - z: 50 } SessionMenu { id: sessionMenuPanel objectName: "sessionMenuPanel-" + (root.screen?.name || "unknown") screen: root.screen - z: 50 } SettingsPanel { id: settingsPanel objectName: "settingsPanel-" + (root.screen?.name || "unknown") screen: root.screen - z: 50 } SetupWizard { id: setupWizardPanel objectName: "setupWizardPanel-" + (root.screen?.name || "unknown") screen: root.screen - z: 50 } TrayDrawerPanel { id: trayDrawerPanel objectName: "trayDrawerPanel-" + (root.screen?.name || "unknown") screen: root.screen - z: 50 } WallpaperPanel { id: wallpaperPanel objectName: "wallpaperPanel-" + (root.screen?.name || "unknown") screen: root.screen - z: 50 } WiFiPanel { id: wifiPanel objectName: "wifiPanel-" + (root.screen?.name || "unknown") screen: root.screen - z: 50 + } + + // ---------------------------------------------- + // Plugin panel slots + // ---------------------------------------------- + PluginPanelSlot { + id: pluginPanel1 + objectName: "pluginPanel1-" + (root.screen?.name || "unknown") + screen: root.screen + slotNumber: 1 + } + + PluginPanelSlot { + id: pluginPanel2 + objectName: "pluginPanel2-" + (root.screen?.name || "unknown") + screen: root.screen + slotNumber: 2 } // ---------------------------------------------- diff --git a/Modules/Panels/Plugins/PluginPanelSlot.qml b/Modules/Panels/Plugins/PluginPanelSlot.qml new file mode 100644 index 000000000..61d70409a --- /dev/null +++ b/Modules/Panels/Plugins/PluginPanelSlot.qml @@ -0,0 +1,133 @@ +import QtQuick +import Quickshell +import qs.Commons +import qs.Modules.MainScreen +import qs.Services.Noctalia +import qs.Services.UI + +/** +* Generic plugin panel slot that can be reused for different plugins +*/ +SmartPanel { + id: root + + // Which plugin slot this is (1 or 2) + property int slotNumber: 1 + + // Currently loaded plugin ID (empty if no plugin using this slot) + property string currentPluginId: "" + + // Plugin instance + property var pluginInstance: null + + // Panel content is dynamically loaded + panelContent: Component { + Item { + id: panelContainer + + // Required by SmartPanel for click-through mask + readonly property var maskRegion: pluginContentItem + + // Panel properties expected by SmartPanel + property bool allowAttach: true + property real topPadding: 0 + property real bottomPadding: 0 + property real leftPadding: 0 + property real rightPadding: 0 + + anchors.fill: parent + + // Dynamic plugin content + Item { + id: pluginContentItem + anchors.fill: parent + + Loader { + id: pluginContentLoader + anchors.fill: parent + active: false + } + } + + Component.onCompleted: { + // Load plugin panel content if assigned + if (root.currentPluginId !== "") { + root.loadPluginPanel(root.currentPluginId); + } + } + } + } + + // Load a plugin's panel content + function loadPluginPanel(pluginId) { + if (!PluginService.isPluginLoaded(pluginId)) { + Logger.w("PluginPanelSlot", "Plugin not loaded:", pluginId); + return false; + } + + var plugin = PluginService.loadedPlugins[pluginId]; + if (!plugin || !plugin.manifest) { + Logger.w("PluginPanelSlot", "Plugin data not found:", pluginId); + return false; + } + + if (!plugin.manifest.provides.panel) { + Logger.w("PluginPanelSlot", "Plugin does not provide a panel:", pluginId); + return false; + } + + var pluginDir = PluginRegistry.getPluginDir(pluginId); + var panelPath = pluginDir + "/" + plugin.manifest.entryPoints.panel; + + Logger.i("PluginPanelSlot", "Loading panel for plugin:", pluginId, "in slot", root.slotNumber); + + // Load the panel component + var component = Qt.createComponent("file://" + panelPath); + + if (component.status === Component.Ready) { + // Get plugin API + var api = PluginService.getPluginAPI(pluginId); + + // Create instance with API + pluginContentLoader.active = true; + pluginContentLoader.sourceComponent = component; + + if (pluginContentLoader.item) { + // Inject plugin API + if (pluginContentLoader.item.hasOwnProperty("pluginApi")) { + pluginContentLoader.item.pluginApi = api; + } + + root.pluginInstance = pluginContentLoader.item; + root.currentPluginId = pluginId; + + Logger.i("PluginPanelSlot", "Panel loaded for:", pluginId); + return true; + } + } else if (component.status === Component.Error) { + Logger.e("PluginPanelSlot", "Failed to load panel component:", component.errorString()); + return false; + } + + return false; + } + + // Unload current plugin panel + function unloadPluginPanel() { + if (root.currentPluginId === "") { + return; + } + + Logger.i("PluginPanelSlot", "Unloading panel from slot", root.slotNumber); + + pluginContentLoader.active = false; + pluginContentLoader.sourceComponent = null; + root.pluginInstance = null; + root.currentPluginId = ""; + } + + // Register with PanelService + Component.onCompleted: { + PanelService.registerPanel(root); + } +} diff --git a/Modules/Panels/Settings/SettingsPanel.qml b/Modules/Panels/Settings/SettingsPanel.qml index 63cad2b7d..6cff2e2e8 100644 --- a/Modules/Panels/Settings/SettingsPanel.qml +++ b/Modules/Panels/Settings/SettingsPanel.qml @@ -76,6 +76,7 @@ SmartPanel { Location, Network, Notifications, + Plugins, ScreenRecorder, SessionMenu, SystemMonitor, @@ -172,6 +173,10 @@ SmartPanel { id: systemMonitorTab SystemMonitorTab {} } + Component { + id: pluginsTab + PluginsTab {} + } // Order *DOES* matter function updateTabsModel() { @@ -284,6 +289,12 @@ SmartPanel { "icon": "settings-system-monitor", "source": systemMonitorTab }, + { + "id": SettingsPanel.Tab.Plugins, + "label": "settings.plugins.title", + "icon": "plugin", + "source": pluginsTab + }, { "id": SettingsPanel.Tab.Hooks, "label": "settings.hooks.title", diff --git a/Modules/Panels/Settings/Tabs/PluginsTab.qml b/Modules/Panels/Settings/Tabs/PluginsTab.qml new file mode 100644 index 000000000..8218262a9 --- /dev/null +++ b/Modules/Panels/Settings/Tabs/PluginsTab.qml @@ -0,0 +1,588 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Commons +import qs.Services.Noctalia +import qs.Services.UI +import qs.Widgets + +ColumnLayout { + id: root + spacing: Style.marginL + width: parent.width + + // ------------------------------ + // Section 1: Installed Plugins + // ------------------------------ + NHeader { + label: I18n.tr("settings.plugins.installed.label") + description: I18n.tr("settings.plugins.installed.description") + } + + ColumnLayout { + spacing: Style.marginM + Layout.fillWidth: true + + Repeater { + id: installedPluginsRepeater + + model: { + // Make this reactive to PluginRegistry changes + var _ = PluginRegistry.installedPlugins; // Force dependency + var __ = PluginRegistry.pluginStates; // Force dependency + + var allIds = PluginRegistry.getAllInstalledPluginIds(); + var plugins = []; + for (var i = 0; i < allIds.length; i++) { + var manifest = PluginRegistry.getPluginManifest(allIds[i]); + if (manifest) { + plugins.push(manifest); + } + } + return plugins; + } + + delegate: RowLayout { + spacing: Style.marginM + Layout.fillWidth: true + + NIcon { + icon: "plugin" + pointSize: Style.fontSizeL + } + + ColumnLayout { + spacing: 2 + Layout.fillWidth: true + + NText { + text: modelData.name + font.weight: Font.Medium + color: Color.mOnSurface + } + + NText { + text: modelData.description + font.pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + } + } + + NToggle { + checked: PluginRegistry.isPluginEnabled(modelData.id) + onToggled: function (checked) { + if (checked) { + PluginService.enablePlugin(modelData.id); + } else { + PluginService.disablePlugin(modelData.id); + } + } + } + + NIconButton { + icon: "settings" + tooltipText: I18n.tr("settings.plugins.settings.tooltip") + visible: modelData.entryPoints?.settings !== undefined + onClicked: { + // TODO: Open plugin settings dialog + Logger.i("PluginsTab", "Open settings for:", modelData.id); + } + } + + NIconButton { + icon: "trash" + tooltipText: I18n.tr("settings.plugins.uninstall.tooltip") + onClicked: { + uninstallDialog.pluginToUninstall = modelData; + uninstallDialog.open(); + } + } + } + } + + NLabel { + visible: PluginRegistry.getAllInstalledPluginIds().length === 0 + label: I18n.tr("settings.plugins.installed.no-plugins") + description: I18n.tr("settings.plugins.installed.no-plugins.description") + Layout.fillWidth: true + } + } + + NDivider { + Layout.fillWidth: true + Layout.topMargin: Style.marginL + Layout.bottomMargin: Style.marginL + } + + // ------------------------------ + // Section 2: Available Plugins + // ------------------------------ + NHeader { + label: I18n.tr("settings.plugins.available.label") + description: I18n.tr("settings.plugins.available.description") + } + + // Filter controls + RowLayout { + spacing: Style.marginM + Layout.fillWidth: true + + NButton { + text: I18n.tr("settings.plugins.filter.all") + backgroundColor: pluginFilter === "all" ? Color.mPrimary : Color.mSurfaceVariant + textColor: pluginFilter === "all" ? Color.mOnPrimary : Color.mOnSurfaceVariant + onClicked: pluginFilter = "all" + } + + NButton { + text: I18n.tr("settings.plugins.filter.downloaded") + backgroundColor: pluginFilter === "downloaded" ? Color.mPrimary : Color.mSurfaceVariant + textColor: pluginFilter === "downloaded" ? Color.mOnPrimary : Color.mOnSurfaceVariant + onClicked: pluginFilter = "downloaded" + } + + NButton { + text: I18n.tr("settings.plugins.filter.not-downloaded") + backgroundColor: pluginFilter === "notDownloaded" ? Color.mPrimary : Color.mSurfaceVariant + textColor: pluginFilter === "notDownloaded" ? Color.mOnPrimary : Color.mOnSurfaceVariant + onClicked: pluginFilter = "notDownloaded" + } + + Item { + Layout.fillWidth: true + } + + NIconButton { + icon: "refresh" + tooltipText: I18n.tr("settings.plugins.refresh.tooltip") + onClicked: { + PluginService.refreshAvailablePlugins(); + ToastService.show(I18n.tr("settings.plugins.refresh.refreshing")); + } + } + } + + property string pluginFilter: "all" + + // Available plugins list + NScrollView { + Layout.fillWidth: true + Layout.preferredHeight: 400 + + NListView { + id: pluginListView + spacing: Style.marginM + + model: { + var all = PluginService.availablePlugins || []; + var filtered = []; + + for (var i = 0; i < all.length; i++) { + var plugin = all[i]; + var downloaded = plugin.downloaded || false; + + if (pluginFilter === "all") { + filtered.push(plugin); + } else if (pluginFilter === "downloaded" && downloaded) { + filtered.push(plugin); + } else if (pluginFilter === "notDownloaded" && !downloaded) { + filtered.push(plugin); + } + } + + return filtered; + } + + delegate: RowLayout { + width: pluginListView.width + spacing: Style.marginM + + Rectangle { + width: 48 + height: 48 + radius: Style.radiusM + color: Color.mSurfaceContainerHigh + + NIcon { + anchors.centerIn: parent + icon: "plugin" + pointSize: Style.fontSizeXL + } + } + + ColumnLayout { + spacing: 2 + Layout.fillWidth: true + + NText { + text: modelData.name + font.weight: Font.Medium + color: Color.mOnSurface + } + + NText { + text: modelData.description + font.pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + RowLayout { + spacing: Style.marginS + + NText { + text: "v" + modelData.version + font.pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + } + + NText { + text: "•" + font.pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + } + + NText { + text: modelData.author + font.pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + } + + NText { + text: "•" + font.pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + } + + NText { + text: modelData.source?.name || "Unknown" + font.pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + } + } + } + + // Downloaded indicator + NIcon { + icon: "check-circle" + pointSize: Style.fontSizeM + color: Color.mPrimary + visible: modelData.downloaded === true + } + + // Install/Uninstall button + NButton { + text: modelData.downloaded ? I18n.tr("settings.plugins.uninstall") : I18n.tr("settings.plugins.install") + onClicked: { + if (modelData.downloaded) { + uninstallDialog.pluginToUninstall = modelData; + uninstallDialog.open(); + } else { + installPlugin(modelData); + } + } + } + + // Enable/Disable toggle (only for downloaded plugins) + NToggle { + visible: modelData.downloaded === true + checked: modelData.enabled || false + onToggled: function (checked) { + if (checked) { + PluginService.enablePlugin(modelData.id); + } else { + PluginService.disablePlugin(modelData.id); + } + } + } + } + } + } + + NLabel { + visible: pluginListView.count === 0 + label: I18n.tr("settings.plugins.available.no-plugins") + description: I18n.tr("settings.plugins.available.no-plugins.description") + Layout.fillWidth: true + } + + NDivider { + Layout.fillWidth: true + Layout.topMargin: Style.marginL + Layout.bottomMargin: Style.marginL + } + + // ------------------------------ + // Section 3: Plugin Sources + // ------------------------------ + NCollapsible { + Layout.fillWidth: true + label: I18n.tr("settings.plugins.sources.label") + description: I18n.tr("settings.plugins.sources.description") + expanded: false + + ColumnLayout { + spacing: Style.marginM + Layout.fillWidth: true + + // List of plugin sources + Repeater { + model: PluginRegistry.pluginSources || [] + + delegate: RowLayout { + spacing: Style.marginM + Layout.fillWidth: true + + NIcon { + icon: "brand-github" + pointSize: Style.fontSizeM + } + + ColumnLayout { + spacing: 2 + Layout.fillWidth: true + + NText { + text: modelData.name + font.weight: Font.Medium + color: Color.mOnSurface + } + + NText { + text: modelData.url + font.pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + } + } + + NIconButton { + icon: "trash" + tooltipText: I18n.tr("settings.plugins.sources.remove.tooltip") + visible: index !== 0 // Cannot remove official source + onClicked: { + PluginRegistry.removePluginSource(modelData.url); + } + } + } + } + + NDivider { + Layout.fillWidth: true + } + + // Add custom repository + NButton { + text: I18n.tr("settings.plugins.sources.add-custom") + icon: "plus" + onClicked: { + addSourceDialog.open(); + } + Layout.fillWidth: true + } + } + } + + // ------------------------------ + // Dialogs + // ------------------------------ + + // Add source dialog + Popup { + id: addSourceDialog + modal: true + dim: false + anchors.centerIn: parent + width: 500 + padding: Style.marginL + + ColumnLayout { + width: parent.width + spacing: Style.marginL + + NHeader { + label: I18n.tr("settings.plugins.sources.add-dialog.title") + description: I18n.tr("settings.plugins.sources.add-dialog.description") + } + + NTextInput { + id: sourceNameInput + label: I18n.tr("settings.plugins.sources.add-dialog.name") + placeholderText: I18n.tr("settings.plugins.sources.add-dialog.name.placeholder") + Layout.fillWidth: true + } + + NTextInput { + id: sourceUrlInput + label: I18n.tr("settings.plugins.sources.add-dialog.url") + placeholderText: "https://github.com/user/repo" + Layout.fillWidth: true + } + + RowLayout { + spacing: Style.marginM + Layout.fillWidth: true + + Item { + Layout.fillWidth: true + } + + NButton { + text: I18n.tr("common.cancel") + onClicked: addSourceDialog.close() + } + + NButton { + text: I18n.tr("common.add") + backgroundColor: Color.mPrimary + textColor: Color.mOnPrimary + enabled: sourceNameInput.text.length > 0 && sourceUrlInput.text.length > 0 + onClicked: { + if (PluginRegistry.addPluginSource(sourceNameInput.text, sourceUrlInput.text)) { + ToastService.show(I18n.tr("settings.plugins.sources.add-dialog.success")); + PluginService.refreshAvailablePlugins(); + addSourceDialog.close(); + sourceNameInput.text = ""; + sourceUrlInput.text = ""; + } else { + ToastService.show(I18n.tr("settings.plugins.sources.add-dialog.error")); + } + } + } + } + } + } + + // Uninstall confirmation dialog + Popup { + id: uninstallDialog + modal: true + dim: false + anchors.centerIn: parent + width: 400 + padding: Style.marginL + + property var pluginToUninstall: null + + ColumnLayout { + width: parent.width + spacing: Style.marginL + + NHeader { + label: I18n.tr("settings.plugins.uninstall-dialog.title") + description: I18n.tr("settings.plugins.uninstall-dialog.description").replace("%1", uninstallDialog.pluginToUninstall?.name || "") + } + + RowLayout { + spacing: Style.marginM + Layout.fillWidth: true + + Item { + Layout.fillWidth: true + } + + NButton { + text: I18n.tr("common.cancel") + onClicked: uninstallDialog.close() + } + + NButton { + text: I18n.tr("settings.plugins.uninstall") + backgroundColor: Color.mPrimary + textColor: Color.mOnPrimary + onClicked: { + if (uninstallDialog.pluginToUninstall) { + uninstallPlugin(uninstallDialog.pluginToUninstall.id); + uninstallDialog.close(); + } + } + } + } + } + } + + // ------------------------------ + // Functions + // ------------------------------ + + function installPlugin(pluginMetadata) { + ToastService.show(I18n.tr("settings.plugins.installing").replace("%1", pluginMetadata.name)); + + PluginService.installPlugin(pluginMetadata, function (success, error) { + if (success) { + ToastService.show(I18n.tr("settings.plugins.install-success").replace("%1", pluginMetadata.name)); + } else { + ToastService.show(I18n.tr("settings.plugins.install-error").replace("%1", error || "Unknown error")); + } + }); + } + + function uninstallPlugin(pluginId) { + var manifest = PluginRegistry.getPluginManifest(pluginId); + var pluginName = manifest?.name || pluginId; + + ToastService.show(I18n.tr("settings.plugins.uninstalling").replace("%1", pluginName)); + + PluginService.uninstallPlugin(pluginId, function (success, error) { + if (success) { + ToastService.show(I18n.tr("settings.plugins.uninstall-success").replace("%1", pluginName)); + } else { + ToastService.show(I18n.tr("settings.plugins.uninstall-error").replace("%1", error || "Unknown error")); + } + }); + } + + // Listen to plugin registry changes + Connections { + target: PluginRegistry + + function onPluginsChanged() { + // Force model refresh for installed plugins + installedPluginsRepeater.model = undefined; + Qt.callLater(function () { + installedPluginsRepeater.model = Qt.binding(function () { + var allIds = PluginRegistry.getAllInstalledPluginIds(); + var plugins = []; + for (var i = 0; i < allIds.length; i++) { + var manifest = PluginRegistry.getPluginManifest(allIds[i]); + if (manifest) { + plugins.push(manifest); + } + } + return plugins; + }); + }); + } + } + + // Listen to plugin service signals + Connections { + target: PluginService + + function onAvailablePluginsUpdated() { + // Force model refresh + pluginListView.model = undefined; + Qt.callLater(function () { + pluginListView.model = Qt.binding(function () { + var all = PluginService.availablePlugins || []; + var filtered = []; + + for (var i = 0; i < all.length; i++) { + var plugin = all[i]; + var downloaded = plugin.downloaded || false; + + if (root.pluginFilter === "all") { + filtered.push(plugin); + } else if (root.pluginFilter === "downloaded" && downloaded) { + filtered.push(plugin); + } else if (root.pluginFilter === "notDownloaded" && !downloaded) { + filtered.push(plugin); + } + } + + return filtered; + }); + }); + } + } +} diff --git a/Services/Control/IPCService.qml b/Services/Control/IPCService.qml index 850fc8d77..15b254ed4 100644 --- a/Services/Control/IPCService.qml +++ b/Services/Control/IPCService.qml @@ -8,6 +8,7 @@ import qs.Commons import qs.Services.Compositor import qs.Services.Hardware import qs.Services.Media +import qs.Services.Noctalia import qs.Services.Power import qs.Services.System import qs.Services.Theming @@ -386,6 +387,46 @@ Item { } } + // ------------------------------------------------------------------- + // Plugin IPC namespace + // ------------------------------------------------------------------- + IpcHandler { + target: "plugins" + + // Dynamic plugin IPC calls + // Usage: qs -c noctalia-shell ipc call plugins invoke pluginId actionName [args] + // where args is an optional string that the plugin can parse + function invoke(pluginId: string, actionName: string, args: string) { + Logger.d("IPC", "Plugin IPC call:", pluginId, actionName, args); + + // Check if plugin is loaded + if (!PluginService.isPluginLoaded(pluginId)) { + Logger.w("IPC", "Plugin not loaded:", pluginId); + return false; + } + + // Get plugin API + var api = PluginService.getPluginAPI(pluginId); + if (!api) { + Logger.w("IPC", "Plugin API not found:", pluginId); + return false; + } + + // Check if plugin has registered the IPC action + if (api.ipcHandlers && api.ipcHandlers[actionName]) { + try { + return api.ipcHandlers[actionName](args || ""); + } catch (e) { + Logger.e("IPC", "Plugin IPC call failed:", e); + return false; + } + } else { + Logger.w("IPC", "Plugin", pluginId, "has no IPC action:", actionName); + return false; + } + } + } + // ------------------------------------------------------------------- // Queue an IPC panel operation - will execute when screen is detected // ------------------------------------------------------------------- diff --git a/Services/Noctalia/PluginRegistry.qml b/Services/Noctalia/PluginRegistry.qml new file mode 100644 index 000000000..98734e4d1 --- /dev/null +++ b/Services/Noctalia/PluginRegistry.qml @@ -0,0 +1,371 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Commons + +Singleton { + id: root + + readonly property string pluginsDir: Settings.configDir + "plugins" + readonly property string pluginsFile: Settings.configDir + "plugins.json" + + // Signals + signal pluginsChanged + + // In-memory plugin cache (populated by scanning disk) + property var installedPlugins: ({}) // { pluginId: manifest } + property var pluginStates: ({}) // { pluginId: { enabled: bool } } + property var pluginSources: [] // Array of { name, url } + + // Track async loading + property int pendingManifests: 0 + + // File storage (minimal - only states and sources) + property FileView pluginsFileView: FileView { + id: pluginsFileView + path: root.pluginsFile + + adapter: JsonAdapter { + id: adapter + property int version: 1 + property var states: ({}) + property list sources: [] + } + + onLoaded: { + Logger.i("PluginRegistry", "Loaded plugin states from:", path); + Logger.i("PluginRegistry", "FileView onLoaded triggered"); + root.pluginStates = adapter.states || {}; + root.pluginSources = adapter.sources || []; + + // Ensure official repo is in sources + if (root.pluginSources.length === 0) { + root.pluginSources = [ + { + "name": "Official Noctalia Plugins", + "url": "https://github.com/noctalia-dev/noctalia-plugins" + } + ]; + root.save(); + } + + // Scan plugin folder to discover installed plugins + scanPluginFolder(); + } + } + + Component.onCompleted: { + ensurePluginsDirectory(); + ensurePluginsFile(); + } + + function init() { + Logger.d("PluginRegistry", "Initialized"); + // Force instantiation of PluginService to set up signal listener + PluginService.initialized; + } + + // Ensure plugins directory exists + function ensurePluginsDirectory() { + var mkdirProcess = Qt.createQmlObject(` + import QtQuick + import Quickshell.Io + Process { + command: ["mkdir", "-p", "${root.pluginsDir}"] + } + `, root, "MkdirPlugins"); + + mkdirProcess.exited.connect(function (exitCode) { + if (exitCode === 0) { + Logger.d("PluginRegistry", "Plugins directory ensured:", root.pluginsDir); + } else { + Logger.e("PluginRegistry", "Failed to create plugins directory"); + } + mkdirProcess.destroy(); + }); + + mkdirProcess.running = true; + } + + // Ensure plugins.json exists (create minimal one if it doesn't) + function ensurePluginsFile() { + var checkProcess = Qt.createQmlObject(` + import QtQuick + import Quickshell.Io + Process { + command: ["sh", "-c", "test -f '${root.pluginsFile}' || echo '{\\"version\\":1,\\"states\\":{},\\"sources\\":[]}' > '${root.pluginsFile}'"] + } + `, root, "EnsurePluginsFile"); + + checkProcess.exited.connect(function (exitCode) { + if (exitCode === 0) { + Logger.d("PluginRegistry", "Plugins file ensured:", root.pluginsFile); + } + checkProcess.destroy(); + }); + + checkProcess.running = true; + } + + // Scan plugin folder to discover installed plugins + function scanPluginFolder() { + Logger.i("PluginRegistry", "Scanning plugin folder:", root.pluginsDir); + + var lsProcess = Qt.createQmlObject(` + import QtQuick + import Quickshell.Io + Process { + command: ["sh", "-c", "ls -1 '${root.pluginsDir}' 2>/dev/null || true"] + stdout: StdioCollector {} + running: true + } + `, root, "ScanPlugins"); + + lsProcess.exited.connect(function (exitCode) { + var output = String(lsProcess.stdout.text || ""); + var pluginDirs = output.trim().split('\n').filter(function (dir) { + return dir.length > 0; + }); + + Logger.i("PluginRegistry", "Found", pluginDirs.length, "potential plugin directories"); + + if (pluginDirs.length === 0) { + // No plugins to load, emit signal immediately + root.pluginsChanged(); + lsProcess.destroy(); + return; + } + + // Track how many manifests we're loading + root.pendingManifests = pluginDirs.length; + Logger.i("PluginRegistry", "Starting to load", root.pendingManifests, "manifests"); + + // Load each manifest + for (var i = 0; i < pluginDirs.length; i++) { + loadPluginManifest(pluginDirs[i]); + } + + lsProcess.destroy(); + }); + } + + // Load a single plugin's manifest from disk + function loadPluginManifest(pluginId) { + var manifestPath = root.pluginsDir + "/" + pluginId + "/manifest.json"; + + var catProcess = Qt.createQmlObject(` + import QtQuick + import Quickshell.Io + Process { + command: ["cat", "${manifestPath}"] + stdout: StdioCollector {} + running: true + } + `, root, "LoadManifest_" + pluginId); + + catProcess.exited.connect(function (exitCode) { + var output = String(catProcess.stdout.text || ""); + if (exitCode === 0 && output) { + try { + var manifest = JSON.parse(output); + var validation = validateManifest(manifest); + + if (validation.valid) { + root.installedPlugins[pluginId] = manifest; + Logger.i("PluginRegistry", "Loaded plugin:", pluginId, "-", manifest.name); + + // Ensure state exists (default to disabled) + if (!root.pluginStates[pluginId]) { + root.pluginStates[pluginId] = { + enabled: false + }; + } + } else { + Logger.e("PluginRegistry", "Invalid manifest for", pluginId + ":", validation.error); + } + } catch (e) { + Logger.e("PluginRegistry", "Failed to parse manifest for", pluginId + ":", e.toString()); + } + } else { + Logger.d("PluginRegistry", "No manifest found for:", pluginId); + } + + // Decrement pending count and emit signal when all are done + root.pendingManifests--; + Logger.d("PluginRegistry", "Pending manifests remaining:", root.pendingManifests); + if (root.pendingManifests === 0) { + var installedIds = Object.keys(root.installedPlugins); + Logger.i("PluginRegistry", "All plugin manifests loaded. Total plugins:", installedIds.length); + Logger.d("PluginRegistry", "Installed plugin IDs:", JSON.stringify(installedIds)); + root.pluginsChanged(); + } + + catProcess.destroy(); + }); + } + + // Save registry to disk (only states and sources) + function save() { + adapter.states = root.pluginStates; + adapter.sources = root.pluginSources; + + Qt.callLater(() => { + pluginsFileView.writeAdapter(); + Logger.d("PluginRegistry", "Plugin states saved"); + }); + } + + // Enable/disable a plugin + function setPluginEnabled(pluginId, enabled) { + if (!root.installedPlugins[pluginId]) { + Logger.w("PluginRegistry", "Cannot set state for non-existent plugin:", pluginId); + return; + } + + if (!root.pluginStates[pluginId]) { + root.pluginStates[pluginId] = { + enabled: enabled + }; + } else { + root.pluginStates[pluginId].enabled = enabled; + } + + save(); + root.pluginsChanged(); + Logger.i("PluginRegistry", "Plugin", pluginId, enabled ? "enabled" : "disabled"); + } + + // Check if plugin is enabled + function isPluginEnabled(pluginId) { + return root.pluginStates[pluginId]?.enabled || false; + } + + // Check if plugin is downloaded/installed + function isPluginDownloaded(pluginId) { + return pluginId in root.installedPlugins; + } + + // Get plugin manifest from cache + function getPluginManifest(pluginId) { + return root.installedPlugins[pluginId] || null; + } + + // Get ALL installed plugin IDs (discovered from disk) + function getAllInstalledPluginIds() { + return Object.keys(root.installedPlugins); + } + + // Get enabled plugin IDs only + function getEnabledPluginIds() { + return Object.keys(root.pluginStates).filter(function (id) { + return root.pluginStates[id].enabled === true; + }); + } + + // Remove plugin state (call after deleting plugin folder) + function removePluginState(pluginId) { + delete root.pluginStates[pluginId]; + delete root.installedPlugins[pluginId]; + save(); + root.pluginsChanged(); + Logger.i("PluginRegistry", "Removed plugin state:", pluginId); + } + + // Add a plugin source + function addPluginSource(name, url) { + for (var i = 0; i < root.pluginSources.length; i++) { + if (root.pluginSources[i].url === url) { + Logger.w("PluginRegistry", "Source already exists:", url); + return false; + } + } + + root.pluginSources.push({ + name: name, + url: url + }); + save(); + Logger.i("PluginRegistry", "Added plugin source:", name); + return true; + } + + // Remove a plugin source + function removePluginSource(url) { + var newSources = []; + for (var i = 0; i < root.pluginSources.length; i++) { + if (root.pluginSources[i].url !== url) { + newSources.push(root.pluginSources[i]); + } + } + + if (newSources.length === root.pluginSources.length) { + Logger.w("PluginRegistry", "Source not found:", url); + return false; + } + + root.pluginSources = newSources; + save(); + Logger.i("PluginRegistry", "Removed plugin source:", url); + return true; + } + + // Get plugin directory path + function getPluginDir(pluginId) { + return root.pluginsDir + "/" + pluginId; + } + + // Get plugin settings file path + function getPluginSettingsFile(pluginId) { + return getPluginDir(pluginId) + "/settings.json"; + } + + // Validate manifest + function validateManifest(manifest) { + if (!manifest) { + return { + valid: false, + error: "Manifest is null or undefined" + }; + } + + var required = ["id", "name", "version", "author", "description"]; + for (var i = 0; i < required.length; i++) { + if (!manifest[required[i]]) { + return { + valid: false, + error: "Missing required field: " + required[i] + }; + } + } + + if (!manifest.provides) { + return { + valid: false, + error: "Missing 'provides' field" + }; + } + + if (!manifest.entryPoints) { + return { + valid: false, + error: "Missing 'entryPoints' field" + }; + } + + // Check version format (simple x.y.z check) + var versionRegex = /^\d+\.\d+\.\d+$/; + if (!versionRegex.test(manifest.version)) { + return { + valid: false, + error: "Invalid version format (must be x.y.z)" + }; + } + + return { + valid: true, + error: null + }; + } +} diff --git a/Services/Noctalia/PluginService.qml b/Services/Noctalia/PluginService.qml new file mode 100644 index 000000000..df8b5f04d --- /dev/null +++ b/Services/Noctalia/PluginService.qml @@ -0,0 +1,549 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Services.Noctalia +import qs.Services.UI + +Singleton { + id: root + + signal pluginLoaded(string pluginId) + signal pluginUnloaded(string pluginId) + signal pluginEnabled(string pluginId) + signal pluginDisabled(string pluginId) + signal availablePluginsUpdated + signal allPluginsLoaded + + // Loaded plugin instances + property var loadedPlugins: ({}) // { pluginId: { component, instance, api } } + + // Available plugins from all sources (fetched from registries) + property var availablePlugins: ([]) // Array of plugin metadata from all sources + + // Track active fetches + property var activeFetches: ({}) + + property bool initialized: false + property bool pluginsFullyLoaded: false + + // Listen for PluginRegistry to finish loading + Connections { + target: PluginRegistry + + function onPluginsChanged() { + if (!root.initialized) { + root.init(); + } + } + } + + function init() { + if (root.initialized) { + Logger.d("PluginService", "Already initialized, skipping"); + return; + } + + Logger.i("PluginService", "Initializing plugin system"); + root.initialized = true; + + // Debug: Check what's in PluginRegistry + var allInstalled = PluginRegistry.getAllInstalledPluginIds(); + Logger.d("PluginService", "All installed plugins:", JSON.stringify(allInstalled)); + Logger.d("PluginService", "Plugin states:", JSON.stringify(PluginRegistry.pluginStates)); + + // Load all enabled plugins + var enabledIds = PluginRegistry.getEnabledPluginIds(); + Logger.i("PluginService", "Found", enabledIds.length, "enabled plugins:", JSON.stringify(enabledIds)); + + for (var i = 0; i < enabledIds.length; i++) { + Logger.d("PluginService", "Attempting to load plugin:", enabledIds[i]); + var manifest = PluginRegistry.getPluginManifest(enabledIds[i]); + if (manifest) { + Logger.d("PluginService", "Manifest found for", enabledIds[i]); + loadPlugin(enabledIds[i]); + } else { + Logger.e("PluginService", "No manifest for enabled plugin:", enabledIds[i]); + } + } + + // Mark plugins as fully loaded + root.pluginsFullyLoaded = true; + Logger.i("PluginService", "All plugins loaded"); + root.allPluginsLoaded(); + + // Fetch available plugins from all sources + refreshAvailablePlugins(); + } + + // Refresh available plugins from all sources + function refreshAvailablePlugins() { + Logger.i("PluginService", "Refreshing available plugins"); + root.availablePlugins = []; + + var sources = PluginRegistry.pluginSources; + for (var i = 0; i < sources.length; i++) { + fetchPluginRegistry(sources[i]); + } + } + + // Fetch plugin registry from a source + function fetchPluginRegistry(source) { + var rawUrl = source.url + "/raw/main/registry.json"; + var registryUrl = rawUrl.replace("github.com", "raw.githubusercontent.com"); + + Logger.d("PluginService", "Fetching registry from:", registryUrl); + + var fetchProcess = Qt.createQmlObject(` + import QtQuick + import Quickshell.Io + Process { + command: ["sh", "-c", "curl -L -s '${registryUrl}' || wget -q -O- '${registryUrl}'"] + stdout: StdioCollector {} + } + `, root, "FetchRegistry_" + Date.now()); + + activeFetches[source.url] = fetchProcess; + + fetchProcess.exited.connect(function (exitCode) { + if (exitCode === 0) { + try { + var response = fetchProcess.stdout.text; + var registry = JSON.parse(response); + + if (registry && registry.plugins && Array.isArray(registry.plugins)) { + // Add source info to each plugin + for (var i = 0; i < registry.plugins.length; i++) { + var plugin = registry.plugins[i]; + plugin.source = source; + + // Check if already downloaded + plugin.downloaded = PluginRegistry.isPluginDownloaded(plugin.id); + plugin.enabled = PluginRegistry.isPluginEnabled(plugin.id); + + root.availablePlugins.push(plugin); + } + + Logger.i("PluginService", "Loaded", registry.plugins.length, "plugins from", source.name); + root.availablePluginsUpdated(); + } + } catch (e) { + Logger.e("PluginService", "Failed to parse registry from", source.name, ":", e); + } + } else { + Logger.e("PluginService", "Failed to fetch registry from", source.name); + } + + delete activeFetches[source.url]; + fetchProcess.destroy(); + }); + + fetchProcess.running = true; + } + + // Download and install a plugin + function installPlugin(pluginMetadata, callback) { + var pluginId = pluginMetadata.id; + var source = pluginMetadata.source; + + Logger.i("PluginService", "Installing plugin:", pluginId, "from", source.name); + + var pluginDir = PluginRegistry.getPluginDir(pluginId); + var repoUrl = source.url; + var pluginPath = pluginId; + + // Download plugin folder from GitHub + var downloadCmd = ` + mkdir -p '${pluginDir}' && + cd '${pluginDir}' && + (curl -L -s '${repoUrl}/archive/refs/heads/main.tar.gz' | tar -xz --strip-components=2 '*/main/${pluginPath}' || + wget -q -O- '${repoUrl}/archive/refs/heads/main.tar.gz' | tar -xz --strip-components=2 '*/main/${pluginPath}') + `; + + var downloadProcess = Qt.createQmlObject(` + import QtQuick + import Quickshell.Io + Process { + command: ["sh", "-c", "${downloadCmd}"] + } + `, root, "DownloadPlugin_" + pluginId); + + downloadProcess.exited.connect(function (exitCode) { + if (exitCode === 0) { + Logger.i("PluginService", "Downloaded plugin:", pluginId); + + // Load and validate manifest + var manifestPath = pluginDir + "/manifest.json"; + loadManifest(manifestPath, function (success, manifest) { + if (success) { + var validation = PluginRegistry.validateManifest(manifest); + if (validation.valid) { + // Register plugin + PluginRegistry.registerPlugin(manifest); + Logger.i("PluginService", "Installed plugin:", pluginId); + + // Update available plugins list + updatePluginInAvailable(pluginId, { + downloaded: true + }); + + if (callback) + callback(true, null); + } else { + Logger.e("PluginService", "Invalid manifest:", validation.error); + if (callback) + callback(false, "Invalid manifest: " + validation.error); + } + } else { + Logger.e("PluginService", "Failed to load manifest for:", pluginId); + if (callback) + callback(false, "Failed to load manifest"); + } + }); + } else { + Logger.e("PluginService", "Failed to download plugin:", pluginId); + if (callback) + callback(false, "Download failed"); + } + + downloadProcess.destroy(); + }); + + downloadProcess.running = true; + } + + // Uninstall a plugin + function uninstallPlugin(pluginId, callback) { + Logger.i("PluginService", "Uninstalling plugin:", pluginId); + + // Disable and unload first + if (PluginRegistry.isPluginEnabled(pluginId)) { + disablePlugin(pluginId); + } + + var pluginDir = PluginRegistry.getPluginDir(pluginId); + + var removeProcess = Qt.createQmlObject(` + import QtQuick + import Quickshell.Io + Process { + command: ["rm", "-rf", "${pluginDir}"] + } + `, root, "RemovePlugin_" + pluginId); + + removeProcess.exited.connect(function (exitCode) { + if (exitCode === 0) { + PluginRegistry.unregisterPlugin(pluginId); + Logger.i("PluginService", "Uninstalled plugin:", pluginId); + + // Update available plugins list + updatePluginInAvailable(pluginId, { + downloaded: false, + enabled: false + }); + + if (callback) + callback(true, null); + } else { + Logger.e("PluginService", "Failed to uninstall plugin:", pluginId); + if (callback) + callback(false, "Failed to remove plugin files"); + } + + removeProcess.destroy(); + }); + + removeProcess.running = true; + } + + // Enable a plugin + function enablePlugin(pluginId) { + if (PluginRegistry.isPluginEnabled(pluginId)) { + Logger.w("PluginService", "Plugin already enabled:", pluginId); + return true; + } + + if (!PluginRegistry.isPluginDownloaded(pluginId)) { + Logger.e("PluginService", "Cannot enable: plugin not downloaded:", pluginId); + return false; + } + + PluginRegistry.setPluginEnabled(pluginId, true); + loadPlugin(pluginId); + updatePluginInAvailable(pluginId, { + enabled: true + }); + root.pluginEnabled(pluginId); + return true; + } + + // Disable a plugin + function disablePlugin(pluginId) { + if (!PluginRegistry.isPluginEnabled(pluginId)) { + Logger.w("PluginService", "Plugin already disabled:", pluginId); + return true; + } + + PluginRegistry.setPluginEnabled(pluginId, false); + unloadPlugin(pluginId); + updatePluginInAvailable(pluginId, { + enabled: false + }); + root.pluginDisabled(pluginId); + return true; + } + + // Load a plugin + function loadPlugin(pluginId) { + if (root.loadedPlugins[pluginId]) { + Logger.w("PluginService", "Plugin already loaded:", pluginId); + return; + } + + var manifest = PluginRegistry.getPluginManifest(pluginId); + if (!manifest) { + Logger.e("PluginService", "Cannot load: manifest not found for:", pluginId); + return; + } + + var pluginDir = PluginRegistry.getPluginDir(pluginId); + + Logger.i("PluginService", "Loading plugin:", pluginId); + + // Create plugin API object + var pluginApi = createPluginAPI(pluginId, manifest); + + // Load main component if provides bar widget + if (manifest.provides.barWidget && manifest.entryPoints.main) { + var mainPath = pluginDir + "/" + manifest.entryPoints.main; + var component = Qt.createComponent("file://" + mainPath); + + if (component.status === Component.Ready) { + // Don't instantiate yet - BarWidgetRegistry will do that + // Just register the component + root.loadedPlugins[pluginId] = { + component: component, + instance: null, + api: pluginApi, + manifest: manifest + }; + + // Register with BarWidgetRegistry + if (manifest.provides.barWidget) { + BarWidgetRegistry.registerPluginWidget(pluginId, component, manifest.metadata); + } + + Logger.i("PluginService", "Loaded plugin:", pluginId); + root.pluginLoaded(pluginId); + } else if (component.status === Component.Error) { + Logger.e("PluginService", "Failed to load plugin component:", component.errorString()); + } + } else { + Logger.d("PluginService", "Plugin", pluginId, "does not provide a bar widget"); + } + } + + // Unload a plugin + function unloadPlugin(pluginId) { + var plugin = root.loadedPlugins[pluginId]; + if (!plugin) { + Logger.w("PluginService", "Plugin not loaded:", pluginId); + return; + } + + Logger.i("PluginService", "Unloading plugin:", pluginId); + + // Unregister from BarWidgetRegistry + if (plugin.manifest.provides.barWidget) { + BarWidgetRegistry.unregisterPluginWidget(pluginId); + } + + // Destroy instance if any + if (plugin.instance) { + plugin.instance.destroy(); + } + + delete root.loadedPlugins[pluginId]; + root.pluginUnloaded(pluginId); + Logger.i("PluginService", "Unloaded plugin:", pluginId); + } + + // Create plugin API object + function createPluginAPI(pluginId, manifest) { + var pluginDir = PluginRegistry.getPluginDir(pluginId); + var settingsFile = PluginRegistry.getPluginSettingsFile(pluginId); + + var api = Qt.createQmlObject(` + import QtQuick + + QtObject { + // Plugin-specific + readonly property string pluginId: "${pluginId}" + readonly property string pluginDir: "${pluginDir}" + property var pluginSettings: ({}) + + // IPC handlers storage + property var ipcHandlers: ({}) + + // Functions will be bound below + property var saveSettings: null + property var openPanel: null + property var closePanel: null + property var showToast: null + property var registerIPC: null + } + `, root, "PluginAPI_" + pluginId); + + // Load plugin settings + loadPluginSettings(pluginId, function (settings) { + api.pluginSettings = settings; + }); + + // Bind functions + api.saveSettings = function () { + savePluginSettings(pluginId, api.pluginSettings); + }; + + api.openPanel = function (panelId) { + // TODO: Implement panel opening + Logger.d("PluginAPI", "openPanel:", panelId); + }; + + api.closePanel = function () { + // TODO: Implement panel closing + Logger.d("PluginAPI", "closePanel"); + }; + + api.showToast = function (message) { + ToastService.show(message); + }; + + api.registerIPC = function (name, handler) { + if (!name || typeof handler !== 'function') { + Logger.e("PluginAPI", "Invalid IPC registration: name and handler function required"); + return false; + } + + api.ipcHandlers[name] = handler; + Logger.i("PluginAPI", "Registered IPC handler for plugin", pluginId, ":", name); + return true; + }; + + return api; + } + + // Load plugin settings + function loadPluginSettings(pluginId, callback) { + var settingsFile = PluginRegistry.getPluginSettingsFile(pluginId); + + var readProcess = Qt.createQmlObject(` + import QtQuick + import Quickshell.Io + Process { + command: ["cat", "${settingsFile}"] + stdout: StdioCollector {} + } + `, root, "ReadSettings_" + pluginId); + + readProcess.exited.connect(function (exitCode) { + if (exitCode === 0) { + try { + var settings = JSON.parse(readProcess.stdout.text); + callback(settings); + } catch (e) { + Logger.w("PluginService", "Failed to parse settings for", pluginId, "- using defaults"); + callback({}); + } + } else { + // File doesn't exist - use defaults + callback({}); + } + + readProcess.destroy(); + }); + + readProcess.running = true; + } + + // Save plugin settings + function savePluginSettings(pluginId, settings) { + var settingsFile = PluginRegistry.getPluginSettingsFile(pluginId); + var settingsJson = JSON.stringify(settings, null, 2); + + var writeProcess = Qt.createQmlObject(` + import QtQuick + import Quickshell.Io + Process { + command: ["sh", "-c", "echo '${settingsJson}' > '${settingsFile}'"] + } + `, root, "WriteSettings_" + pluginId); + + writeProcess.exited.connect(function (exitCode) { + if (exitCode === 0) { + Logger.d("PluginService", "Saved settings for:", pluginId); + } else { + Logger.e("PluginService", "Failed to save settings for:", pluginId); + } + + writeProcess.destroy(); + }); + + writeProcess.running = true; + } + + // Load manifest from file + function loadManifest(manifestPath, callback) { + var readProcess = Qt.createQmlObject(` + import QtQuick + import Quickshell.Io + Process { + command: ["cat", "${manifestPath}"] + stdout: StdioCollector {} + } + `, root, "ReadManifest_" + Date.now()); + + readProcess.exited.connect(function (exitCode) { + if (exitCode === 0) { + try { + var manifest = JSON.parse(readProcess.stdout.text); + callback(true, manifest); + } catch (e) { + Logger.e("PluginService", "Failed to parse manifest:", e); + callback(false, null); + } + } else { + Logger.e("PluginService", "Failed to read manifest at:", manifestPath); + callback(false, null); + } + + readProcess.destroy(); + }); + + readProcess.running = true; + } + + // Update plugin metadata in available plugins list + function updatePluginInAvailable(pluginId, updates) { + for (var i = 0; i < root.availablePlugins.length; i++) { + if (root.availablePlugins[i].id === pluginId) { + for (var key in updates) { + root.availablePlugins[i][key] = updates[key]; + } + root.availablePluginsUpdated(); + break; + } + } + } + + // Get plugin API for a loaded plugin + function getPluginAPI(pluginId) { + return root.loadedPlugins[pluginId]?.api || null; + } + + // Check if plugin is loaded + function isPluginLoaded(pluginId) { + return !!root.loadedPlugins[pluginId]; + } +} diff --git a/Services/UI/BarWidgetRegistry.qml b/Services/UI/BarWidgetRegistry.qml index 1a74d982d..e6d7e1cd1 100644 --- a/Services/UI/BarWidgetRegistry.qml +++ b/Services/UI/BarWidgetRegistry.qml @@ -353,4 +353,60 @@ Singleton { function widgetHasUserSettings(id) { return (widgetMetadata[id] !== undefined) && (widgetMetadata[id].allowUserSettings === true); } + + // ------------------------------ + // Plugin widget registration + + // Track plugin widgets separately + property var pluginWidgets: ({}) + property var pluginWidgetMetadata: ({}) + + // Register a plugin widget + function registerPluginWidget(pluginId, component, metadata) { + if (!pluginId || !component) { + Logger.e("BarWidgetRegistry", "Cannot register plugin widget: invalid parameters"); + return false; + } + + // Add plugin: prefix to avoid conflicts with core widgets + var widgetId = "plugin:" + pluginId; + + pluginWidgets[widgetId] = component; + pluginWidgetMetadata[widgetId] = metadata || {}; + + // Also add to main widgets object for unified access + widgets[widgetId] = component; + widgetMetadata[widgetId] = metadata || {}; + + Logger.i("BarWidgetRegistry", "Registered plugin widget:", widgetId); + return true; + } + + // Unregister a plugin widget + function unregisterPluginWidget(pluginId) { + var widgetId = "plugin:" + pluginId; + + if (!pluginWidgets[widgetId]) { + Logger.w("BarWidgetRegistry", "Plugin widget not registered:", widgetId); + return false; + } + + delete pluginWidgets[widgetId]; + delete pluginWidgetMetadata[widgetId]; + delete widgets[widgetId]; + delete widgetMetadata[widgetId]; + + Logger.i("BarWidgetRegistry", "Unregistered plugin widget:", widgetId); + return true; + } + + // Check if a widget is a plugin widget + function isPluginWidget(id) { + return id.startsWith("plugin:"); + } + + // Get list of plugin widget IDs + function getPluginWidgets() { + return Object.keys(pluginWidgets); + } } diff --git a/shell.qml b/shell.qml index 00fd3f157..46a85a2e6 100644 --- a/shell.qml +++ b/shell.qml @@ -42,6 +42,9 @@ ShellRoot { Component.onCompleted: { Logger.i("Shell", "---------------------------"); Logger.i("Shell", "Noctalia Hello!"); + + // Initialize plugin system early so Settings can validate plugin widgets + PluginRegistry.init(); } Connections { From 3f830d0c7390174444b4a5e2c59fd1c27c73b214 Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Sun, 30 Nov 2025 21:04:52 -0500 Subject: [PATCH 02/28] PluginSystem: panel opening --- Modules/Bar/Extras/BarWidgetLoader.qml | 12 ++++ Modules/Panels/Plugins/PluginPanelSlot.qml | 30 +++++--- Services/Noctalia/PluginService.qml | 84 +++++++++++++++++++--- 3 files changed, 109 insertions(+), 17 deletions(-) diff --git a/Modules/Bar/Extras/BarWidgetLoader.qml b/Modules/Bar/Extras/BarWidgetLoader.qml index 99ba181ef..7263a5824 100644 --- a/Modules/Bar/Extras/BarWidgetLoader.qml +++ b/Modules/Bar/Extras/BarWidgetLoader.qml @@ -1,6 +1,7 @@ import QtQuick import Quickshell import qs.Commons +import qs.Services.Noctalia import qs.Services.UI Item { @@ -58,6 +59,17 @@ Item { }); } + // Inject plugin API for plugin widgets + if (BarWidgetRegistry.isPluginWidget(widgetId)) { + var pluginId = widgetId.replace("plugin:", ""); + var api = PluginService.getPluginAPI(pluginId); + if (api && item.hasOwnProperty("pluginApi")) { + // Inject API into widget + item.pluginApi = api; + Logger.d("BarWidgetLoader", "Injected plugin API for", widgetId); + } + } + // Register this widget instance with BarService BarService.registerWidget(widgetScreen.name, section, widgetId, sectionIndex, item); diff --git a/Modules/Panels/Plugins/PluginPanelSlot.qml b/Modules/Panels/Plugins/PluginPanelSlot.qml index 61d70409a..c5e1fdefd 100644 --- a/Modules/Panels/Plugins/PluginPanelSlot.qml +++ b/Modules/Panels/Plugins/PluginPanelSlot.qml @@ -20,6 +20,9 @@ SmartPanel { // Plugin instance property var pluginInstance: null + // Reference to the plugin content loader (set when panel content is created) + property var contentLoader: null + // Panel content is dynamically loaded panelContent: Component { Item { @@ -50,6 +53,9 @@ SmartPanel { } Component.onCompleted: { + // Store reference to the loader so loadPluginPanel can access it + root.contentLoader = pluginContentLoader; + // Load plugin panel content if assigned if (root.currentPluginId !== "") { root.loadPluginPanel(root.currentPluginId); @@ -76,6 +82,12 @@ SmartPanel { return false; } + // Check if loader is available + if (!root.contentLoader) { + Logger.e("PluginPanelSlot", "Content loader not available yet"); + return false; + } + var pluginDir = PluginRegistry.getPluginDir(pluginId); var panelPath = pluginDir + "/" + plugin.manifest.entryPoints.panel; @@ -89,16 +101,16 @@ SmartPanel { var api = PluginService.getPluginAPI(pluginId); // Create instance with API - pluginContentLoader.active = true; - pluginContentLoader.sourceComponent = component; + root.contentLoader.active = true; + root.contentLoader.sourceComponent = component; - if (pluginContentLoader.item) { + if (root.contentLoader.item) { // Inject plugin API - if (pluginContentLoader.item.hasOwnProperty("pluginApi")) { - pluginContentLoader.item.pluginApi = api; + if (root.contentLoader.item.hasOwnProperty("pluginApi")) { + root.contentLoader.item.pluginApi = api; } - root.pluginInstance = pluginContentLoader.item; + root.pluginInstance = root.contentLoader.item; root.currentPluginId = pluginId; Logger.i("PluginPanelSlot", "Panel loaded for:", pluginId); @@ -120,8 +132,10 @@ SmartPanel { Logger.i("PluginPanelSlot", "Unloading panel from slot", root.slotNumber); - pluginContentLoader.active = false; - pluginContentLoader.sourceComponent = null; + if (root.contentLoader) { + root.contentLoader.active = false; + root.contentLoader.sourceComponent = null; + } root.pluginInstance = null; root.currentPluginId = ""; } diff --git a/Services/Noctalia/PluginService.qml b/Services/Noctalia/PluginService.qml index df8b5f04d..9996ff4be 100644 --- a/Services/Noctalia/PluginService.qml +++ b/Services/Noctalia/PluginService.qml @@ -316,9 +316,9 @@ Singleton { var pluginApi = createPluginAPI(pluginId, manifest); // Load main component if provides bar widget - if (manifest.provides.barWidget && manifest.entryPoints.main) { - var mainPath = pluginDir + "/" + manifest.entryPoints.main; - var component = Qt.createComponent("file://" + mainPath); + if (manifest.provides.barWidget && manifest.entryPoints.barWidget) { + var path = pluginDir + "/" + manifest.entryPoints.barWidget; + var component = Qt.createComponent("file://" + path); if (component.status === Component.Ready) { // Don't instantiate yet - BarWidgetRegistry will do that @@ -406,14 +406,26 @@ Singleton { savePluginSettings(pluginId, api.pluginSettings); }; - api.openPanel = function (panelId) { - // TODO: Implement panel opening - Logger.d("PluginAPI", "openPanel:", panelId); + api.openPanel = function (screen) { + // Open this plugin's panel on the specified screen + if (!screen) { + Logger.w("PluginAPI", "No screen available for opening panel"); + return false; + } + return openPluginPanel(pluginId, screen); }; - api.closePanel = function () { - // TODO: Implement panel closing - Logger.d("PluginAPI", "closePanel"); + api.closePanel = function (screen) { + // Close this plugin's panel (find which slot it's in and close it) + for (var slotNum = 1; slotNum <= 2; slotNum++) { + var panelName = "pluginPanel" + slotNum; + var panel = PanelService.getPanel(panelName, screen); + if (panel && panel.currentPluginId === pluginId) { + panel.close(); + return true; + } + } + return false; }; api.showToast = function (message) { @@ -546,4 +558,58 @@ Singleton { function isPluginLoaded(pluginId) { return !!root.loadedPlugins[pluginId]; } + + // Open a plugin's panel (finds a free slot and loads the panel) + function openPluginPanel(pluginId, screen) { + if (!isPluginLoaded(pluginId)) { + Logger.w("PluginService", "Cannot open panel: plugin not loaded:", pluginId); + return false; + } + + var plugin = root.loadedPlugins[pluginId]; + if (!plugin || !plugin.manifest || !plugin.manifest.provides.panel) { + Logger.w("PluginService", "Plugin does not provide a panel:", pluginId); + return false; + } + + // Try to find the plugin panel slot (pluginPanel1 or pluginPanel2) + // Try slot 1 first, then slot 2 + for (var slotNum = 1; slotNum <= 2; slotNum++) { + var panelName = "pluginPanel" + slotNum; + var panel = PanelService.getPanel(panelName, screen); + + if (panel) { + // If this slot is already showing this plugin's panel, toggle it + if (panel.currentPluginId === pluginId) { + panel.toggle(); + return true; + } + + // If this slot is empty, use it + if (panel.currentPluginId === "") { + // Open the panel first so the loader gets created + panel.open(); + // Wait a brief moment for the panel to be fully created + Qt.callLater(function () { + panel.loadPluginPanel(pluginId); + }); + return true; + } + } + } + + // If both slots are occupied, use slot 1 (replace existing) + var panel1 = PanelService.getPanel("pluginPanel1", screen); + if (panel1) { + panel1.unloadPluginPanel(); + panel1.open(); + Qt.callLater(function () { + panel1.loadPluginPanel(pluginId); + }); + return true; + } + + Logger.e("PluginService", "Failed to find plugin panel slot"); + return false; + } } From 59e5d44185125f2b963ec08ad9c9cf04e5655148 Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Sun, 30 Nov 2025 21:24:01 -0500 Subject: [PATCH 03/28] PluginSystem: proper sizing --- Modules/Panels/Plugins/PluginPanelSlot.qml | 26 +++++++++++++++++----- Services/Control/IPCService.qml | 2 +- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/Modules/Panels/Plugins/PluginPanelSlot.qml b/Modules/Panels/Plugins/PluginPanelSlot.qml index c5e1fdefd..c1d106cb0 100644 --- a/Modules/Panels/Plugins/PluginPanelSlot.qml +++ b/Modules/Panels/Plugins/PluginPanelSlot.qml @@ -32,11 +32,27 @@ SmartPanel { readonly property var maskRegion: pluginContentItem // Panel properties expected by SmartPanel - property bool allowAttach: true - property real topPadding: 0 - property real bottomPadding: 0 - property real leftPadding: 0 - property real rightPadding: 0 + property bool allowAttach: { + if (pluginContentLoader.item && pluginContentLoader.item.allowAttach !== undefined) { + return pluginContentLoader.item.allowAttach; + } + return true; + } + // Expose preferred dimensions from plugin panel content + // Only define these if the plugin provides them + property var contentPreferredWidth: { + if (pluginContentLoader.item && pluginContentLoader.item.contentPreferredWidth !== undefined && pluginContentLoader.item.contentPreferredWidth > 0) { + return pluginContentLoader.item.contentPreferredWidth; + } + return undefined; + } + + property var contentPreferredHeight: { + if (pluginContentLoader.item && pluginContentLoader.item.contentPreferredHeight !== undefined && pluginContentLoader.item.contentPreferredHeight > 0) { + return pluginContentLoader.item.contentPreferredHeight; + } + return undefined; + } anchors.fill: parent diff --git a/Services/Control/IPCService.qml b/Services/Control/IPCService.qml index 15b254ed4..7727facd6 100644 --- a/Services/Control/IPCService.qml +++ b/Services/Control/IPCService.qml @@ -388,7 +388,7 @@ Item { } // ------------------------------------------------------------------- - // Plugin IPC namespace + // Plugins IPC namespace // ------------------------------------------------------------------- IpcHandler { target: "plugins" From 90ba6ac6b489a3ae4268bd5027246293c93de3de Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Sun, 30 Nov 2025 21:35:01 -0500 Subject: [PATCH 04/28] Renaming maskRegion to geometryPlaceholder to avoid confusion --- Modules/MainScreen/SmartPanel.qml | 14 +++++++------- Modules/Panels/Plugins/PluginPanelSlot.qml | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Modules/MainScreen/SmartPanel.qml b/Modules/MainScreen/SmartPanel.qml index 084452e42..0bb2fc9e3 100644 --- a/Modules/MainScreen/SmartPanel.qml +++ b/Modules/MainScreen/SmartPanel.qml @@ -101,8 +101,8 @@ Item { function onCtrlKPressed() { } - // Expose panel region for click-through mask - readonly property var panelRegion: panelContent.maskRegion + // Expose panel region for background rendering + readonly property var panelRegion: panelContent.geometryPlaceholder readonly property string barPosition: Settings.data.bar.position readonly property bool barIsVertical: barPosition === "left" || barPosition === "right" @@ -606,7 +606,7 @@ Item { if (!running && duration === 0) { if (root.isClosing && root.opacity === 0.0) { root.opacityFadeComplete = true; - var shouldFinalizeNow = panelContent.maskRegion && !panelContent.maskRegion.shouldAnimateWidth && !panelContent.maskRegion.shouldAnimateHeight; + var shouldFinalizeNow = panelContent.geometryPlaceholder && !panelContent.geometryPlaceholder.shouldAnimateWidth && !panelContent.geometryPlaceholder.shouldAnimateHeight; if (shouldFinalizeNow) { Logger.d("SmartPanel", "Zero-duration opacity + no size animation - finalizing", root.objectName); Qt.callLater(root.finalizeClose); @@ -624,12 +624,12 @@ Item { root.opacityFadeComplete = true; // If no size animation will run (centered attached panels only), finalize immediately // Detached panels (allowAttach === false) should always animate from top - var shouldFinalizeNow = panelContent.maskRegion && !panelContent.maskRegion.shouldAnimateWidth && !panelContent.maskRegion.shouldAnimateHeight; + var shouldFinalizeNow = panelContent.geometryPlaceholder && !panelContent.geometryPlaceholder.shouldAnimateWidth && !panelContent.geometryPlaceholder.shouldAnimateHeight; if (shouldFinalizeNow) { Logger.d("SmartPanel", "No animation - finalizing immediately", root.objectName); Qt.callLater(root.finalizeClose); } else { - Logger.d("SmartPanel", "Animation will run - waiting for size animation", root.objectName, "shouldAnimateHeight:", panelContent.maskRegion.shouldAnimateHeight, "shouldAnimateWidth:", panelContent.maskRegion.shouldAnimateWidth); + Logger.d("SmartPanel", "Animation will run - waiting for size animation", root.objectName, "shouldAnimateHeight:", panelContent.geometryPlaceholder.shouldAnimateHeight, "shouldAnimateWidth:", panelContent.geometryPlaceholder.shouldAnimateWidth); } } // When opacity fade completes during open, stop watchdog else if (!running && root.isPanelVisible && root.opacity === 1.0) { @@ -715,8 +715,8 @@ Item { readonly property bool touchingLeftBar: allowAttachToBar && root.barPosition === "left" && root.barIsVertical && Math.abs(panelBackground.x - (root.barMarginH + Style.barHeight)) <= 1 readonly property bool touchingRightBar: allowAttachToBar && root.barPosition === "right" && root.barIsVertical && Math.abs((panelBackground.x + panelBackground.width) - (root.width - root.barMarginH - Style.barHeight)) <= 1 - // Expose panelBackground for mask region - property alias maskRegion: panelBackground + // Expose panelBackground for geometry placeholder + property alias geometryPlaceholder: panelBackground // The actual panel background - provides geometry for PanelBackground rendering Item { diff --git a/Modules/Panels/Plugins/PluginPanelSlot.qml b/Modules/Panels/Plugins/PluginPanelSlot.qml index c1d106cb0..522a807e7 100644 --- a/Modules/Panels/Plugins/PluginPanelSlot.qml +++ b/Modules/Panels/Plugins/PluginPanelSlot.qml @@ -28,8 +28,8 @@ SmartPanel { Item { id: panelContainer - // Required by SmartPanel for click-through mask - readonly property var maskRegion: pluginContentItem + // Required by SmartPanel for background rendering geometry + readonly property var geometryPlaceholder: pluginContentItem // Panel properties expected by SmartPanel property bool allowAttach: { From e705544c0e4d03ef6d9d005ffd6a6d42dac2718a Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Sun, 30 Nov 2025 21:50:34 -0500 Subject: [PATCH 05/28] PluginSystem: respect allowAttach --- Modules/MainScreen/SmartPanel.qml | 8 +++++++- Modules/Panels/Plugins/PluginPanelSlot.qml | 7 +------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Modules/MainScreen/SmartPanel.qml b/Modules/MainScreen/SmartPanel.qml index 0bb2fc9e3..b579225ff 100644 --- a/Modules/MainScreen/SmartPanel.qml +++ b/Modules/MainScreen/SmartPanel.qml @@ -691,7 +691,13 @@ Item { anchors.fill: parent // Screen-dependent attachment properties - readonly property bool allowAttach: Settings.data.ui.panelsAttachedToBar || root.forceAttachToBar + // Allow panel content to override allowAttach (e.g., plugin panels) + readonly property bool allowAttach: { + if (contentLoader.item && contentLoader.item.allowAttach !== undefined) { + return contentLoader.item.allowAttach; + } + return Settings.data.ui.panelsAttachedToBar || root.forceAttachToBar; + } readonly property bool allowAttachToBar: { if (!(Settings.data.ui.panelsAttachedToBar || root.forceAttachToBar) || Settings.data.bar.backgroundOpacity < 1.0) { return false; diff --git a/Modules/Panels/Plugins/PluginPanelSlot.qml b/Modules/Panels/Plugins/PluginPanelSlot.qml index 522a807e7..392733745 100644 --- a/Modules/Panels/Plugins/PluginPanelSlot.qml +++ b/Modules/Panels/Plugins/PluginPanelSlot.qml @@ -32,12 +32,7 @@ SmartPanel { readonly property var geometryPlaceholder: pluginContentItem // Panel properties expected by SmartPanel - property bool allowAttach: { - if (pluginContentLoader.item && pluginContentLoader.item.allowAttach !== undefined) { - return pluginContentLoader.item.allowAttach; - } - return true; - } + readonly property bool allowAttach: (pluginContentLoader.item && pluginContentLoader.item.allowAttach !== undefined) ? pluginContentLoader.item.allowAttach : true // Expose preferred dimensions from plugin panel content // Only define these if the plugin provides them property var contentPreferredWidth: { From e2731b0d1f731cc61638b0f4412a4fc8065c5e55 Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Mon, 1 Dec 2025 17:22:26 -0500 Subject: [PATCH 06/28] wip --- Services/Noctalia/PluginService.qml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Services/Noctalia/PluginService.qml b/Services/Noctalia/PluginService.qml index 9996ff4be..cd7631a94 100644 --- a/Services/Noctalia/PluginService.qml +++ b/Services/Noctalia/PluginService.qml @@ -484,11 +484,15 @@ Singleton { var settingsFile = PluginRegistry.getPluginSettingsFile(pluginId); var settingsJson = JSON.stringify(settings, null, 2); + // Write JSON directly using printf to avoid QML template escaping issues + // Escape backslashes and single quotes for shell safety + var escapedJson = settingsJson.replace(/\\/g, '\\\\').replace(/'/g, "'\\''"); + var writeProcess = Qt.createQmlObject(` import QtQuick import Quickshell.Io Process { - command: ["sh", "-c", "echo '${settingsJson}' > '${settingsFile}'"] + command: ["sh", "-c", "printf '%s' '${escapedJson}' > '${settingsFile}'"] } `, root, "WriteSettings_" + pluginId); From 34f84afcd1481508925ab173f01333d59580738b Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Mon, 1 Dec 2025 20:44:26 -0500 Subject: [PATCH 07/28] PluginSystem: simplified IPC calls creation --- Services/Control/IPCService.qml | 40 --------- Services/Noctalia/PluginService.qml | 133 +++++++++++++++------------- shell.qml | 11 ++- 3 files changed, 82 insertions(+), 102 deletions(-) diff --git a/Services/Control/IPCService.qml b/Services/Control/IPCService.qml index 7727facd6..481417102 100644 --- a/Services/Control/IPCService.qml +++ b/Services/Control/IPCService.qml @@ -387,46 +387,6 @@ Item { } } - // ------------------------------------------------------------------- - // Plugins IPC namespace - // ------------------------------------------------------------------- - IpcHandler { - target: "plugins" - - // Dynamic plugin IPC calls - // Usage: qs -c noctalia-shell ipc call plugins invoke pluginId actionName [args] - // where args is an optional string that the plugin can parse - function invoke(pluginId: string, actionName: string, args: string) { - Logger.d("IPC", "Plugin IPC call:", pluginId, actionName, args); - - // Check if plugin is loaded - if (!PluginService.isPluginLoaded(pluginId)) { - Logger.w("IPC", "Plugin not loaded:", pluginId); - return false; - } - - // Get plugin API - var api = PluginService.getPluginAPI(pluginId); - if (!api) { - Logger.w("IPC", "Plugin API not found:", pluginId); - return false; - } - - // Check if plugin has registered the IPC action - if (api.ipcHandlers && api.ipcHandlers[actionName]) { - try { - return api.ipcHandlers[actionName](args || ""); - } catch (e) { - Logger.e("IPC", "Plugin IPC call failed:", e); - return false; - } - } else { - Logger.w("IPC", "Plugin", pluginId, "has no IPC action:", actionName); - return false; - } - } - } - // ------------------------------------------------------------------- // Queue an IPC panel operation - will execute when screen is detected // ------------------------------------------------------------------- diff --git a/Services/Noctalia/PluginService.qml b/Services/Noctalia/PluginService.qml index cd7631a94..d0cbead40 100644 --- a/Services/Noctalia/PluginService.qml +++ b/Services/Noctalia/PluginService.qml @@ -29,6 +29,9 @@ Singleton { property bool initialized: false property bool pluginsFullyLoaded: false + // Plugin container from shell.qml (for placing Main instances in graphics scene) + property var pluginContainer: null + // Listen for PluginRegistry to finish loading Connections { target: PluginRegistry @@ -315,34 +318,65 @@ Singleton { // Create plugin API object var pluginApi = createPluginAPI(pluginId, manifest); - // Load main component if provides bar widget - if (manifest.provides.barWidget && manifest.entryPoints.barWidget) { - var path = pluginDir + "/" + manifest.entryPoints.barWidget; - var component = Qt.createComponent("file://" + path); + // Initialize plugin entry with API and manifest + root.loadedPlugins[pluginId] = { + barWidgetComponent: null, + mainInstance: null, + api: pluginApi, + manifest: manifest + }; - if (component.status === Component.Ready) { - // Don't instantiate yet - BarWidgetRegistry will do that - // Just register the component - root.loadedPlugins[pluginId] = { - component: component, - instance: null, - api: pluginApi, - manifest: manifest - }; + // Load Main.qml entry point if it exists + if (manifest.entryPoints && manifest.entryPoints.main) { + var mainPath = pluginDir + "/" + manifest.entryPoints.main; + var mainComponent = Qt.createComponent("file://" + mainPath); - // Register with BarWidgetRegistry - if (manifest.provides.barWidget) { - BarWidgetRegistry.registerPluginWidget(pluginId, component, manifest.metadata); + if (mainComponent.status === Component.Ready) { + // Get the plugin container from shell.qml (must be in graphics scene) + if (!root.pluginContainer) { + Logger.e("PluginService", "Plugin container not set. Shell must set PluginService.pluginContainer."); + return; } - Logger.i("PluginService", "Loaded plugin:", pluginId); - root.pluginLoaded(pluginId); - } else if (component.status === Component.Error) { - Logger.e("PluginService", "Failed to load plugin component:", component.errorString()); + // Instantiate Main.qml with container as parent (places it in graphics scene) + var mainInstance = mainComponent.createObject(root.pluginContainer); + + if (mainInstance) { + // Set pluginApi property after creation + if (mainInstance.hasOwnProperty('pluginApi')) { + mainInstance.pluginApi = pluginApi; + } else { + Logger.w("PluginService", "Main.qml for", pluginId, "should declare 'property var pluginApi: null'"); + } + + root.loadedPlugins[pluginId].mainInstance = mainInstance; + Logger.i("PluginService", "Loaded Main.qml for plugin:", pluginId); + } else { + Logger.e("PluginService", "Failed to instantiate Main.qml for:", pluginId); + } + } else if (mainComponent.status === Component.Error) { + Logger.e("PluginService", "Failed to load Main.qml:", mainComponent.errorString()); } - } else { - Logger.d("PluginService", "Plugin", pluginId, "does not provide a bar widget"); } + + // Load bar widget component if provided (don't instantiate - BarWidgetRegistry will do that) + if (manifest.provides.barWidget && manifest.entryPoints.barWidget) { + var widgetPath = pluginDir + "/" + manifest.entryPoints.barWidget; + var widgetComponent = Qt.createComponent("file://" + widgetPath); + + if (widgetComponent.status === Component.Ready) { + root.loadedPlugins[pluginId].barWidgetComponent = widgetComponent; + + // Register with BarWidgetRegistry + BarWidgetRegistry.registerPluginWidget(pluginId, widgetComponent, manifest.metadata); + Logger.i("PluginService", "Loaded bar widget for plugin:", pluginId); + } else if (widgetComponent.status === Component.Error) { + Logger.e("PluginService", "Failed to load bar widget component:", widgetComponent.errorString()); + } + } + + Logger.i("PluginService", "Plugin loaded:", pluginId); + root.pluginLoaded(pluginId); } // Unload a plugin @@ -360,9 +394,9 @@ Singleton { BarWidgetRegistry.unregisterPluginWidget(pluginId); } - // Destroy instance if any - if (plugin.instance) { - plugin.instance.destroy(); + // Destroy Main instance if any + if (plugin.mainInstance) { + plugin.mainInstance.destroy(); } delete root.loadedPlugins[pluginId]; @@ -391,8 +425,6 @@ Singleton { property var saveSettings: null property var openPanel: null property var closePanel: null - property var showToast: null - property var registerIPC: null } `, root, "PluginAPI_" + pluginId); @@ -428,21 +460,6 @@ Singleton { return false; }; - api.showToast = function (message) { - ToastService.show(message); - }; - - api.registerIPC = function (name, handler) { - if (!name || typeof handler !== 'function') { - Logger.e("PluginAPI", "Invalid IPC registration: name and handler function required"); - return false; - } - - api.ipcHandlers[name] = handler; - Logger.i("PluginAPI", "Registered IPC handler for plugin", pluginId, ":", name); - return true; - }; - return api; } @@ -484,29 +501,23 @@ Singleton { var settingsFile = PluginRegistry.getPluginSettingsFile(pluginId); var settingsJson = JSON.stringify(settings, null, 2); - // Write JSON directly using printf to avoid QML template escaping issues - // Escape backslashes and single quotes for shell safety - var escapedJson = settingsJson.replace(/\\/g, '\\\\').replace(/'/g, "'\\''"); + // Use heredoc delimiter pattern to avoid all escaping issues + var delimiter = "PLUGIN_SETTINGS_EOF_" + Math.random().toString(36).substr(2, 9); + var fileEsc = settingsFile.replace(/'/g, "'\\''"); - var writeProcess = Qt.createQmlObject(` - import QtQuick - import Quickshell.Io - Process { - command: ["sh", "-c", "printf '%s' '${escapedJson}' > '${settingsFile}'"] - } - `, root, "WriteSettings_" + pluginId); + // Get parent directory and ensure it exists + var settingsDir = settingsFile.substring(0, settingsFile.lastIndexOf('/')); + var dirEsc = settingsDir.replace(/'/g, "'\\''"); - writeProcess.exited.connect(function (exitCode) { - if (exitCode === 0) { - Logger.d("PluginService", "Saved settings for:", pluginId); - } else { - Logger.e("PluginService", "Failed to save settings for:", pluginId); - } + // Build the shell command with heredoc (create dir first) + var writeCmd = "mkdir -p '" + dirEsc + "' && cat > '" + fileEsc + "' << '" + delimiter + "'\n" + settingsJson + "\n" + delimiter + "\n"; - writeProcess.destroy(); - }); + Logger.d("PluginService", "Saving settings to:", settingsFile); + Logger.d("PluginService", "Settings JSON:", settingsJson); - writeProcess.running = true; + // Use Quickshell.execDetached to execute the command (use array syntax) + var pid = Quickshell.execDetached(["sh", "-c", writeCmd]); + Logger.d("PluginService", "Write process started, PID:", pid); } // Load manifest from file diff --git a/shell.qml b/shell.qml index 46a85a2e6..164a8e07d 100644 --- a/shell.qml +++ b/shell.qml @@ -117,8 +117,17 @@ ShellRoot { LockScreen {} - // IPCService is treated as a service but it's actually an Item that needs to exists in the shell. + // IPCService is treated as a service but it must be in graphics scene. IPCService {} + + // Container for plugins Main.qml instances (must be in graphics scene) + Item { + id: pluginContainer + visible: false + Component.onCompleted: { + PluginService.pluginContainer = pluginContainer; + } + } } } From ec932e949e280e9c5d69bd92199da4efad4d5517 Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Mon, 1 Dec 2025 20:55:22 -0500 Subject: [PATCH 08/28] PluginSystem: ensure settings rebinds when changed. --- Services/Noctalia/PluginService.qml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Services/Noctalia/PluginService.qml b/Services/Noctalia/PluginService.qml index d0cbead40..10866e320 100644 --- a/Services/Noctalia/PluginService.qml +++ b/Services/Noctalia/PluginService.qml @@ -436,6 +436,10 @@ Singleton { // Bind functions api.saveSettings = function () { savePluginSettings(pluginId, api.pluginSettings); + + // Replace the entire pluginSettings object to trigger QML property bindings + // Make a shallow copy so bindings detect the change + api.pluginSettings = Object.assign({}, api.pluginSettings); }; api.openPanel = function (screen) { From 757ecd6b8519947b50ae1f19edc37990e0d4e86c Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Mon, 1 Dec 2025 21:28:03 -0500 Subject: [PATCH 09/28] PluginSystem: ui --- Modules/Panels/Settings/Tabs/PluginsTab.qml | 107 ++++++++++---------- 1 file changed, 54 insertions(+), 53 deletions(-) diff --git a/Modules/Panels/Settings/Tabs/PluginsTab.qml b/Modules/Panels/Settings/Tabs/PluginsTab.qml index 8218262a9..1cc1ea9d0 100644 --- a/Modules/Panels/Settings/Tabs/PluginsTab.qml +++ b/Modules/Panels/Settings/Tabs/PluginsTab.qml @@ -42,59 +42,53 @@ ColumnLayout { return plugins; } - delegate: RowLayout { - spacing: Style.marginM + delegate: NBox { Layout.fillWidth: true + implicitHeight: rowLayout.implicitHeight + Style.marginL * 2 + color: Color.mSurface - NIcon { - icon: "plugin" - pointSize: Style.fontSizeL - } + RowLayout { + id: rowLayout + anchors.fill: parent + anchors.margins: Style.marginL + spacing: Style.marginM - ColumnLayout { - spacing: 2 - Layout.fillWidth: true - - NText { - text: modelData.name - font.weight: Font.Medium - color: Color.mOnSurface + NLabel { + label: modelData.name + description: modelData.description } - NText { - text: modelData.description - font.pointSize: Style.fontSizeS - color: Color.mOnSurfaceVariant - } - } - - NToggle { - checked: PluginRegistry.isPluginEnabled(modelData.id) - onToggled: function (checked) { - if (checked) { - PluginService.enablePlugin(modelData.id); - } else { - PluginService.disablePlugin(modelData.id); + NToggle { + checked: PluginRegistry.isPluginEnabled(modelData.id) + baseSize: Style.baseWidgetSize * 0.7 + onToggled: function (checked) { + if (checked) { + PluginService.enablePlugin(modelData.id); + } else { + PluginService.disablePlugin(modelData.id); + } } } - } - NIconButton { - icon: "settings" - tooltipText: I18n.tr("settings.plugins.settings.tooltip") - visible: modelData.entryPoints?.settings !== undefined - onClicked: { - // TODO: Open plugin settings dialog - Logger.i("PluginsTab", "Open settings for:", modelData.id); + NIconButton { + icon: "settings" + tooltipText: I18n.tr("settings.plugins.settings.tooltip") + baseSize: Style.baseWidgetSize * 0.7 + visible: modelData.entryPoints?.settings !== undefined + onClicked: { + // TODO: Open plugin settings dialog + Logger.i("PluginsTab", "Open settings for:", modelData.id); + } } - } - NIconButton { - icon: "trash" - tooltipText: I18n.tr("settings.plugins.uninstall.tooltip") - onClicked: { - uninstallDialog.pluginToUninstall = modelData; - uninstallDialog.open(); + NIconButton { + icon: "trash" + tooltipText: I18n.tr("settings.plugins.uninstall.tooltip") + baseSize: Style.baseWidgetSize * 0.7 + onClicked: { + uninstallDialog.pluginToUninstall = modelData; + uninstallDialog.open(); + } } } } @@ -157,7 +151,7 @@ ColumnLayout { tooltipText: I18n.tr("settings.plugins.refresh.tooltip") onClicked: { PluginService.refreshAvailablePlugins(); - ToastService.show(I18n.tr("settings.plugins.refresh.refreshing")); + ToastService.showNotice(I18n.tr("settings.plugins.refresh.refreshing")); } } } @@ -439,13 +433,13 @@ ColumnLayout { enabled: sourceNameInput.text.length > 0 && sourceUrlInput.text.length > 0 onClicked: { if (PluginRegistry.addPluginSource(sourceNameInput.text, sourceUrlInput.text)) { - ToastService.show(I18n.tr("settings.plugins.sources.add-dialog.success")); + ToastService.showNotice(I18n.tr("settings.plugins.sources.add-dialog.success")); PluginService.refreshAvailablePlugins(); addSourceDialog.close(); sourceNameInput.text = ""; sourceUrlInput.text = ""; } else { - ToastService.show(I18n.tr("settings.plugins.sources.add-dialog.error")); + ToastService.showNotice(I18n.tr("settings.plugins.sources.add-dialog.error")); } } } @@ -459,12 +453,19 @@ ColumnLayout { modal: true dim: false anchors.centerIn: parent - width: 400 + width: 400 * Style.uiScaleRatio padding: Style.marginL property var pluginToUninstall: null - ColumnLayout { + background: Rectangle { + color: Color.mSurface + radius: Style.radiusS + border.color: Color.mPrimary + border.width: Style.borderM + } + + contentItem: ColumnLayout { width: parent.width spacing: Style.marginL @@ -510,9 +511,9 @@ ColumnLayout { PluginService.installPlugin(pluginMetadata, function (success, error) { if (success) { - ToastService.show(I18n.tr("settings.plugins.install-success").replace("%1", pluginMetadata.name)); + ToastService.showNotice(I18n.tr("settings.plugins.install-success").replace("%1", pluginMetadata.name)); } else { - ToastService.show(I18n.tr("settings.plugins.install-error").replace("%1", error || "Unknown error")); + ToastService.showNotice(I18n.tr("settings.plugins.install-error").replace("%1", error || "Unknown error")); } }); } @@ -521,13 +522,13 @@ ColumnLayout { var manifest = PluginRegistry.getPluginManifest(pluginId); var pluginName = manifest?.name || pluginId; - ToastService.show(I18n.tr("settings.plugins.uninstalling").replace("%1", pluginName)); + ToastService.showNotice(I18n.tr("settings.plugins.uninstalling").replace("%1", pluginName)); PluginService.uninstallPlugin(pluginId, function (success, error) { if (success) { - ToastService.show(I18n.tr("settings.plugins.uninstall-success").replace("%1", pluginName)); + ToastService.showNotice(I18n.tr("settings.plugins.uninstall-success").replace("%1", pluginName)); } else { - ToastService.show(I18n.tr("settings.plugins.uninstall-error").replace("%1", error || "Unknown error")); + ToastService.showNotice(I18n.tr("settings.plugins.uninstall-error").replace("%1", error || "Unknown error")); } }); } From 49c1b835cbe086fa95cb22ff43c6b77901294a4d Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Mon, 1 Dec 2025 21:46:52 -0500 Subject: [PATCH 10/28] PluginSystem: proper settings dialog --- Modules/Panels/Settings/Tabs/PluginsTab.qml | 122 +++++++++++++++++++- 1 file changed, 119 insertions(+), 3 deletions(-) diff --git a/Modules/Panels/Settings/Tabs/PluginsTab.qml b/Modules/Panels/Settings/Tabs/PluginsTab.qml index 1cc1ea9d0..8bc3b461c 100644 --- a/Modules/Panels/Settings/Tabs/PluginsTab.qml +++ b/Modules/Panels/Settings/Tabs/PluginsTab.qml @@ -76,8 +76,7 @@ ColumnLayout { baseSize: Style.baseWidgetSize * 0.7 visible: modelData.entryPoints?.settings !== undefined onClicked: { - // TODO: Open plugin settings dialog - Logger.i("PluginsTab", "Open settings for:", modelData.id); + pluginSettingsDialog.openPluginSettings(modelData); } } @@ -493,7 +492,7 @@ ColumnLayout { textColor: Color.mOnPrimary onClicked: { if (uninstallDialog.pluginToUninstall) { - uninstallPlugin(uninstallDialog.pluginToUninstall.id); + root.uninstallPlugin(uninstallDialog.pluginToUninstall.id); uninstallDialog.close(); } } @@ -502,6 +501,123 @@ ColumnLayout { } } + // Plugin settings dialog + Popup { + id: pluginSettingsDialog + modal: true + dim: false + anchors.centerIn: parent + width: Math.max(settingsContent.implicitWidth + padding * 2, 500) + height: settingsContent.implicitHeight + padding * 2 + padding: Style.marginXL + + property var currentPlugin: null + property var currentPluginApi: null + + background: Rectangle { + color: Color.mSurface + radius: Style.radiusL + border.color: Color.mPrimary + border.width: Style.borderM + } + + contentItem: FocusScope { + focus: true + + ColumnLayout { + id: settingsContent + anchors.fill: parent + spacing: Style.marginM + + // Header + RowLayout { + Layout.fillWidth: true + + NText { + text: I18n.tr("system.plugin-settings-title", { + "plugin": pluginSettingsDialog.currentPlugin?.name || "" + }) + pointSize: Style.fontSizeL + font.weight: Style.fontWeightBold + color: Color.mPrimary + Layout.fillWidth: true + } + + NIconButton { + icon: "close" + tooltipText: I18n.tr("tooltips.close") + onClicked: pluginSettingsDialog.close() + } + } + + // Separator + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Color.mOutline + } + + // Settings loader + Loader { + id: settingsLoader + Layout.fillWidth: true + } + + // Action buttons + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Style.marginM + spacing: Style.marginM + + Item { + Layout.fillWidth: true + } + + NButton { + text: I18n.tr("common.cancel") + outlined: true + onClicked: pluginSettingsDialog.close() + } + + NButton { + text: I18n.tr("common.apply") + icon: "check" + onClicked: { + if (settingsLoader.item && settingsLoader.item.saveSettings) { + settingsLoader.item.saveSettings(); + pluginSettingsDialog.close(); + ToastService.showNotice(I18n.tr("settings.plugins.settings-saved")); + } + } + } + } + } + } + + function openPluginSettings(pluginManifest) { + currentPlugin = pluginManifest; + + // Get plugin API + currentPluginApi = PluginService.getPluginAPI(pluginManifest.id); + if (!currentPluginApi) { + Logger.e("PluginsTab", "Cannot open settings: plugin not loaded:", pluginManifest.id); + ToastService.showNotice(I18n.tr("settings.plugins.settings-error-not-loaded")); + return; + } + + // Get plugin directory + var pluginDir = PluginRegistry.getPluginDir(pluginManifest.id); + var settingsPath = pluginDir + "/" + pluginManifest.entryPoints.settings; + + // Load settings component + settingsLoader.setSource("file://" + settingsPath, { + "pluginApi": currentPluginApi + }); + + open(); + } + } + // ------------------------------ // Functions // ------------------------------ From 2662df52cdc60e357b3e6a1e0d5e4e43d77dac48 Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Mon, 1 Dec 2025 22:04:22 -0500 Subject: [PATCH 11/28] PluginSystem: relying on entryPoints, removing "provides" to keep things simple --- Modules/Panels/Plugins/PluginPanelSlot.qml | 2 +- Modules/Panels/Settings/Tabs/PluginsTab.qml | 24 ++++++++++----------- Services/Noctalia/PluginRegistry.qml | 7 ------ Services/Noctalia/PluginService.qml | 10 ++++++--- 4 files changed, 20 insertions(+), 23 deletions(-) diff --git a/Modules/Panels/Plugins/PluginPanelSlot.qml b/Modules/Panels/Plugins/PluginPanelSlot.qml index 392733745..647976275 100644 --- a/Modules/Panels/Plugins/PluginPanelSlot.qml +++ b/Modules/Panels/Plugins/PluginPanelSlot.qml @@ -88,7 +88,7 @@ SmartPanel { return false; } - if (!plugin.manifest.provides.panel) { + if (!plugin.manifest.entryPoints || !plugin.manifest.entryPoints.panel) { Logger.w("PluginPanelSlot", "Plugin does not provide a panel:", pluginId); return false; } diff --git a/Modules/Panels/Settings/Tabs/PluginsTab.qml b/Modules/Panels/Settings/Tabs/PluginsTab.qml index 8bc3b461c..0c4d8f777 100644 --- a/Modules/Panels/Settings/Tabs/PluginsTab.qml +++ b/Modules/Panels/Settings/Tabs/PluginsTab.qml @@ -58,18 +58,6 @@ ColumnLayout { description: modelData.description } - NToggle { - checked: PluginRegistry.isPluginEnabled(modelData.id) - baseSize: Style.baseWidgetSize * 0.7 - onToggled: function (checked) { - if (checked) { - PluginService.enablePlugin(modelData.id); - } else { - PluginService.disablePlugin(modelData.id); - } - } - } - NIconButton { icon: "settings" tooltipText: I18n.tr("settings.plugins.settings.tooltip") @@ -89,6 +77,18 @@ ColumnLayout { uninstallDialog.open(); } } + + NToggle { + checked: PluginRegistry.isPluginEnabled(modelData.id) + baseSize: Style.baseWidgetSize * 0.7 + onToggled: function (checked) { + if (checked) { + PluginService.enablePlugin(modelData.id); + } else { + PluginService.disablePlugin(modelData.id); + } + } + } } } } diff --git a/Services/Noctalia/PluginRegistry.qml b/Services/Noctalia/PluginRegistry.qml index 98734e4d1..1c2246a73 100644 --- a/Services/Noctalia/PluginRegistry.qml +++ b/Services/Noctalia/PluginRegistry.qml @@ -340,13 +340,6 @@ Singleton { } } - if (!manifest.provides) { - return { - valid: false, - error: "Missing 'provides' field" - }; - } - if (!manifest.entryPoints) { return { valid: false, diff --git a/Services/Noctalia/PluginService.qml b/Services/Noctalia/PluginService.qml index 10866e320..ca1748a48 100644 --- a/Services/Noctalia/PluginService.qml +++ b/Services/Noctalia/PluginService.qml @@ -360,7 +360,7 @@ Singleton { } // Load bar widget component if provided (don't instantiate - BarWidgetRegistry will do that) - if (manifest.provides.barWidget && manifest.entryPoints.barWidget) { + if (manifest.entryPoints && manifest.entryPoints.barWidget) { var widgetPath = pluginDir + "/" + manifest.entryPoints.barWidget; var widgetComponent = Qt.createComponent("file://" + widgetPath); @@ -390,7 +390,7 @@ Singleton { Logger.i("PluginService", "Unloading plugin:", pluginId); // Unregister from BarWidgetRegistry - if (plugin.manifest.provides.barWidget) { + if (plugin.manifest.entryPoints && plugin.manifest.entryPoints.barWidget) { BarWidgetRegistry.unregisterPluginWidget(pluginId); } @@ -417,6 +417,7 @@ Singleton { readonly property string pluginId: "${pluginId}" readonly property string pluginDir: "${pluginDir}" property var pluginSettings: ({}) + property var manifest: ({}) // IPC handlers storage property var ipcHandlers: ({}) @@ -428,6 +429,9 @@ Singleton { } `, root, "PluginAPI_" + pluginId); + // Set manifest + api.manifest = manifest; + // Load plugin settings loadPluginSettings(pluginId, function (settings) { api.pluginSettings = settings; @@ -586,7 +590,7 @@ Singleton { } var plugin = root.loadedPlugins[pluginId]; - if (!plugin || !plugin.manifest || !plugin.manifest.provides.panel) { + if (!plugin || !plugin.manifest || !plugin.manifest.entryPoints || !plugin.manifest.entryPoints.panel) { Logger.w("PluginService", "Plugin does not provide a panel:", pluginId); return false; } From 0e395753f28c766490e6369527177ee224fb29bd Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Mon, 1 Dec 2025 22:28:25 -0500 Subject: [PATCH 12/28] PluginSystem: Add/Remove bar widget on plugin enable/disable. --- Services/Noctalia/PluginService.qml | 66 +++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/Services/Noctalia/PluginService.qml b/Services/Noctalia/PluginService.qml index ca1748a48..50a0415b0 100644 --- a/Services/Noctalia/PluginService.qml +++ b/Services/Noctalia/PluginService.qml @@ -275,6 +275,14 @@ Singleton { PluginRegistry.setPluginEnabled(pluginId, true); loadPlugin(pluginId); + + // Add plugin widget to bar if it provides one + var manifest = PluginRegistry.getPluginManifest(pluginId); + if (manifest && manifest.entryPoints && manifest.entryPoints.barWidget) { + var widgetId = "plugin:" + pluginId; + addWidgetToBar(widgetId, "right"); // Default to right section + } + updatePluginInAvailable(pluginId, { enabled: true }); @@ -282,6 +290,33 @@ Singleton { return true; } + // Helper function to add a widget to the bar + function addWidgetToBar(widgetId, section) { + section = section || "right"; // Default to right section + + // Check if widget already exists in any section + var sections = ["left", "center", "right"]; + for (var s = 0; s < sections.length; s++) { + var widgets = Settings.data.bar.widgets[sections[s]] || []; + for (var i = 0; i < widgets.length; i++) { + if (widgets[i].id === widgetId) { + Logger.d("PluginService", "Widget already in bar:", widgetId); + return false; + } + } + } + + // Add to specified section + var widgets = Settings.data.bar.widgets[section] || []; + widgets.push({ + id: widgetId + }); + Settings.data.bar.widgets[section] = widgets; + + Logger.i("PluginService", "Added widget", widgetId, "to bar section:", section); + return true; + } + // Disable a plugin function disablePlugin(pluginId) { if (!PluginRegistry.isPluginEnabled(pluginId)) { @@ -289,6 +324,10 @@ Singleton { return true; } + // Remove plugin widget from bar before unloading + var widgetId = "plugin:" + pluginId; + removeWidgetFromBar(widgetId); + PluginRegistry.setPluginEnabled(pluginId, false); unloadPlugin(pluginId); updatePluginInAvailable(pluginId, { @@ -298,6 +337,33 @@ Singleton { return true; } + // Helper function to remove a widget from all bar sections + function removeWidgetFromBar(widgetId) { + var sections = ["left", "center", "right"]; + var changed = false; + + for (var s = 0; s < sections.length; s++) { + var section = sections[s]; + var widgets = Settings.data.bar.widgets[section] || []; + var newWidgets = []; + + for (var i = 0; i < widgets.length; i++) { + if (widgets[i].id !== widgetId) { + newWidgets.push(widgets[i]); + } else { + changed = true; + Logger.i("PluginService", "Removed widget", widgetId, "from bar section:", section); + } + } + + if (changed) { + Settings.data.bar.widgets[section] = newWidgets; + } + } + + return changed; + } + // Load a plugin function loadPlugin(pluginId) { if (root.loadedPlugins[pluginId]) { From f302302fb2db6876e84e916ab4a6e0875792dfe3 Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Mon, 1 Dec 2025 22:28:41 -0500 Subject: [PATCH 13/28] PluginSystem: Fix some translations --- Assets/Translations/en.json | 14 +++++++++----- Modules/Panels/Settings/Tabs/PluginsTab.qml | 10 +++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Assets/Translations/en.json b/Assets/Translations/en.json index 9accf50fb..3a0652a5c 100644 --- a/Assets/Translations/en.json +++ b/Assets/Translations/en.json @@ -5,7 +5,10 @@ }, "common": { "add": "Add", - "cancel": "Cancel" + "cancel": "Cancel", + "apply": "Apply", + "save": "Save", + "close": "Close" }, "bar": { "widget-settings": { @@ -1750,14 +1753,14 @@ "installed": { "description": "Manage and configure all locally installed plugins.", "label": "Installed plugins", - "no-plugins": "No plugins installed", - "no-plugins.description": "Install plugins from the Available Plugins section below." + "no-plugins-label": "No plugins installed", + "no-plugins-description": "Install plugins from the Available Plugins section below." }, "available": { "description": "Browse and install plugins from configured sources.", "label": "Available plugins", - "no-plugins": "No plugins available", - "no-plugins.description": "Check your plugin sources or refresh the list." + "no-plugins-label": "No plugins available", + "no-plugins-description": "Check your plugin sources or refresh the list." }, "filter": { "all": "All", @@ -1768,6 +1771,7 @@ "install-error": "Failed to install: %1", "install-success": "Successfully installed %1", "installing": "Installing %1...", + "plugin-settings-title": "{plugin} Settings", "refresh": { "refreshing": "Refreshing plugin list...", "tooltip": "Refresh available plugins" diff --git a/Modules/Panels/Settings/Tabs/PluginsTab.qml b/Modules/Panels/Settings/Tabs/PluginsTab.qml index 0c4d8f777..1efe77594 100644 --- a/Modules/Panels/Settings/Tabs/PluginsTab.qml +++ b/Modules/Panels/Settings/Tabs/PluginsTab.qml @@ -95,8 +95,8 @@ ColumnLayout { NLabel { visible: PluginRegistry.getAllInstalledPluginIds().length === 0 - label: I18n.tr("settings.plugins.installed.no-plugins") - description: I18n.tr("settings.plugins.installed.no-plugins.description") + label: I18n.tr("settings.plugins.installed.no-plugins-label") + description: I18n.tr("settings.plugins.installed.no-plugins-description") Layout.fillWidth: true } } @@ -295,8 +295,8 @@ ColumnLayout { NLabel { visible: pluginListView.count === 0 - label: I18n.tr("settings.plugins.available.no-plugins") - description: I18n.tr("settings.plugins.available.no-plugins.description") + label: I18n.tr("settings.plugins.available.no-plugins-label") + description: I18n.tr("settings.plugins.available.no-plugins-description") Layout.fillWidth: true } @@ -534,7 +534,7 @@ ColumnLayout { Layout.fillWidth: true NText { - text: I18n.tr("system.plugin-settings-title", { + text: I18n.tr("settings.plugins.plugin-settings-title", { "plugin": pluginSettingsDialog.currentPlugin?.name || "" }) pointSize: Style.fontSizeL From 5eeeebae4812ef7eff829425e386e3cf3531621f Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Mon, 1 Dec 2025 22:47:21 -0500 Subject: [PATCH 14/28] PluginSystem: proper registry listing --- Services/Noctalia/PluginRegistry.qml | 26 +++++++++++ Services/Noctalia/PluginService.qml | 64 +++++++++++++++++----------- 2 files changed, 66 insertions(+), 24 deletions(-) diff --git a/Services/Noctalia/PluginRegistry.qml b/Services/Noctalia/PluginRegistry.qml index 1c2246a73..af1193b2f 100644 --- a/Services/Noctalia/PluginRegistry.qml +++ b/Services/Noctalia/PluginRegistry.qml @@ -264,6 +264,32 @@ Singleton { }); } + // Register a plugin (add to installed plugins after download) + function registerPlugin(manifest) { + var pluginId = manifest.id; + root.installedPlugins[pluginId] = manifest; + + // Ensure state exists (default to disabled) + if (!root.pluginStates[pluginId]) { + root.pluginStates[pluginId] = { + enabled: false + }; + } + + save(); + root.pluginsChanged(); + Logger.i("PluginRegistry", "Registered plugin:", pluginId); + } + + // Unregister a plugin (remove from registry) + function unregisterPlugin(pluginId) { + delete root.pluginStates[pluginId]; + delete root.installedPlugins[pluginId]; + save(); + root.pluginsChanged(); + Logger.i("PluginRegistry", "Unregistered plugin:", pluginId); + } + // Remove plugin state (call after deleting plugin folder) function removePluginState(pluginId) { delete root.pluginStates[pluginId]; diff --git a/Services/Noctalia/PluginService.qml b/Services/Noctalia/PluginService.qml index 50a0415b0..9a9959278 100644 --- a/Services/Noctalia/PluginService.qml +++ b/Services/Noctalia/PluginService.qml @@ -94,7 +94,7 @@ Singleton { // Fetch plugin registry from a source function fetchPluginRegistry(source) { - var rawUrl = source.url + "/raw/main/registry.json"; + var rawUrl = source.url + "/refs/heads/main/registry.json"; var registryUrl = rawUrl.replace("github.com", "raw.githubusercontent.com"); Logger.d("PluginService", "Fetching registry from:", registryUrl); @@ -110,39 +110,55 @@ Singleton { activeFetches[source.url] = fetchProcess; - fetchProcess.exited.connect(function (exitCode) { - if (exitCode === 0) { - try { - var response = fetchProcess.stdout.text; - var registry = JSON.parse(response); + fetchProcess.stdout.onStreamFinished.connect(function () { + var response = fetchProcess.stdout.text; - if (registry && registry.plugins && Array.isArray(registry.plugins)) { - // Add source info to each plugin - for (var i = 0; i < registry.plugins.length; i++) { - var plugin = registry.plugins[i]; - plugin.source = source; + // Debug: log the raw response + Logger.d("PluginService", "Registry response length:", response ? response.length : 0); - // Check if already downloaded - plugin.downloaded = PluginRegistry.isPluginDownloaded(plugin.id); - plugin.enabled = PluginRegistry.isPluginEnabled(plugin.id); + if (!response || response.trim() === "") { + Logger.e("PluginService", "Empty response from", source.name); + delete activeFetches[source.url]; + fetchProcess.destroy(); + return; + } - root.availablePlugins.push(plugin); - } + try { + var registry = JSON.parse(response); - Logger.i("PluginService", "Loaded", registry.plugins.length, "plugins from", source.name); - root.availablePluginsUpdated(); + if (registry && registry.plugins && Array.isArray(registry.plugins)) { + // Add source info to each plugin + for (var i = 0; i < registry.plugins.length; i++) { + var plugin = registry.plugins[i]; + plugin.source = source; + + // Check if already downloaded + plugin.downloaded = PluginRegistry.isPluginDownloaded(plugin.id); + plugin.enabled = PluginRegistry.isPluginEnabled(plugin.id); + + root.availablePlugins.push(plugin); } - } catch (e) { - Logger.e("PluginService", "Failed to parse registry from", source.name, ":", e); + + Logger.i("PluginService", "Loaded", registry.plugins.length, "plugins from", source.name); + root.availablePluginsUpdated(); } - } else { - Logger.e("PluginService", "Failed to fetch registry from", source.name); + } catch (e) { + Logger.e("PluginService", "Failed to parse registry from", source.name, ":", e); + Logger.e("PluginService", "Response was:", response ? response.substring(0, 200) : "null"); } delete activeFetches[source.url]; fetchProcess.destroy(); }); + fetchProcess.exited.connect(function (exitCode) { + if (exitCode !== 0) { + Logger.e("PluginService", "Failed to fetch registry from", source.name, "- exit code:", exitCode); + delete activeFetches[source.url]; + fetchProcess.destroy(); + } + }); + fetchProcess.running = true; } @@ -161,8 +177,8 @@ Singleton { var downloadCmd = ` mkdir -p '${pluginDir}' && cd '${pluginDir}' && - (curl -L -s '${repoUrl}/archive/refs/heads/main.tar.gz' | tar -xz --strip-components=2 '*/main/${pluginPath}' || - wget -q -O- '${repoUrl}/archive/refs/heads/main.tar.gz' | tar -xz --strip-components=2 '*/main/${pluginPath}') + (curl -L -s '${repoUrl}/archive/refs/heads/main.tar.gz' | tar -xz --strip-components=1 --wildcards '*/${pluginPath}' || + wget -q -O- '${repoUrl}/archive/refs/heads/main.tar.gz' | tar -xz --strip-components=1 --wildcards '*/${pluginPath}') `; var downloadProcess = Qt.createQmlObject(` From 24a9e94bafbc68eab573c7780e3e7298528b3316 Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Mon, 1 Dec 2025 23:01:57 -0500 Subject: [PATCH 15/28] PluginSystem: improved ui, fixed installing plus added auto enabling after install. --- Assets/Translations/en.json | 18 +- Modules/Panels/Settings/Tabs/PluginsTab.qml | 188 ++++++++++---------- Services/Noctalia/PluginRegistry.qml | 15 +- Services/Noctalia/PluginService.qml | 4 +- 4 files changed, 121 insertions(+), 104 deletions(-) diff --git a/Assets/Translations/en.json b/Assets/Translations/en.json index 3a0652a5c..931b7f3f6 100644 --- a/Assets/Translations/en.json +++ b/Assets/Translations/en.json @@ -1768,17 +1768,19 @@ "not-downloaded": "Not Downloaded" }, "install": "Install", - "install-error": "Failed to install: %1", - "install-success": "Successfully installed %1", - "installing": "Installing %1...", + "install-error": "Failed to install: {error}", + "install-success": "Successfully installed {plugin}", + "installing": "Installing {plugin}...", "plugin-settings-title": "{plugin} Settings", "refresh": { - "refreshing": "Refreshing plugin list...", + "refreshing": "Refreshing plugins list...", "tooltip": "Refresh available plugins" }, "settings": { "tooltip": "Plugin settings" }, + "settings-error-not-loaded": "Plugin not loaded", + "settings-saved": "Plugin settings saved", "sources": { "add-custom": "Add custom repository", "add-dialog": { @@ -1799,13 +1801,13 @@ "title": "Plugins", "uninstall": "Uninstall", "uninstall-dialog": { - "description": "Are you sure you want to uninstall %1? This will remove all plugin data.", + "description": "Are you sure you want to uninstall {plugin}? This will remove all plugin data.", "title": "Uninstall plugin" }, - "uninstall-error": "Failed to uninstall: %1", - "uninstall-success": "Successfully uninstalled %1", + "uninstall-error": "Failed to uninstall: {error}", + "uninstall-success": "Successfully uninstalled {plugin}", "uninstall.tooltip": "Uninstall plugin", - "uninstalling": "Uninstalling %1..." + "uninstalling": "Uninstalling {plugin}..." }, "screen-recorder": { "audio": { diff --git a/Modules/Panels/Settings/Tabs/PluginsTab.qml b/Modules/Panels/Settings/Tabs/PluginsTab.qml index 1efe77594..f47beaf0a 100644 --- a/Modules/Panels/Settings/Tabs/PluginsTab.qml +++ b/Modules/Panels/Settings/Tabs/PluginsTab.qml @@ -68,16 +68,6 @@ ColumnLayout { } } - NIconButton { - icon: "trash" - tooltipText: I18n.tr("settings.plugins.uninstall.tooltip") - baseSize: Style.baseWidgetSize * 0.7 - onClicked: { - uninstallDialog.pluginToUninstall = modelData; - uninstallDialog.open(); - } - } - NToggle { checked: PluginRegistry.isPluginEnabled(modelData.id) baseSize: Style.baseWidgetSize * 0.7 @@ -120,34 +110,43 @@ ColumnLayout { spacing: Style.marginM Layout.fillWidth: true - NButton { - text: I18n.tr("settings.plugins.filter.all") - backgroundColor: pluginFilter === "all" ? Color.mPrimary : Color.mSurfaceVariant - textColor: pluginFilter === "all" ? Color.mOnPrimary : Color.mOnSurfaceVariant - onClicked: pluginFilter = "all" - } - - NButton { - text: I18n.tr("settings.plugins.filter.downloaded") - backgroundColor: pluginFilter === "downloaded" ? Color.mPrimary : Color.mSurfaceVariant - textColor: pluginFilter === "downloaded" ? Color.mOnPrimary : Color.mOnSurfaceVariant - onClicked: pluginFilter = "downloaded" - } - - NButton { - text: I18n.tr("settings.plugins.filter.not-downloaded") - backgroundColor: pluginFilter === "notDownloaded" ? Color.mPrimary : Color.mSurfaceVariant - textColor: pluginFilter === "notDownloaded" ? Color.mOnPrimary : Color.mOnSurfaceVariant - onClicked: pluginFilter = "notDownloaded" - } - - Item { + NTabBar { + id: filterTabBar Layout.fillWidth: true + currentIndex: 0 + onCurrentIndexChanged: { + if (currentIndex === 0) + pluginFilter = "all"; + else if (currentIndex === 1) + pluginFilter = "downloaded"; + else if (currentIndex === 2) + pluginFilter = "notDownloaded"; + } + spacing: Style.marginXS + + NTabButton { + text: I18n.tr("settings.plugins.filter.all") + tabIndex: 0 + checked: pluginFilter === "all" + } + + NTabButton { + text: I18n.tr("settings.plugins.filter.downloaded") + tabIndex: 1 + checked: pluginFilter === "downloaded" + } + + NTabButton { + text: I18n.tr("settings.plugins.filter.not-downloaded") + tabIndex: 2 + checked: pluginFilter === "notDownloaded" + } } NIconButton { icon: "refresh" tooltipText: I18n.tr("settings.plugins.refresh.tooltip") + baseSize: Style.baseWidgetSize * 0.9 onClicked: { PluginService.refreshAvailablePlugins(); ToastService.showNotice(I18n.tr("settings.plugins.refresh.refreshing")); @@ -158,49 +157,47 @@ ColumnLayout { property string pluginFilter: "all" // Available plugins list - NScrollView { + NListView { + id: pluginListView Layout.fillWidth: true Layout.preferredHeight: 400 + spacing: Style.marginM - NListView { - id: pluginListView - spacing: Style.marginM + model: { + var all = PluginService.availablePlugins || []; + var filtered = []; - model: { - var all = PluginService.availablePlugins || []; - var filtered = []; + for (var i = 0; i < all.length; i++) { + var plugin = all[i]; + var downloaded = plugin.downloaded || false; - for (var i = 0; i < all.length; i++) { - var plugin = all[i]; - var downloaded = plugin.downloaded || false; - - if (pluginFilter === "all") { - filtered.push(plugin); - } else if (pluginFilter === "downloaded" && downloaded) { - filtered.push(plugin); - } else if (pluginFilter === "notDownloaded" && !downloaded) { - filtered.push(plugin); - } + if (pluginFilter === "all") { + filtered.push(plugin); + } else if (pluginFilter === "downloaded" && downloaded) { + filtered.push(plugin); + } else if (pluginFilter === "notDownloaded" && !downloaded) { + filtered.push(plugin); } - - return filtered; } - delegate: RowLayout { - width: pluginListView.width + return filtered; + } + + delegate: NBox { + width: ListView.view.width - pluginListView.scrollBarWidth + implicitHeight: contentRow.implicitHeight + Style.marginL * 2 + color: Color.mSurface + + RowLayout { + id: contentRow + anchors.fill: parent + anchors.margins: Style.marginL spacing: Style.marginM - Rectangle { - width: 48 - height: 48 - radius: Style.radiusM - color: Color.mSurfaceContainerHigh - - NIcon { - anchors.centerIn: parent - icon: "plugin" - pointSize: Style.fontSizeXL - } + NIcon { + icon: "plugin" + pointSize: Style.fontSizeXL + color: Color.mOnSurface } ColumnLayout { @@ -211,6 +208,7 @@ ColumnLayout { text: modelData.name font.weight: Font.Medium color: Color.mOnSurface + Layout.fillWidth: true } NText { @@ -258,7 +256,7 @@ ColumnLayout { // Downloaded indicator NIcon { - icon: "check-circle" + icon: "circle-check" pointSize: Style.fontSizeM color: Color.mPrimary visible: modelData.downloaded === true @@ -276,19 +274,6 @@ ColumnLayout { } } } - - // Enable/Disable toggle (only for downloaded plugins) - NToggle { - visible: modelData.downloaded === true - checked: modelData.enabled || false - onToggled: function (checked) { - if (checked) { - PluginService.enablePlugin(modelData.id); - } else { - PluginService.disablePlugin(modelData.id); - } - } - } } } } @@ -300,12 +285,6 @@ ColumnLayout { Layout.fillWidth: true } - NDivider { - Layout.fillWidth: true - Layout.topMargin: Style.marginL - Layout.bottomMargin: Style.marginL - } - // ------------------------------ // Section 3: Plugin Sources // ------------------------------ @@ -389,7 +368,14 @@ ColumnLayout { width: 500 padding: Style.marginL - ColumnLayout { + background: Rectangle { + color: Color.mSurface + radius: Style.radiusS + border.color: Color.mPrimary + border.width: Style.borderM + } + + contentItem: ColumnLayout { width: parent.width spacing: Style.marginL @@ -470,7 +456,9 @@ ColumnLayout { NHeader { label: I18n.tr("settings.plugins.uninstall-dialog.title") - description: I18n.tr("settings.plugins.uninstall-dialog.description").replace("%1", uninstallDialog.pluginToUninstall?.name || "") + description: I18n.tr("settings.plugins.uninstall-dialog.description", { + "plugin": uninstallDialog.pluginToUninstall?.name || "" + }) } RowLayout { @@ -623,13 +611,21 @@ ColumnLayout { // ------------------------------ function installPlugin(pluginMetadata) { - ToastService.show(I18n.tr("settings.plugins.installing").replace("%1", pluginMetadata.name)); + ToastService.showNotice(I18n.tr("settings.plugins.installing", { + "plugin": pluginMetadata.name + })); PluginService.installPlugin(pluginMetadata, function (success, error) { if (success) { - ToastService.showNotice(I18n.tr("settings.plugins.install-success").replace("%1", pluginMetadata.name)); + ToastService.showNotice(I18n.tr("settings.plugins.install-success", { + "plugin": pluginMetadata.name + })); + // Auto-enable the plugin after installation + PluginService.enablePlugin(pluginMetadata.id); } else { - ToastService.showNotice(I18n.tr("settings.plugins.install-error").replace("%1", error || "Unknown error")); + ToastService.showNotice(I18n.tr("settings.plugins.install-error", { + "error": error || "Unknown error" + })); } }); } @@ -638,13 +634,19 @@ ColumnLayout { var manifest = PluginRegistry.getPluginManifest(pluginId); var pluginName = manifest?.name || pluginId; - ToastService.showNotice(I18n.tr("settings.plugins.uninstalling").replace("%1", pluginName)); + ToastService.showNotice(I18n.tr("settings.plugins.uninstalling", { + "plugin": pluginName + })); PluginService.uninstallPlugin(pluginId, function (success, error) { if (success) { - ToastService.showNotice(I18n.tr("settings.plugins.uninstall-success").replace("%1", pluginName)); + ToastService.showNotice(I18n.tr("settings.plugins.uninstall-success", { + "plugin": pluginName + })); } else { - ToastService.showNotice(I18n.tr("settings.plugins.uninstall-error").replace("%1", error || "Unknown error")); + ToastService.showNotice(I18n.tr("settings.plugins.uninstall-error", { + "error": error || "Unknown error" + })); } }); } diff --git a/Services/Noctalia/PluginRegistry.qml b/Services/Noctalia/PluginRegistry.qml index af1193b2f..02b83048a 100644 --- a/Services/Noctalia/PluginRegistry.qml +++ b/Services/Noctalia/PluginRegistry.qml @@ -36,7 +36,6 @@ Singleton { onLoaded: { Logger.i("PluginRegistry", "Loaded plugin states from:", path); - Logger.i("PluginRegistry", "FileView onLoaded triggered"); root.pluginStates = adapter.states || {}; root.pluginSources = adapter.sources || []; @@ -54,6 +53,20 @@ Singleton { // Scan plugin folder to discover installed plugins scanPluginFolder(); } + + onLoadFailed: function (error) { + Logger.w("PluginRegistry", "Failed to load plugins.json, will create it:", error); + // Initialize defaults and continue + root.pluginStates = {}; + root.pluginSources = [ + { + "name": "Official Noctalia Plugins", + "url": "https://github.com/noctalia-dev/noctalia-plugins" + } + ]; + // Scan for installed plugins + root.scanPluginFolder(); + } } Component.onCompleted: { diff --git a/Services/Noctalia/PluginService.qml b/Services/Noctalia/PluginService.qml index 9a9959278..a066ee9ea 100644 --- a/Services/Noctalia/PluginService.qml +++ b/Services/Noctalia/PluginService.qml @@ -177,8 +177,8 @@ Singleton { var downloadCmd = ` mkdir -p '${pluginDir}' && cd '${pluginDir}' && - (curl -L -s '${repoUrl}/archive/refs/heads/main.tar.gz' | tar -xz --strip-components=1 --wildcards '*/${pluginPath}' || - wget -q -O- '${repoUrl}/archive/refs/heads/main.tar.gz' | tar -xz --strip-components=1 --wildcards '*/${pluginPath}') + (curl -L -s '${repoUrl}/archive/refs/heads/main.tar.gz' | tar -xz --strip-components=2 --wildcards '*/${pluginPath}/*' || + wget -q -O- '${repoUrl}/archive/refs/heads/main.tar.gz' | tar -xz --strip-components=2 --wildcards '*/${pluginPath}/*') `; var downloadProcess = Qt.createQmlObject(` From 65cc3c91d8f614d860c52b74ec6fa3a47bc20906 Mon Sep 17 00:00:00 2001 From: Ly-sec Date: Tue, 2 Dec 2025 15:50:03 +0100 Subject: [PATCH 16/28] BarTab: better plugin display --- Modules/Panels/Settings/Tabs/BarTab.qml | 21 +++++++++++++++++++-- Widgets/NSearchableComboBox.qml | 10 ++++++++++ Widgets/NSectionEditor.qml | 18 +++++++++++++++++- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/Modules/Panels/Settings/Tabs/BarTab.qml b/Modules/Panels/Settings/Tabs/BarTab.qml index 4afa8d635..4da7a49cc 100644 --- a/Modules/Panels/Settings/Tabs/BarTab.qml +++ b/Modules/Panels/Settings/Tabs/BarTab.qml @@ -5,6 +5,7 @@ import Quickshell import qs.Commons import qs.Services.Compositor import qs.Services.UI +import qs.Services.Noctalia import qs.Widgets ColumnLayout { @@ -425,10 +426,26 @@ ColumnLayout { availableWidgets.clear(); const widgets = BarWidgetRegistry.getAvailableWidgets(); widgets.forEach(entry => { + const isPlugin = BarWidgetRegistry.isPluginWidget(entry); + let displayName = entry; + + // For plugin widgets, strip the "plugin:" prefix and try to get the actual plugin name + if (isPlugin) { + const pluginId = entry.replace("plugin:", ""); + const manifest = PluginRegistry.getPluginManifest(pluginId); + if (manifest && manifest.name) { + displayName = manifest.name; + } else { + // Fallback: just strip the prefix + displayName = pluginId; + } + } + availableWidgets.append({ "key": entry, - "name": entry, - "badgeLocations": getWidgetLocations(entry) + "name": displayName, + "badgeLocations": getWidgetLocations(entry), + "isPlugin": isPlugin }); }); } diff --git a/Widgets/NSearchableComboBox.qml b/Widgets/NSearchableComboBox.qml index 4dac05d3c..393b5ae6c 100644 --- a/Widgets/NSearchableComboBox.qml +++ b/Widgets/NSearchableComboBox.qml @@ -222,6 +222,16 @@ RowLayout { spacing: Style.marginS Layout.alignment: Qt.AlignRight + // Plugin badge indicator + NIcon { + visible: typeof isPlugin !== 'undefined' && isPlugin === true + icon: "plugin" + pointSize: Style.fontSizeXS + color: highlighted ? Color.mOnHover : Color.mSecondary + Layout.preferredWidth: Style.baseWidgetSize * 0.7 + Layout.preferredHeight: Style.baseWidgetSize * 0.7 + } + Repeater { model: typeof badgeLocations !== 'undefined' ? badgeLocations : [] diff --git a/Widgets/NSectionEditor.qml b/Widgets/NSectionEditor.qml index fda2a1fdc..db09daa48 100644 --- a/Widgets/NSectionEditor.qml +++ b/Widgets/NSectionEditor.qml @@ -252,8 +252,24 @@ NBox { anchors.centerIn: parent spacing: Style.marginXXS + // Plugin indicator icon + NIcon { + visible: root.widgetRegistry && root.widgetRegistry.isPluginWidget(modelData.id) + icon: "plugin" + pointSize: Style.fontSizeXXS + color: root.getWidgetColor(modelData)[1] + Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.5 : 0 + Layout.preferredHeight: Style.baseWidgetSize * 0.5 + } + NText { - text: modelData.id + text: { + // Strip "plugin:" prefix for display + if (root.widgetRegistry && root.widgetRegistry.isPluginWidget(modelData.id)) { + return modelData.id.replace("plugin:", ""); + } + return modelData.id; + } pointSize: Style.fontSizeXS color: root.getWidgetColor(modelData)[1] horizontalAlignment: Text.AlignHCenter From 0554db8cf781ecfdd2ac0065ee60a49c4850fca1 Mon Sep 17 00:00:00 2001 From: Ly-sec Date: Tue, 2 Dec 2025 16:17:26 +0100 Subject: [PATCH 17/28] NSectionEditor: use Grid --- Widgets/NSectionEditor.qml | 95 +++++++++++++++++++++++++++++--------- 1 file changed, 73 insertions(+), 22 deletions(-) diff --git a/Widgets/NSectionEditor.qml b/Widgets/NSectionEditor.qml index db09daa48..b3306e218 100644 --- a/Widgets/NSectionEditor.qml +++ b/Widgets/NSectionEditor.qml @@ -31,6 +31,23 @@ NBox { color: Color.mSurface Layout.fillWidth: true + readonly property real widgetItemHeight: Style.baseWidgetSize * 1.3 * Style.uiScaleRatio + + function calculateWidgetWidth(gridWidth) { + // Calculate width to fit 3 widgets with spacing + // gridWidth is already the Grid's width (after margins) + // Column spacing: 2 gaps between 3 columns + var columnSpacing = 2 * Style.marginM; + var widgetWidth = (gridWidth - columnSpacing) / 3; + // Ensure minimum width and don't exceed available space + return Math.max(150 * Style.uiScaleRatio, Math.min(widgetWidth, gridWidth / 3)); + } + + function calculateGridColumns(availableWidth) { + // Always show 3 widgets per row + return 3; + } + Layout.minimumHeight: { // header + minimal content area var absoluteMin = (Style.marginL * 2) + (Style.fontSizeL * 2) + Style.marginM + (65 * Style.uiScaleRatio); @@ -40,16 +57,24 @@ NBox { return absoluteMin; } - // Calculate rows based on estimated widget layout - var availableWidth = parent.width - (Style.marginL * 2); - var avgWidgetWidth = 120 * Style.uiScaleRatio; // More accurate estimate - var widgetsPerRow = Math.max(1, Math.floor(availableWidth / avgWidgetWidth)); - var rows = Math.ceil(widgetCount / widgetsPerRow); + // Calculate rows based on grid layout + // Use actual parent width if available, otherwise estimate + var availableWidth = (parent && parent.width > 0) ? (parent.width - (Style.marginL * 2)) : 400; + var columns = calculateGridColumns(availableWidth); + if (columns === 0) + columns = 1; + var rows = Math.ceil(widgetCount / columns); + + // Calculate widget width for height calculation + var containerWidth = availableWidth; + var widgetWidth = calculateWidgetWidth(containerWidth); // Header height + spacing + (rows * widget height) + (spacing between rows) + margins var headerHeight = Style.fontSizeL * 2; - var widgetHeight = Style.baseWidgetSize * 1.15 * Style.uiScaleRatio; - var widgetAreaHeight = ((rows + 1) * widgetHeight) + ((rows - 1) * Style.marginS); + // Account for grid margins and add buffer to prevent overlap + var gridTopMargin = Style.marginXXS; + var gridBottomMargin = Style.marginXXS; + var widgetAreaHeight = gridTopMargin + (rows * widgetItemHeight) + ((rows - 1) * Style.marginS) + gridBottomMargin + Style.marginXS; return Math.max(absoluteMin, (Style.marginL * 2) + headerHeight + Style.marginM + widgetAreaHeight); } @@ -152,18 +177,38 @@ NBox { // Drag and Drop Widget Area Item { + id: gridContainer Layout.fillWidth: true - Layout.fillHeight: true + Layout.preferredHeight: { + if (widgetModel.length === 0) { + return 65 * Style.uiScaleRatio; + } + // Use actual width, fallback to a reasonable default if not yet available + var containerWidth = width > 0 ? width : (parent ? parent.width : 400); + var columns = root.calculateGridColumns(containerWidth); + if (columns === 0) + columns = 1; + var rows = Math.ceil(widgetModel.length / columns); + // Calculate height: (rows * item height) + (row spacing between items) + grid margins + // Add extra buffer to prevent overlap + var gridTopMargin = Style.marginXXS; + var gridBottomMargin = Style.marginXXS; + var calculatedHeight = gridTopMargin + (rows * root.widgetItemHeight) + ((rows - 1) * Style.marginS) + gridBottomMargin + Style.marginXS; + return Math.max(65 * Style.uiScaleRatio, calculatedHeight); + } Layout.minimumHeight: 65 * Style.uiScaleRatio - clip: false // Don't clip children so ghost can move freely + clip: true // Clip to prevent overflow - Flow { - id: widgetFlow + Grid { + id: widgetGrid anchors.fill: parent - spacing: Style.marginS - flow: Flow.LeftToRight + anchors.margins: Style.marginXXS // Small margin to prevent edge overlap + columns: 3 + rowSpacing: Style.marginS + columnSpacing: Style.marginM Repeater { + id: widgetRepeater model: widgetModel delegate: Rectangle { @@ -171,8 +216,8 @@ NBox { required property int index required property var modelData - width: widgetContent.implicitWidth + Style.marginL - height: Style.baseWidgetSize * 1.15 * Style.uiScaleRatio + width: root.calculateWidgetWidth(parent.width) + height: root.widgetItemHeight radius: Style.radiusL color: root.getWidgetColor(modelData)[0] border.color: Color.mOutline @@ -249,7 +294,8 @@ NBox { } RowLayout { id: widgetContent - anchors.centerIn: parent + anchors.fill: parent + anchors.margins: Style.marginXS spacing: Style.marginXXS // Plugin indicator icon @@ -272,14 +318,19 @@ NBox { } pointSize: Style.fontSizeXS color: root.getWidgetColor(modelData)[1] - horizontalAlignment: Text.AlignHCenter + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter elide: Text.ElideRight - Layout.preferredWidth: 60 * Style.uiScaleRatio + leftPadding: Style.marginS + rightPadding: Style.marginS + Layout.fillWidth: true + Layout.fillHeight: true } RowLayout { spacing: 0 Layout.preferredWidth: buttonsCount * buttonsWidth * Style.uiScaleRatio + Layout.preferredHeight: parent.height Loader { active: root.widgetRegistry && root.widgetRegistry.widgetHasUserSettings(modelData.id) @@ -443,7 +494,7 @@ NBox { for (var i = 0; i < widgetModel.length; i++) { if (i === draggedIndex) continue; - const widget = widgetFlow.children[i]; + const widget = widgetRepeater.itemAt(i); if (!widget || widget.widgetIndex === undefined) continue; @@ -468,9 +519,9 @@ NBox { // Check if we should insert at position 0 (very beginning) if (widgetModel.length > 0 && draggedIndex !== 0) { - const firstWidget = widgetFlow.children[0]; + const firstWidget = widgetRepeater.itemAt(0); if (firstWidget) { - const dist = Math.sqrt(Math.pow(mouseX, 2) + Math.pow(mouseY - firstWidget.y, 2)); + const dist = Math.sqrt(Math.pow(mouseX - firstWidget.x, 2) + Math.pow(mouseY - firstWidget.y, 2)); if (dist < minDistance && mouseX < firstWidget.x + firstWidget.width / 2) { minDistance = dist; bestIndex = 0; @@ -522,7 +573,7 @@ NBox { // Find which widget was clicked for (var i = 0; i < widgetModel.length; i++) { - const widget = widgetFlow.children[i]; + const widget = widgetRepeater.itemAt(i); if (widget && widget.widgetIndex !== undefined) { if (mouse.x >= widget.x && mouse.x <= widget.x + widget.width && mouse.y >= widget.y && mouse.y <= widget.y + widget.height) { const localX = mouse.x - widget.x; From b031041cccc9243d0a040c14bfa5bcf2af405103 Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Tue, 2 Dec 2025 21:54:23 -0500 Subject: [PATCH 18/28] PluginSystem: support for translations via extra/optional i18n/ folder in the plugin --- Modules/Bar/Extras/BarWidgetLoader.qml | 16 ++ Modules/Panels/Plugins/PluginPanelSlot.qml | 21 ++- Modules/Panels/Settings/Tabs/PluginsTab.qml | 28 ++++ Services/Noctalia/PluginService.qml | 158 ++++++++++++++++++++ 4 files changed, 221 insertions(+), 2 deletions(-) diff --git a/Modules/Bar/Extras/BarWidgetLoader.qml b/Modules/Bar/Extras/BarWidgetLoader.qml index 7263a5824..070e67814 100644 --- a/Modules/Bar/Extras/BarWidgetLoader.qml +++ b/Modules/Bar/Extras/BarWidgetLoader.qml @@ -35,9 +35,25 @@ Item { asynchronous: false sourceComponent: BarWidgetRegistry.getWidget(widgetId) + // Create a dummy pluginApi that returns empty strings to avoid undefined warnings + property var _dummyApi: QtObject { + function tr(key) { + return ""; + } + function trp(key, count) { + return ""; + } + } + onLoaded: { if (!item) return; + + // Inject dummy API immediately to prevent undefined warnings during initialization + if (BarWidgetRegistry.isPluginWidget(widgetId) && item.hasOwnProperty("pluginApi") && !item.pluginApi) { + item.pluginApi = _dummyApi; + } + Logger.d("BarWidgetLoader", "Loading widget", widgetId, "on screen:", widgetScreen.name); // Apply properties to loaded widget diff --git a/Modules/Panels/Plugins/PluginPanelSlot.qml b/Modules/Panels/Plugins/PluginPanelSlot.qml index 647976275..65ae30c44 100644 --- a/Modules/Panels/Plugins/PluginPanelSlot.qml +++ b/Modules/Panels/Plugins/PluginPanelSlot.qml @@ -60,6 +60,23 @@ SmartPanel { id: pluginContentLoader anchors.fill: parent active: false + + // Create a dummy pluginApi that returns empty strings to avoid undefined warnings + property var _dummyApi: QtObject { + function tr(key) { + return ""; + } + function trp(key, count) { + return ""; + } + } + + onLoaded: { + // Inject the dummy API immediately to prevent undefined warnings + if (item && item.hasOwnProperty("pluginApi") && !item.pluginApi) { + item.pluginApi = _dummyApi; + } + } } } @@ -111,12 +128,12 @@ SmartPanel { // Get plugin API var api = PluginService.getPluginAPI(pluginId); - // Create instance with API + // Activate loader and set component simultaneously root.contentLoader.active = true; root.contentLoader.sourceComponent = component; + // Immediately inject API (before any bindings evaluate) if (root.contentLoader.item) { - // Inject plugin API if (root.contentLoader.item.hasOwnProperty("pluginApi")) { root.contentLoader.item.pluginApi = api; } diff --git a/Modules/Panels/Settings/Tabs/PluginsTab.qml b/Modules/Panels/Settings/Tabs/PluginsTab.qml index f47beaf0a..8bf421175 100644 --- a/Modules/Panels/Settings/Tabs/PluginsTab.qml +++ b/Modules/Panels/Settings/Tabs/PluginsTab.qml @@ -549,6 +549,34 @@ ColumnLayout { Loader { id: settingsLoader Layout.fillWidth: true + + // Create a dummy pluginApi that returns empty strings to avoid undefined warnings + property var _dummyApi: QtObject { + property var pluginSettings: ({}) + property var manifest: ({ + metadata: { + defaultSettings: {} + } + }) + + function tr(key) { + return ""; + } + + function trp(key, count) { + return ""; + } + + function saveSettings() { + } + } + + onLoaded: { + // Inject dummy API immediately to prevent undefined warnings during initialization + if (item && item.hasOwnProperty("pluginApi") && !item.pluginApi) { + item.pluginApi = _dummyApi; + } + } } // Action buttons diff --git a/Services/Noctalia/PluginService.qml b/Services/Noctalia/PluginService.qml index a066ee9ea..c5d7fce6f 100644 --- a/Services/Noctalia/PluginService.qml +++ b/Services/Noctalia/PluginService.qml @@ -43,6 +43,30 @@ Singleton { } } + // Listen for language changes to reload plugin translations + Connections { + target: I18n + + function onLanguageChanged() { + Logger.d("PluginService", "Language changed to:", I18n.langCode, "- reloading plugin translations"); + + // Reload translations for all loaded plugins + for (var pluginId in root.loadedPlugins) { + var plugin = root.loadedPlugins[pluginId]; + if (plugin && plugin.api && plugin.manifest) { + // Update current language + plugin.api.currentLanguage = I18n.langCode; + + // Reload translations + loadPluginTranslationsAsync(pluginId, plugin.manifest, I18n.langCode, function (translations) { + plugin.api.pluginTranslations = translations; + Logger.d("PluginService", "Reloaded translations for plugin:", pluginId); + }); + } + } + } + } + function init() { if (root.initialized) { Logger.d("PluginService", "Already initialized, skipping"); @@ -504,22 +528,53 @@ Singleton { // IPC handlers storage property var ipcHandlers: ({}) + // Translation storage + property var pluginTranslations: ({}) + property string currentLanguage: "" + // Functions will be bound below property var saveSettings: null property var openPanel: null property var closePanel: null + property var tr: null + property var trp: null + property var hasTranslation: null } `, root, "PluginAPI_" + pluginId); // Set manifest api.manifest = manifest; + // Set current language (can't use binding in Qt.createQmlObject string) + api.currentLanguage = I18n.langCode; + // Load plugin settings loadPluginSettings(pluginId, function (settings) { api.pluginSettings = settings; }); + // Load plugin translations for current language + loadPluginTranslationsAsync(pluginId, manifest, I18n.langCode, function (translations) { + api.pluginTranslations = translations; + }); + + // ---------------------------------------- + // Helper function to get nested property by dot notation + var getNestedProperty = function (obj, path) { + var keys = path.split('.'); + var current = obj; + for (var i = 0; i < keys.length; i++) { + if (current === undefined || current === null) { + return undefined; + } + current = current[keys[i]]; + } + return current; + }; + + // ---------------------------------------- // Bind functions + // ---------------------------------------- api.saveSettings = function () { savePluginSettings(pluginId, api.pluginSettings); @@ -528,6 +583,7 @@ Singleton { api.pluginSettings = Object.assign({}, api.pluginSettings); }; + // ---------------------------------------- api.openPanel = function (screen) { // Open this plugin's panel on the specified screen if (!screen) { @@ -537,6 +593,7 @@ Singleton { return openPluginPanel(pluginId, screen); }; + // ---------------------------------------- api.closePanel = function (screen) { // Close this plugin's panel (find which slot it's in and close it) for (var slotNum = 1; slotNum <= 2; slotNum++) { @@ -550,9 +607,110 @@ Singleton { return false; }; + // ---------------------------------------- + // Translation function + api.tr = function (key, interpolations) { + if (typeof interpolations === 'undefined') { + interpolations = {}; + } + + var translation = getNestedProperty(api.pluginTranslations, key); + + // Return formatted key if translation not found + if (translation === undefined || translation === null) { + return '## ' + key + ' ##'; + } + + // Ensure translation is a string + if (typeof translation !== 'string') { + return '## ' + key + ' ##'; + } + + // Handle interpolations (e.g., "Hello {name}!") + var result = translation; + for (var placeholder in interpolations) { + var regex = new RegExp('\\{' + placeholder + '\\}', 'g'); + result = result.replace(regex, interpolations[placeholder]); + } + + return result; + }; + + // ---------------------------------------- + // Plural translation function + api.trp = function (key, count, defaultSingular, defaultPlural, interpolations) { + if (typeof defaultSingular === 'undefined') { + defaultSingular = ''; + } + if (typeof defaultPlural === 'undefined') { + defaultPlural = ''; + } + if (typeof interpolations === 'undefined') { + interpolations = {}; + } + + // Use key for singular, key_plural for plural + var pluralKey = count === 1 ? key : key + '_plural'; + + // Merge interpolations with count + var finalInterpolations = { + 'count': count + }; + for (var prop in interpolations) { + finalInterpolations[prop] = interpolations[prop]; + } + + // Use tr() to look up the translation + return api.tr(pluralKey, finalInterpolations); + }; + + // ---------------------------------------- + // Check if translation exists + api.hasTranslation = function (key) { + return getNestedProperty(api.pluginTranslations, key) !== undefined; + }; + return api; } + // Load plugin translations asynchronously + function loadPluginTranslationsAsync(pluginId, manifest, language, callback) { + var pluginDir = PluginRegistry.getPluginDir(pluginId); + var translationFile = pluginDir + "/i18n/" + language + ".json"; + + var readProcess = Qt.createQmlObject(` + import QtQuick + import Quickshell.Io + Process { + command: ["cat", "${translationFile}"] + stdout: StdioCollector {} + } + `, root, "ReadTranslation_" + pluginId + "_" + language); + + readProcess.exited.connect(function (exitCode) { + var translations = {}; + + if (exitCode === 0) { + try { + translations = JSON.parse(readProcess.stdout.text); + Logger.d("PluginService", "Loaded translations for", pluginId, "language:", language); + } catch (e) { + Logger.w("PluginService", "Failed to parse translations for", pluginId, "language:", language); + } + } else { + Logger.d("PluginService", "No translation file for", pluginId, "language:", language); + } + + if (callback) { + callback(translations); + } + + readProcess.destroy(); + }); + + readProcess.running = true; + } + // Load plugin settings function loadPluginSettings(pluginId, callback) { var settingsFile = PluginRegistry.getPluginSettingsFile(pluginId); From f8810f714e105ffee2bccc70dd461d5444547b20 Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Tue, 2 Dec 2025 22:26:26 -0500 Subject: [PATCH 19/28] PluginSystem: NSectionEdito allow direct plugin settings editing --- Modules/Panels/Settings/Tabs/BarTab.qml | 16 +- Modules/Panels/Settings/Tabs/PluginsTab.qml | 146 +----------------- Widgets/NPluginSettingsPopup.qml | 162 ++++++++++++++++++++ Widgets/NSectionEditor.qml | 145 +++++++++++------- 4 files changed, 269 insertions(+), 200 deletions(-) create mode 100644 Widgets/NPluginSettingsPopup.qml diff --git a/Modules/Panels/Settings/Tabs/BarTab.qml b/Modules/Panels/Settings/Tabs/BarTab.qml index 4da7a49cc..facf36589 100644 --- a/Modules/Panels/Settings/Tabs/BarTab.qml +++ b/Modules/Panels/Settings/Tabs/BarTab.qml @@ -4,8 +4,8 @@ import QtQuick.Layouts import Quickshell import qs.Commons import qs.Services.Compositor -import qs.Services.UI import qs.Services.Noctalia +import qs.Services.UI import qs.Widgets ColumnLayout { @@ -250,6 +250,7 @@ ColumnLayout { onReorderWidget: (section, fromIndex, toIndex) => _reorderWidgetInSection(section, fromIndex, toIndex) onUpdateWidgetSettings: (section, index, settings) => _updateWidgetSettingsInSection(section, index, settings) onMoveWidget: (fromSection, index, toSection) => _moveWidgetBetweenSections(fromSection, index, toSection) + onOpenPluginSettingsRequested: manifest => pluginSettingsDialog.openPluginSettings(manifest) } // Center Section @@ -265,6 +266,7 @@ ColumnLayout { onReorderWidget: (section, fromIndex, toIndex) => _reorderWidgetInSection(section, fromIndex, toIndex) onUpdateWidgetSettings: (section, index, settings) => _updateWidgetSettingsInSection(section, index, settings) onMoveWidget: (fromSection, index, toSection) => _moveWidgetBetweenSections(fromSection, index, toSection) + onOpenPluginSettingsRequested: manifest => pluginSettingsDialog.openPluginSettings(manifest) } // Right Section @@ -280,6 +282,7 @@ ColumnLayout { onReorderWidget: (section, fromIndex, toIndex) => _reorderWidgetInSection(section, fromIndex, toIndex) onUpdateWidgetSettings: (section, index, settings) => _updateWidgetSettingsInSection(section, index, settings) onMoveWidget: (fromSection, index, toSection) => _moveWidgetBetweenSections(fromSection, index, toSection) + onOpenPluginSettingsRequested: manifest => pluginSettingsDialog.openPluginSettings(manifest) } } } @@ -428,7 +431,7 @@ ColumnLayout { widgets.forEach(entry => { const isPlugin = BarWidgetRegistry.isPluginWidget(entry); let displayName = entry; - + // For plugin widgets, strip the "plugin:" prefix and try to get the actual plugin name if (isPlugin) { const pluginId = entry.replace("plugin:", ""); @@ -440,7 +443,7 @@ ColumnLayout { displayName = pluginId; } } - + availableWidgets.append({ "key": entry, "name": displayName, @@ -465,4 +468,11 @@ ColumnLayout { updateAvailableWidgetsModel(); } } + + // Shared Plugin Settings Popup + NPluginSettingsPopup { + id: pluginSettingsDialog + parent: Overlay.overlay + showToastOnSave: false + } } diff --git a/Modules/Panels/Settings/Tabs/PluginsTab.qml b/Modules/Panels/Settings/Tabs/PluginsTab.qml index 8bf421175..f2ae8b194 100644 --- a/Modules/Panels/Settings/Tabs/PluginsTab.qml +++ b/Modules/Panels/Settings/Tabs/PluginsTab.qml @@ -489,149 +489,11 @@ ColumnLayout { } } - // Plugin settings dialog - Popup { + // Plugin settings popup + NPluginSettingsPopup { id: pluginSettingsDialog - modal: true - dim: false - anchors.centerIn: parent - width: Math.max(settingsContent.implicitWidth + padding * 2, 500) - height: settingsContent.implicitHeight + padding * 2 - padding: Style.marginXL - - property var currentPlugin: null - property var currentPluginApi: null - - background: Rectangle { - color: Color.mSurface - radius: Style.radiusL - border.color: Color.mPrimary - border.width: Style.borderM - } - - contentItem: FocusScope { - focus: true - - ColumnLayout { - id: settingsContent - anchors.fill: parent - spacing: Style.marginM - - // Header - RowLayout { - Layout.fillWidth: true - - NText { - text: I18n.tr("settings.plugins.plugin-settings-title", { - "plugin": pluginSettingsDialog.currentPlugin?.name || "" - }) - pointSize: Style.fontSizeL - font.weight: Style.fontWeightBold - color: Color.mPrimary - Layout.fillWidth: true - } - - NIconButton { - icon: "close" - tooltipText: I18n.tr("tooltips.close") - onClicked: pluginSettingsDialog.close() - } - } - - // Separator - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 1 - color: Color.mOutline - } - - // Settings loader - Loader { - id: settingsLoader - Layout.fillWidth: true - - // Create a dummy pluginApi that returns empty strings to avoid undefined warnings - property var _dummyApi: QtObject { - property var pluginSettings: ({}) - property var manifest: ({ - metadata: { - defaultSettings: {} - } - }) - - function tr(key) { - return ""; - } - - function trp(key, count) { - return ""; - } - - function saveSettings() { - } - } - - onLoaded: { - // Inject dummy API immediately to prevent undefined warnings during initialization - if (item && item.hasOwnProperty("pluginApi") && !item.pluginApi) { - item.pluginApi = _dummyApi; - } - } - } - - // Action buttons - RowLayout { - Layout.fillWidth: true - Layout.topMargin: Style.marginM - spacing: Style.marginM - - Item { - Layout.fillWidth: true - } - - NButton { - text: I18n.tr("common.cancel") - outlined: true - onClicked: pluginSettingsDialog.close() - } - - NButton { - text: I18n.tr("common.apply") - icon: "check" - onClicked: { - if (settingsLoader.item && settingsLoader.item.saveSettings) { - settingsLoader.item.saveSettings(); - pluginSettingsDialog.close(); - ToastService.showNotice(I18n.tr("settings.plugins.settings-saved")); - } - } - } - } - } - } - - function openPluginSettings(pluginManifest) { - currentPlugin = pluginManifest; - - // Get plugin API - currentPluginApi = PluginService.getPluginAPI(pluginManifest.id); - if (!currentPluginApi) { - Logger.e("PluginsTab", "Cannot open settings: plugin not loaded:", pluginManifest.id); - ToastService.showNotice(I18n.tr("settings.plugins.settings-error-not-loaded")); - return; - } - - // Get plugin directory - var pluginDir = PluginRegistry.getPluginDir(pluginManifest.id); - var settingsPath = pluginDir + "/" + pluginManifest.entryPoints.settings; - - // Load settings component - settingsLoader.setSource("file://" + settingsPath, { - "pluginApi": currentPluginApi - }); - - open(); - } + parent: Overlay.overlay + showToastOnSave: true } // ------------------------------ diff --git a/Widgets/NPluginSettingsPopup.qml b/Widgets/NPluginSettingsPopup.qml new file mode 100644 index 000000000..2865cf99f --- /dev/null +++ b/Widgets/NPluginSettingsPopup.qml @@ -0,0 +1,162 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Commons +import qs.Services.Noctalia +import qs.Services.UI +import qs.Widgets + +Popup { + id: root + modal: true + dim: false + anchors.centerIn: parent + width: Math.max(settingsContent.implicitWidth + padding * 2, 500 * Style.uiScaleRatio) + height: settingsContent.implicitHeight + padding * 2 + padding: Style.marginXL + + property var currentPlugin: null + property var currentPluginApi: null + property bool showToastOnSave: false + + background: Rectangle { + color: Color.mSurface + radius: Style.radiusL + border.color: Color.mPrimary + border.width: Style.borderM + } + + contentItem: FocusScope { + focus: true + + ColumnLayout { + id: settingsContent + anchors.fill: parent + spacing: Style.marginM + + // Header + RowLayout { + Layout.fillWidth: true + + NText { + text: I18n.tr("settings.plugins.plugin-settings-title", { + "plugin": root.currentPlugin?.name || "" + }) + pointSize: Style.fontSizeL + font.weight: Style.fontWeightBold + color: Color.mPrimary + Layout.fillWidth: true + } + + NIconButton { + icon: "close" + tooltipText: I18n.tr("tooltips.close") + onClicked: root.close() + } + } + + // Separator + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Color.mOutline + } + + // Settings loader + Loader { + id: settingsLoader + Layout.fillWidth: true + + // Create a dummy pluginApi that returns empty strings to avoid undefined warnings + property var _dummyApi: QtObject { + property var pluginSettings: ({}) + property var manifest: ({ + metadata: { + defaultSettings: {} + } + }) + + function tr(key) { + return ""; + } + + function trp(key, count) { + return ""; + } + + function saveSettings() { + } + } + + onLoaded: { + // Inject dummy API immediately to prevent undefined warnings during initialization + if (item && item.hasOwnProperty("pluginApi") && !item.pluginApi) { + item.pluginApi = _dummyApi; + } + } + } + + // Action buttons + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Style.marginM + spacing: Style.marginM + + Item { + Layout.fillWidth: true + } + + NButton { + text: I18n.tr("common.cancel") + outlined: true + onClicked: root.close() + } + + NButton { + text: I18n.tr("common.apply") + icon: "check" + onClicked: { + if (settingsLoader.item && settingsLoader.item.saveSettings) { + settingsLoader.item.saveSettings(); + root.close(); + if (root.showToastOnSave) { + ToastService.showNotice(I18n.tr("settings.plugins.settings-saved")); + } + } + } + } + } + } + } + + onClosed: { + settingsLoader.source = ""; + currentPlugin = null; + currentPluginApi = null; + } + + function openPluginSettings(pluginManifest) { + currentPlugin = pluginManifest; + + // Get plugin API + currentPluginApi = PluginService.getPluginAPI(pluginManifest.id); + if (!currentPluginApi) { + Logger.e("NPluginSettingsPopup", "Cannot open settings: plugin not loaded:", pluginManifest.id); + if (showToastOnSave) { + ToastService.showNotice(I18n.tr("settings.plugins.settings-error-not-loaded")); + } + return; + } + + // Get plugin directory + var pluginDir = PluginRegistry.getPluginDir(pluginManifest.id); + var settingsPath = pluginDir + "/" + pluginManifest.entryPoints.settings; + + // Load settings component + settingsLoader.setSource("file://" + settingsPath, { + "pluginApi": currentPluginApi + }); + + open(); + } +} diff --git a/Widgets/NSectionEditor.qml b/Widgets/NSectionEditor.qml index b3306e218..42337f34e 100644 --- a/Widgets/NSectionEditor.qml +++ b/Widgets/NSectionEditor.qml @@ -3,6 +3,7 @@ import QtQuick.Controls import QtQuick.Effects import QtQuick.Layouts import qs.Commons +import qs.Services.Noctalia import qs.Widgets NBox { @@ -28,6 +29,7 @@ NBox { signal moveWidget(string fromSection, int index, string toSection) signal dragPotentialStarted signal dragPotentialEnded + signal openPluginSettingsRequested(var pluginManifest) color: Color.mSurface Layout.fillWidth: true @@ -100,6 +102,23 @@ NBox { } } + // Check if widget has settings (either core widget with allowUserSettings or plugin with settings entry point) + function widgetHasSettings(widgetId) { + // Check if it's a core widget with user settings + if (root.widgetRegistry && root.widgetRegistry.widgetHasUserSettings(widgetId)) { + return true; + } + + // Check if it's a plugin with settings + if (root.widgetRegistry && root.widgetRegistry.isPluginWidget(widgetId)) { + var pluginId = widgetId.replace("plugin:", ""); + var manifest = PluginRegistry.getPluginManifest(pluginId); + return manifest?.entryPoints?.settings !== undefined; + } + + return false; + } + ColumnLayout { anchors.fill: parent anchors.margins: Style.marginL @@ -226,7 +245,7 @@ NBox { // Store the widget index for drag operations property int widgetIndex: index readonly property int buttonsWidth: Math.round(20) - readonly property int buttonsCount: 1 + (root.widgetRegistry ? root.widgetRegistry.widgetHasUserSettings(modelData.id) : 0) + readonly property int buttonsCount: root.widgetHasSettings(modelData.id) ? 1 : 0 // Visual feedback during drag opacity: flowDragArea.draggedIndex === index ? 0.5 : 1.0 @@ -267,26 +286,37 @@ NBox { "action": "right", "icon": "arrow-bar-to-right", "visible": root.availableSections.includes("right") && root.sectionId !== "right" + }, + { + "label": I18n.tr("tooltips.remove-widget"), + "action": "remove", + "icon": "trash", + "visible": true } ] - onTriggered: action => root.moveWidget(root.sectionId, index, action) + onTriggered: action => { + if (action === "remove") { + root.removeWidget(root.sectionId, index); + } else { + root.moveWidget(root.sectionId, index, action); + } + } } // MouseArea for the context menu MouseArea { id: contextMouseArea - enabled: root.availableSections.length > 1 // Enable if there are other sections to move to anchors.fill: parent acceptedButtons: Qt.RightButton z: -1 // Below the buttons but above background onPressed: mouse => { if (mouse.button === Qt.RightButton) { - // Check if click is not on the buttons area + // Check if click is not on the settings button area (if visible) const localX = mouse.x; const buttonsStartX = parent.width - (parent.buttonsCount * parent.buttonsWidth); - if (localX < buttonsStartX) { + if (localX < buttonsStartX || parent.buttonsCount === 0) { contextMenu.openAtItem(widgetItem, mouse.x, mouse.y); } } @@ -296,18 +326,9 @@ NBox { id: widgetContent anchors.fill: parent anchors.margins: Style.marginXS + anchors.rightMargin: Style.marginS spacing: Style.marginXXS - // Plugin indicator icon - NIcon { - visible: root.widgetRegistry && root.widgetRegistry.isPluginWidget(modelData.id) - icon: "plugin" - pointSize: Style.fontSizeXXS - color: root.getWidgetColor(modelData)[1] - Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.5 : 0 - Layout.preferredHeight: Style.baseWidgetSize * 0.5 - } - NText { text: { // Strip "plugin:" prefix for display @@ -327,13 +348,23 @@ NBox { Layout.fillHeight: true } + // Plugin indicator icon + NIcon { + visible: root.widgetRegistry && root.widgetRegistry.isPluginWidget(modelData.id) + icon: "plugin" + pointSize: Style.fontSizeXXS + color: root.getWidgetColor(modelData)[1] + Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.5 : 0 + Layout.preferredHeight: Style.baseWidgetSize * 0.5 + } + RowLayout { spacing: 0 Layout.preferredWidth: buttonsCount * buttonsWidth * Style.uiScaleRatio Layout.preferredHeight: parent.height Loader { - active: root.widgetRegistry && root.widgetRegistry.widgetHasUserSettings(modelData.id) + active: root.widgetHasSettings(modelData.id) sourceComponent: NIconButton { icon: "settings" tooltipText: I18n.tr("tooltips.widget-settings") @@ -344,51 +375,55 @@ NBox { colorBgHover: Qt.alpha(Color.mOnPrimary, Style.opacityLight) colorFgHover: Color.mOnPrimary onClicked: { - var component = Qt.createComponent(Qt.resolvedUrl(root.settingsDialogComponent)); - function instantiateAndOpen() { - var dialog = component.createObject(Overlay.overlay, { - "widgetIndex": index, - "widgetData": modelData, - "widgetId": modelData.id, - "sectionId": root.sectionId - }); - if (dialog) { - dialog.updateWidgetSettings.connect(root.updateWidgetSettings); - dialog.open(); + // Check if this is a plugin widget + var isPlugin = root.widgetRegistry && root.widgetRegistry.isPluginWidget(modelData.id); + + if (isPlugin) { + // Handle plugin settings - emit signal for parent to handle + var pluginId = modelData.id.replace("plugin:", ""); + var manifest = PluginRegistry.getPluginManifest(pluginId); + + if (!manifest || !manifest.entryPoints?.settings) { + Logger.e("NSectionEditor", "Plugin settings not found for:", pluginId); + return; + } + + // Emit signal to request opening plugin settings + root.openPluginSettingsRequested(manifest); + } else { + // Handle core widget settings + var component = Qt.createComponent(Qt.resolvedUrl(root.settingsDialogComponent)); + function instantiateAndOpen() { + var dialog = component.createObject(Overlay.overlay, { + "widgetIndex": index, + "widgetData": modelData, + "widgetId": modelData.id, + "sectionId": root.sectionId + }); + if (dialog) { + dialog.updateWidgetSettings.connect(root.updateWidgetSettings); + dialog.open(); + } else { + Logger.e("NSectionEditor", "Failed to create settings dialog instance"); + } + } + if (component.status === Component.Ready) { + instantiateAndOpen(); + } else if (component.status === Component.Error) { + Logger.e("NSectionEditor", component.errorString()); } else { - Logger.e("NSectionEditor", "Failed to create settings dialog instance"); + component.statusChanged.connect(function () { + if (component.status === Component.Ready) { + instantiateAndOpen(); + } else if (component.status === Component.Error) { + Logger.e("NSectionEditor", component.errorString()); + } + }); } } - if (component.status === Component.Ready) { - instantiateAndOpen(); - } else if (component.status === Component.Error) { - Logger.e("NSectionEditor", component.errorString()); - } else { - component.statusChanged.connect(function () { - if (component.status === Component.Ready) { - instantiateAndOpen(); - } else if (component.status === Component.Error) { - Logger.e("NSectionEditor", component.errorString()); - } - }); - } } } } - - NIconButton { - icon: "close" - tooltipText: I18n.tr("tooltips.remove-widget") - baseSize: miniButtonSize - colorBorder: Qt.alpha(Color.mOutline, Style.opacityLight) - colorBg: Color.mOnSurface - colorFg: Color.mOnPrimary - colorBgHover: Qt.alpha(Color.mOnPrimary, Style.opacityLight) - colorFgHover: Color.mOnPrimary - onClicked: { - removeWidget(sectionId, index); - } - } } } } From 7583dee81d1731683a233025bc459135555bf9a5 Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Tue, 2 Dec 2025 22:34:43 -0500 Subject: [PATCH 20/28] PluginSystem: fix ControlCenterTab that uses NSectionEditor --- .../Panels/Settings/Tabs/ControlCenterTab.qml | 9 +++ Services/UI/ControlCenterWidgetRegistry.qml | 56 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/Modules/Panels/Settings/Tabs/ControlCenterTab.qml b/Modules/Panels/Settings/Tabs/ControlCenterTab.qml index aa2fd4b44..2cbebf429 100644 --- a/Modules/Panels/Settings/Tabs/ControlCenterTab.qml +++ b/Modules/Panels/Settings/Tabs/ControlCenterTab.qml @@ -259,6 +259,7 @@ ColumnLayout { onReorderWidget: (section, fromIndex, toIndex) => _reorderWidgetInSection(section, fromIndex, toIndex) onUpdateWidgetSettings: (section, index, settings) => _updateWidgetSettingsInSection(section, index, settings) onMoveWidget: (fromSection, index, toSection) => _moveWidgetBetweenSections(fromSection, index, toSection) + onOpenPluginSettingsRequested: manifest => pluginSettingsDialog.openPluginSettings(manifest) } // Right @@ -276,6 +277,7 @@ ColumnLayout { onReorderWidget: (section, fromIndex, toIndex) => _reorderWidgetInSection(section, fromIndex, toIndex) onUpdateWidgetSettings: (section, index, settings) => _updateWidgetSettingsInSection(section, index, settings) onMoveWidget: (fromSection, index, toSection) => _moveWidgetBetweenSections(fromSection, index, toSection) + onOpenPluginSettingsRequested: manifest => pluginSettingsDialog.openPluginSettings(manifest) } } } @@ -359,4 +361,11 @@ ColumnLayout { ListModel { id: availableWidgets } + + // Shared Plugin Settings Popup + NPluginSettingsPopup { + id: pluginSettingsDialog + parent: Overlay.overlay + showToastOnSave: false + } } diff --git a/Services/UI/ControlCenterWidgetRegistry.qml b/Services/UI/ControlCenterWidgetRegistry.qml index 23ef73703..21f1895d0 100644 --- a/Services/UI/ControlCenterWidgetRegistry.qml +++ b/Services/UI/ControlCenterWidgetRegistry.qml @@ -87,4 +87,60 @@ Singleton { function widgetHasUserSettings(id) { return (widgetMetadata[id] !== undefined) && (widgetMetadata[id].allowUserSettings === true); } + + // ------------------------------ + // Plugin widget registration + + // Track plugin widgets separately + property var pluginWidgets: ({}) + property var pluginWidgetMetadata: ({}) + + // Register a plugin widget + function registerPluginWidget(pluginId, component, metadata) { + if (!pluginId || !component) { + Logger.e("ControlCenterWidgetRegistry", "Cannot register plugin widget: invalid parameters"); + return false; + } + + // Add plugin: prefix to avoid conflicts with core widgets + var widgetId = "plugin:" + pluginId; + + pluginWidgets[widgetId] = component; + pluginWidgetMetadata[widgetId] = metadata || {}; + + // Also add to main widgets object for unified access + widgets[widgetId] = component; + widgetMetadata[widgetId] = metadata || {}; + + Logger.i("ControlCenterWidgetRegistry", "Registered plugin widget:", widgetId); + return true; + } + + // Unregister a plugin widget + function unregisterPluginWidget(pluginId) { + var widgetId = "plugin:" + pluginId; + + if (!pluginWidgets[widgetId]) { + Logger.w("ControlCenterWidgetRegistry", "Plugin widget not registered:", widgetId); + return false; + } + + delete pluginWidgets[widgetId]; + delete pluginWidgetMetadata[widgetId]; + delete widgets[widgetId]; + delete widgetMetadata[widgetId]; + + Logger.i("ControlCenterWidgetRegistry", "Unregistered plugin widget:", widgetId); + return true; + } + + // Check if a widget is a plugin widget + function isPluginWidget(id) { + return id.startsWith("plugin:"); + } + + // Get list of plugin widget IDs + function getPluginWidgets() { + return Object.keys(pluginWidgets); + } } From b79b625e12f552f0946227e07ace5574658ec8a1 Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Tue, 2 Dec 2025 22:42:45 -0500 Subject: [PATCH 21/28] NSectionEditor: code cleanup --- Widgets/NSectionEditor.qml | 31 +++++++++---------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/Widgets/NSectionEditor.qml b/Widgets/NSectionEditor.qml index 42337f34e..a8743f298 100644 --- a/Widgets/NSectionEditor.qml +++ b/Widgets/NSectionEditor.qml @@ -19,8 +19,10 @@ NBox { property var widgetRegistry: null property string settingsDialogComponent: "BarWidgetSettingsDialog.qml" + readonly property int gridColumns: 3 readonly property real miniButtonSize: Style.baseWidgetSize * 0.65 readonly property bool isAtMaxCapacity: maxWidgets >= 0 && widgetModel.length >= maxWidgets + readonly property real widgetItemHeight: Style.baseWidgetSize * 1.3 * Style.uiScaleRatio signal addWidget(string widgetId, string section) signal removeWidget(string section, int index) @@ -33,21 +35,12 @@ NBox { color: Color.mSurface Layout.fillWidth: true - readonly property real widgetItemHeight: Style.baseWidgetSize * 1.3 * Style.uiScaleRatio + // Calculate width to fit gridColumns widgets with spacing function calculateWidgetWidth(gridWidth) { - // Calculate width to fit 3 widgets with spacing - // gridWidth is already the Grid's width (after margins) - // Column spacing: 2 gaps between 3 columns - var columnSpacing = 2 * Style.marginM; - var widgetWidth = (gridWidth - columnSpacing) / 3; - // Ensure minimum width and don't exceed available space - return Math.max(150 * Style.uiScaleRatio, Math.min(widgetWidth, gridWidth / 3)); - } - - function calculateGridColumns(availableWidth) { - // Always show 3 widgets per row - return 3; + var columnSpacing = (root.gridColumns - 1) * Style.marginM; + var widgetWidth = (gridWidth - columnSpacing) / root.gridColumns; + return widgetWidth; } Layout.minimumHeight: { @@ -62,10 +55,7 @@ NBox { // Calculate rows based on grid layout // Use actual parent width if available, otherwise estimate var availableWidth = (parent && parent.width > 0) ? (parent.width - (Style.marginL * 2)) : 400; - var columns = calculateGridColumns(availableWidth); - if (columns === 0) - columns = 1; - var rows = Math.ceil(widgetCount / columns); + var rows = Math.ceil(widgetCount / root.gridColumns); // Calculate widget width for height calculation var containerWidth = availableWidth; @@ -204,10 +194,7 @@ NBox { } // Use actual width, fallback to a reasonable default if not yet available var containerWidth = width > 0 ? width : (parent ? parent.width : 400); - var columns = root.calculateGridColumns(containerWidth); - if (columns === 0) - columns = 1; - var rows = Math.ceil(widgetModel.length / columns); + var rows = Math.ceil(widgetModel.length / root.gridColumns); // Calculate height: (rows * item height) + (row spacing between items) + grid margins // Add extra buffer to prevent overlap var gridTopMargin = Style.marginXXS; @@ -222,7 +209,7 @@ NBox { id: widgetGrid anchors.fill: parent anchors.margins: Style.marginXXS // Small margin to prevent edge overlap - columns: 3 + columns: root.gridColumns rowSpacing: Style.marginS columnSpacing: Style.marginM From 7de16a423d9b09177c6d4a9f80417df76205cb6f Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Tue, 2 Dec 2025 22:53:52 -0500 Subject: [PATCH 22/28] NSectionEditor: simpler colors --- Widgets/NSectionEditor.qml | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/Widgets/NSectionEditor.qml b/Widgets/NSectionEditor.qml index a8743f298..2067ace06 100644 --- a/Widgets/NSectionEditor.qml +++ b/Widgets/NSectionEditor.qml @@ -73,23 +73,11 @@ NBox { // Generate widget color from name checksum function getWidgetColor(widget) { - const totalSum = JSON.stringify(widget).split('').reduce((acc, character) => { - return acc + character.charCodeAt(0); - }, 0); - switch (totalSum % 6) { - case 0: - return [Color.mPrimary, Color.mOnPrimary]; - case 1: + console.log(JSON.stringify(widget)); + if (widget.id.startsWith('plugin:')) { return [Color.mSecondary, Color.mOnSecondary]; - case 2: - return [Color.mTertiary, Color.mOnTertiary]; - case 3: - return [Color.mError, Color.mOnError]; - case 4: - return [Color.mOnSurface, Color.mSurface]; - case 5: - return [Color.mOnSurfaceVariant, Color.mSurfaceVariant]; } + return [Color.mPrimary, Color.mOnPrimary]; } // Check if widget has settings (either core widget with allowUserSettings or plugin with settings entry point) From 4958ddfdae292e0f7eab318ed7fa16cd87c3424a Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Tue, 2 Dec 2025 22:53:52 -0500 Subject: [PATCH 23/28] NSectionEditor: simpler colors --- Widgets/NSectionEditor.qml | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/Widgets/NSectionEditor.qml b/Widgets/NSectionEditor.qml index a8743f298..7fae5913a 100644 --- a/Widgets/NSectionEditor.qml +++ b/Widgets/NSectionEditor.qml @@ -73,23 +73,10 @@ NBox { // Generate widget color from name checksum function getWidgetColor(widget) { - const totalSum = JSON.stringify(widget).split('').reduce((acc, character) => { - return acc + character.charCodeAt(0); - }, 0); - switch (totalSum % 6) { - case 0: - return [Color.mPrimary, Color.mOnPrimary]; - case 1: + if (widget.id.startsWith('plugin:')) { return [Color.mSecondary, Color.mOnSecondary]; - case 2: - return [Color.mTertiary, Color.mOnTertiary]; - case 3: - return [Color.mError, Color.mOnError]; - case 4: - return [Color.mOnSurface, Color.mSurface]; - case 5: - return [Color.mOnSurfaceVariant, Color.mSurfaceVariant]; } + return [Color.mPrimary, Color.mOnPrimary]; } // Check if widget has settings (either core widget with allowUserSettings or plugin with settings entry point) From f0b91eb10f61e6ad5592483f4f4475f40df52ce6 Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Wed, 3 Dec 2025 08:39:36 -0500 Subject: [PATCH 24/28] NCollapsbile: changed default bg color and radius --- Widgets/NCollapsible.qml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Widgets/NCollapsible.qml b/Widgets/NCollapsible.qml index e727143e3..94664525c 100644 --- a/Widgets/NCollapsible.qml +++ b/Widgets/NCollapsible.qml @@ -4,11 +4,13 @@ import qs.Commons ColumnLayout { id: root + property string label: "" property string description: "" property bool expanded: false property bool defaultExpanded: false property real contentSpacing: Style.marginM + signal toggled(bool expanded) Layout.fillWidth: true @@ -24,8 +26,8 @@ ColumnLayout { Layout.preferredHeight: headerContent.implicitHeight + (Style.marginM * 2) // Material 3 style background - color: root.expanded ? Color.mSecondary : Color.mSurfaceVariant - radius: Style.radiusL + color: root.expanded ? Color.mSecondary : Color.mPrimary + radius: Style.radiusM // Subtle border border.color: root.expanded ? Color.mOnSecondary : Color.mOutline @@ -83,7 +85,7 @@ ColumnLayout { id: chevronIcon icon: "chevron-right" pointSize: Style.fontSizeL - color: root.expanded ? Color.mOnSecondary : Color.mOnSurfaceVariant + color: root.expanded ? Color.mOnSecondary : Color.mOnPrimary Layout.alignment: Qt.AlignVCenter rotation: root.expanded ? 90 : 0 @@ -111,7 +113,7 @@ ColumnLayout { text: root.label pointSize: Style.fontSizeL font.weight: Style.fontWeightSemiBold - color: root.expanded ? Color.mOnSecondary : Color.mOnSurface + color: root.expanded ? Color.mOnSecondary : Color.mOnPrimary wrapMode: Text.WordWrap Behavior on color { @@ -125,7 +127,7 @@ ColumnLayout { text: root.description pointSize: Style.fontSizeS font.weight: Style.fontWeightRegular - color: root.expanded ? Color.mOnSecondary : Color.mOnSurfaceVariant + color: root.expanded ? Color.mOnSecondary : Color.mOnPrimary Layout.fillWidth: true wrapMode: Text.WordWrap visible: root.description !== "" From 55f2132aa83bcfa0e6ebd92d064b3fd6a63ca2c3 Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Wed, 3 Dec 2025 08:57:24 -0500 Subject: [PATCH 25/28] PluginSystem: better plugins tab --- Modules/Panels/Settings/Tabs/PluginsTab.qml | 432 +++++++++++--------- 1 file changed, 240 insertions(+), 192 deletions(-) diff --git a/Modules/Panels/Settings/Tabs/PluginsTab.qml b/Modules/Panels/Settings/Tabs/PluginsTab.qml index f2ae8b194..ec15a5c47 100644 --- a/Modules/Panels/Settings/Tabs/PluginsTab.qml +++ b/Modules/Panels/Settings/Tabs/PluginsTab.qml @@ -53,9 +53,52 @@ ColumnLayout { anchors.margins: Style.marginL spacing: Style.marginM - NLabel { - label: modelData.name - description: modelData.description + NIcon { + icon: "plugin" + pointSize: Style.fontSizeXL + color: Color.mOnSurface + } + + ColumnLayout { + spacing: 2 + Layout.fillWidth: true + + NText { + text: modelData.name + font.weight: Font.Medium + color: Color.mOnSurface + Layout.fillWidth: true + } + + NText { + text: modelData.description + font.pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + RowLayout { + spacing: Style.marginS + + NText { + text: "v" + modelData.version + font.pointSize: Style.fontSizeXXS + color: Color.mOnSurfaceVariant + } + + NText { + text: "•" + font.pointSize: Style.fontSizeXXS + color: Color.mOnSurfaceVariant + } + + NText { + text: modelData.author + font.pointSize: Style.fontSizeXXS + color: Color.mOnSurfaceVariant + } + } } NIconButton { @@ -98,195 +141,7 @@ ColumnLayout { } // ------------------------------ - // Section 2: Available Plugins - // ------------------------------ - NHeader { - label: I18n.tr("settings.plugins.available.label") - description: I18n.tr("settings.plugins.available.description") - } - - // Filter controls - RowLayout { - spacing: Style.marginM - Layout.fillWidth: true - - NTabBar { - id: filterTabBar - Layout.fillWidth: true - currentIndex: 0 - onCurrentIndexChanged: { - if (currentIndex === 0) - pluginFilter = "all"; - else if (currentIndex === 1) - pluginFilter = "downloaded"; - else if (currentIndex === 2) - pluginFilter = "notDownloaded"; - } - spacing: Style.marginXS - - NTabButton { - text: I18n.tr("settings.plugins.filter.all") - tabIndex: 0 - checked: pluginFilter === "all" - } - - NTabButton { - text: I18n.tr("settings.plugins.filter.downloaded") - tabIndex: 1 - checked: pluginFilter === "downloaded" - } - - NTabButton { - text: I18n.tr("settings.plugins.filter.not-downloaded") - tabIndex: 2 - checked: pluginFilter === "notDownloaded" - } - } - - NIconButton { - icon: "refresh" - tooltipText: I18n.tr("settings.plugins.refresh.tooltip") - baseSize: Style.baseWidgetSize * 0.9 - onClicked: { - PluginService.refreshAvailablePlugins(); - ToastService.showNotice(I18n.tr("settings.plugins.refresh.refreshing")); - } - } - } - - property string pluginFilter: "all" - - // Available plugins list - NListView { - id: pluginListView - Layout.fillWidth: true - Layout.preferredHeight: 400 - spacing: Style.marginM - - model: { - var all = PluginService.availablePlugins || []; - var filtered = []; - - for (var i = 0; i < all.length; i++) { - var plugin = all[i]; - var downloaded = plugin.downloaded || false; - - if (pluginFilter === "all") { - filtered.push(plugin); - } else if (pluginFilter === "downloaded" && downloaded) { - filtered.push(plugin); - } else if (pluginFilter === "notDownloaded" && !downloaded) { - filtered.push(plugin); - } - } - - return filtered; - } - - delegate: NBox { - width: ListView.view.width - pluginListView.scrollBarWidth - implicitHeight: contentRow.implicitHeight + Style.marginL * 2 - color: Color.mSurface - - RowLayout { - id: contentRow - anchors.fill: parent - anchors.margins: Style.marginL - spacing: Style.marginM - - NIcon { - icon: "plugin" - pointSize: Style.fontSizeXL - color: Color.mOnSurface - } - - ColumnLayout { - spacing: 2 - Layout.fillWidth: true - - NText { - text: modelData.name - font.weight: Font.Medium - color: Color.mOnSurface - Layout.fillWidth: true - } - - NText { - text: modelData.description - font.pointSize: Style.fontSizeS - color: Color.mOnSurfaceVariant - wrapMode: Text.WordWrap - Layout.fillWidth: true - } - - RowLayout { - spacing: Style.marginS - - NText { - text: "v" + modelData.version - font.pointSize: Style.fontSizeXS - color: Color.mOnSurfaceVariant - } - - NText { - text: "•" - font.pointSize: Style.fontSizeXS - color: Color.mOnSurfaceVariant - } - - NText { - text: modelData.author - font.pointSize: Style.fontSizeXS - color: Color.mOnSurfaceVariant - } - - NText { - text: "•" - font.pointSize: Style.fontSizeXS - color: Color.mOnSurfaceVariant - } - - NText { - text: modelData.source?.name || "Unknown" - font.pointSize: Style.fontSizeXS - color: Color.mOnSurfaceVariant - } - } - } - - // Downloaded indicator - NIcon { - icon: "circle-check" - pointSize: Style.fontSizeM - color: Color.mPrimary - visible: modelData.downloaded === true - } - - // Install/Uninstall button - NButton { - text: modelData.downloaded ? I18n.tr("settings.plugins.uninstall") : I18n.tr("settings.plugins.install") - onClicked: { - if (modelData.downloaded) { - uninstallDialog.pluginToUninstall = modelData; - uninstallDialog.open(); - } else { - installPlugin(modelData); - } - } - } - } - } - } - - NLabel { - visible: pluginListView.count === 0 - label: I18n.tr("settings.plugins.available.no-plugins-label") - description: I18n.tr("settings.plugins.available.no-plugins-description") - Layout.fillWidth: true - } - - // ------------------------------ - // Section 3: Plugin Sources + // Section 2: Plugin Sources // ------------------------------ NCollapsible { Layout.fillWidth: true @@ -355,6 +210,199 @@ ColumnLayout { } } + NDivider { + Layout.fillWidth: true + Layout.topMargin: Style.marginL + Layout.bottomMargin: Style.marginL + } + + // ------------------------------ + // Section 3: Available Plugins + // ------------------------------ + NHeader { + label: I18n.tr("settings.plugins.available.label") + description: I18n.tr("settings.plugins.available.description") + } + + // Filter controls + RowLayout { + spacing: Style.marginM + Layout.fillWidth: true + + NTabBar { + id: filterTabBar + Layout.fillWidth: true + currentIndex: 0 + onCurrentIndexChanged: { + if (currentIndex === 0) + pluginFilter = "all"; + else if (currentIndex === 1) + pluginFilter = "downloaded"; + else if (currentIndex === 2) + pluginFilter = "notDownloaded"; + } + spacing: Style.marginXS + + NTabButton { + text: I18n.tr("settings.plugins.filter.all") + tabIndex: 0 + checked: pluginFilter === "all" + } + + NTabButton { + text: I18n.tr("settings.plugins.filter.downloaded") + tabIndex: 1 + checked: pluginFilter === "downloaded" + } + + NTabButton { + text: I18n.tr("settings.plugins.filter.not-downloaded") + tabIndex: 2 + checked: pluginFilter === "notDownloaded" + } + } + + NIconButton { + icon: "refresh" + tooltipText: I18n.tr("settings.plugins.refresh.tooltip") + baseSize: Style.baseWidgetSize * 0.9 + onClicked: { + PluginService.refreshAvailablePlugins(); + ToastService.showNotice(I18n.tr("settings.plugins.refresh.refreshing")); + } + } + } + + property string pluginFilter: "all" + + // Available plugins list + NListView { + id: pluginListView + Layout.fillWidth: true + Layout.preferredHeight: 400 + + model: { + var all = PluginService.availablePlugins || []; + var filtered = []; + + for (var i = 0; i < all.length; i++) { + var plugin = all[i]; + var downloaded = plugin.downloaded || false; + + if (pluginFilter === "all") { + filtered.push(plugin); + } else if (pluginFilter === "downloaded" && downloaded) { + filtered.push(plugin); + } else if (pluginFilter === "notDownloaded" && !downloaded) { + filtered.push(plugin); + } + } + + return filtered; + } + + delegate: NBox { + width: ListView.view.width - pluginListView.scrollBarWidth + implicitHeight: contentRow.implicitHeight + Style.marginL * 2 + color: Color.mSurface + + RowLayout { + id: contentRow + anchors.fill: parent + anchors.margins: Style.marginL + spacing: Style.marginM + + NIcon { + icon: "plugin" + pointSize: Style.fontSizeXL + color: Color.mOnSurface + } + + ColumnLayout { + spacing: 2 + Layout.fillWidth: true + + NText { + text: modelData.name + font.weight: Font.Medium + color: Color.mOnSurface + Layout.fillWidth: true + } + + NText { + text: modelData.description + font.pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + RowLayout { + spacing: Style.marginS + + NText { + text: "v" + modelData.version + font.pointSize: Style.fontSizeXXS + color: Color.mOnSurfaceVariant + } + + NText { + text: "•" + font.pointSize: Style.fontSizeXXS + color: Color.mOnSurfaceVariant + } + + NText { + text: modelData.author + font.pointSize: Style.fontSizeXXS + color: Color.mOnSurfaceVariant + } + + NText { + text: "•" + font.pointSize: Style.fontSizeXXS + color: Color.mOnSurfaceVariant + } + + NText { + text: modelData.source?.name || "Unknown" + font.pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + } + } + } + + // Downloaded indicator + NIcon { + icon: "circle-check" + pointSize: Style.fontSizeM + color: Color.mPrimary + visible: modelData.downloaded === true + } + + // Install/Uninstall button + NButton { + text: modelData.downloaded ? I18n.tr("settings.plugins.uninstall") : I18n.tr("settings.plugins.install") + onClicked: { + if (modelData.downloaded) { + uninstallDialog.pluginToUninstall = modelData; + uninstallDialog.open(); + } else { + installPlugin(modelData); + } + } + } + } + } + } + + NLabel { + visible: pluginListView.count === 0 + label: I18n.tr("settings.plugins.available.no-plugins-label") + description: I18n.tr("settings.plugins.available.no-plugins-description") + Layout.fillWidth: true + } + // ------------------------------ // Dialogs // ------------------------------ From c8d00d42e710661b049b10de85a6c3f87052cfc8 Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Wed, 3 Dec 2025 09:14:52 -0500 Subject: [PATCH 26/28] NSectionEditor: improved margin and drop indicator color --- Widgets/NSectionEditor.qml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Widgets/NSectionEditor.qml b/Widgets/NSectionEditor.qml index 7fae5913a..258522ed1 100644 --- a/Widgets/NSectionEditor.qml +++ b/Widgets/NSectionEditor.qml @@ -40,7 +40,7 @@ NBox { function calculateWidgetWidth(gridWidth) { var columnSpacing = (root.gridColumns - 1) * Style.marginM; var widgetWidth = (gridWidth - columnSpacing) / root.gridColumns; - return widgetWidth; + return Math.floor(widgetWidth); } Layout.minimumHeight: { @@ -66,7 +66,7 @@ NBox { // Account for grid margins and add buffer to prevent overlap var gridTopMargin = Style.marginXXS; var gridBottomMargin = Style.marginXXS; - var widgetAreaHeight = gridTopMargin + (rows * widgetItemHeight) + ((rows - 1) * Style.marginS) + gridBottomMargin + Style.marginXS; + var widgetAreaHeight = gridTopMargin + (rows * widgetItemHeight) + ((rows - 1) * Style.marginS) + gridBottomMargin + Style.marginM; return Math.max(absoluteMin, (Style.marginL * 2) + headerHeight + Style.marginM + widgetAreaHeight); } @@ -432,7 +432,7 @@ NBox { width: 3 height: Style.baseWidgetSize * 1.15 radius: Style.radiusXXS - color: Color.mPrimary + color: Color.mSecondary opacity: 0 visible: opacity > 0 z: 1999 From 85b887607a31487a74e91dc893ce235f61eef6db Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Wed, 3 Dec 2025 09:37:21 -0500 Subject: [PATCH 27/28] NSearchableComboBox is now agnostic to badges. Also replaced [L], [C], [R] by icons --- Modules/Panels/Settings/Tabs/BarTab.qml | 36 +++++++++++++++++++---- Widgets/NSearchableComboBox.qml | 39 ++++++++++++------------- 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/Modules/Panels/Settings/Tabs/BarTab.qml b/Modules/Panels/Settings/Tabs/BarTab.qml index facf36589..099f81ddf 100644 --- a/Modules/Panels/Settings/Tabs/BarTab.qml +++ b/Modules/Panels/Settings/Tabs/BarTab.qml @@ -227,6 +227,9 @@ ColumnLayout { NHeader { label: I18n.tr("settings.bar.widgets.section.label") + } + + NLabel { description: I18n.tr("settings.bar.widgets.section.description") } @@ -415,14 +418,36 @@ ColumnLayout { if (instances[i].widgetId === widgetId) { const section = instances[i].section; if (section === "left") - locations["L"] = true; + locations["arrow-bar-to-left"] = true; else if (section === "center") - locations["C"] = true; + locations["layout-columns"] = true; else if (section === "right") - locations["R"] = true; + locations["arrow-bar-to-right"] = true; } } - return Object.keys(locations).join(''); + return Object.keys(locations); + } + + function createBadges(isPlugin, locations) { + const badges = []; + + // Add plugin badge first (with custom color) + if (isPlugin) { + badges.push({ + "icon": "plugin", + "color": Color.mSecondary + }); + } + + // Add location badges (with default styling) + locations.forEach(function (location) { + badges.push({ + "icon": location, + "color": Color.mOnSurfaceVariant + }); + }); + + return badges; } function updateAvailableWidgetsModel() { @@ -447,8 +472,7 @@ ColumnLayout { availableWidgets.append({ "key": entry, "name": displayName, - "badgeLocations": getWidgetLocations(entry), - "isPlugin": isPlugin + "badges": createBadges(isPlugin, getWidgetLocations(entry)) }); }); } diff --git a/Widgets/NSearchableComboBox.qml b/Widgets/NSearchableComboBox.qml index 393b5ae6c..abbe32d12 100644 --- a/Widgets/NSearchableComboBox.qml +++ b/Widgets/NSearchableComboBox.qml @@ -219,33 +219,30 @@ RowLayout { } RowLayout { - spacing: Style.marginS + spacing: 0 Layout.alignment: Qt.AlignRight - // Plugin badge indicator - NIcon { - visible: typeof isPlugin !== 'undefined' && isPlugin === true - icon: "plugin" - pointSize: Style.fontSizeXS - color: highlighted ? Color.mOnHover : Color.mSecondary - Layout.preferredWidth: Style.baseWidgetSize * 0.7 - Layout.preferredHeight: Style.baseWidgetSize * 0.7 - } - + // Generic badge renderer Repeater { - model: typeof badgeLocations !== 'undefined' ? badgeLocations : [] + model: (typeof badges !== 'undefined' && badges !== null) ? badges.count : 0 - delegate: Item { - width: Style.baseWidgetSize * 0.7 - height: Style.baseWidgetSize * 0.7 + delegate: NIcon { + required property int index + readonly property var badgeData: badges.get(index) - NText { - anchors.centerIn: parent - text: modelData - pointSize: Style.fontSizeXXS - font.weight: Style.fontWeightBold - color: highlighted ? Color.mOnHover : Color.mOnSurface + icon: badgeData.icon || "" + pointSize: { + if (badgeData.size === "xsmall") + return Style.fontSizeXXS; + else if (badgeData.size === "medium") + return Style.fontSizeM; + else + return Style.fontSizeXS; } + color: highlighted ? Color.mOnHover : (badgeData.color || Color.mOnSurface) + Layout.preferredWidth: Style.baseWidgetSize * 0.6 + Layout.preferredHeight: Style.baseWidgetSize * 0.6 + visible: badgeData && badgeData.icon !== undefined && badgeData.icon !== "" } } } From b927af6d950f708a35b59b8e890a288a022a99b7 Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Wed, 3 Dec 2025 09:38:12 -0500 Subject: [PATCH 28/28] i18n: fix bar tab widget translation --- Assets/Translations/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Translations/en.json b/Assets/Translations/en.json index 931b7f3f6..d3b5396a7 100644 --- a/Assets/Translations/en.json +++ b/Assets/Translations/en.json @@ -977,7 +977,7 @@ }, "widgets": { "section": { - "description": "Drag and drop widgets to reorder them. Badges indicate usage: [L]eft, [C]enter, [R]ight.", + "description": "Rearrange widgets by dragging. Right-click to manage.", "label": "Widgets positioning" } }