mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
Merge plugin-system
This commit is contained in:
@@ -3,6 +3,13 @@
|
||||
"error": "Authentication error",
|
||||
"failed": "Authentication failed"
|
||||
},
|
||||
"common": {
|
||||
"add": "Add",
|
||||
"cancel": "Cancel",
|
||||
"apply": "Apply",
|
||||
"save": "Save",
|
||||
"close": "Close"
|
||||
},
|
||||
"bar": {
|
||||
"widget-settings": {
|
||||
"active-window": {
|
||||
@@ -970,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"
|
||||
}
|
||||
}
|
||||
@@ -1742,6 +1749,66 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"installed": {
|
||||
"description": "Manage and configure all locally installed plugins.",
|
||||
"label": "Installed plugins",
|
||||
"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-label": "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: {error}",
|
||||
"install-success": "Successfully installed {plugin}",
|
||||
"installing": "Installing {plugin}...",
|
||||
"plugin-settings-title": "{plugin} Settings",
|
||||
"refresh": {
|
||||
"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": {
|
||||
"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 {plugin}? This will remove all plugin data.",
|
||||
"title": "Uninstall plugin"
|
||||
},
|
||||
"uninstall-error": "Failed to uninstall: {error}",
|
||||
"uninstall-success": "Successfully uninstalled {plugin}",
|
||||
"uninstall.tooltip": "Uninstall plugin",
|
||||
"uninstalling": "Uninstalling {plugin}..."
|
||||
},
|
||||
"screen-recorder": {
|
||||
"audio": {
|
||||
"audio-codec": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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 {
|
||||
@@ -715,6 +716,12 @@ Singleton {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for PluginService to finish loading plugin widgets
|
||||
if (!PluginService.pluginsFullyLoaded) {
|
||||
Qt.callLater(upgradeSettingsData);
|
||||
return;
|
||||
}
|
||||
|
||||
const sections = ["left", "center", "right"];
|
||||
|
||||
// -----------------
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Noctalia
|
||||
import qs.Services.UI
|
||||
|
||||
Item {
|
||||
@@ -34,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
|
||||
@@ -58,6 +75,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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// ----------------------------------------------
|
||||
|
||||
@@ -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) {
|
||||
@@ -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;
|
||||
@@ -715,8 +721,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 {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
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
|
||||
|
||||
// Reference to the plugin content loader (set when panel content is created)
|
||||
property var contentLoader: null
|
||||
|
||||
// Panel content is dynamically loaded
|
||||
panelContent: Component {
|
||||
Item {
|
||||
id: panelContainer
|
||||
|
||||
// Required by SmartPanel for background rendering geometry
|
||||
readonly property var geometryPlaceholder: pluginContentItem
|
||||
|
||||
// Panel properties expected by SmartPanel
|
||||
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: {
|
||||
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
|
||||
|
||||
// Dynamic plugin content
|
||||
Item {
|
||||
id: pluginContentItem
|
||||
anchors.fill: parent
|
||||
|
||||
Loader {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.entryPoints || !plugin.manifest.entryPoints.panel) {
|
||||
Logger.w("PluginPanelSlot", "Plugin does not provide a panel:", pluginId);
|
||||
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;
|
||||
|
||||
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);
|
||||
|
||||
// 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) {
|
||||
if (root.contentLoader.item.hasOwnProperty("pluginApi")) {
|
||||
root.contentLoader.item.pluginApi = api;
|
||||
}
|
||||
|
||||
root.pluginInstance = root.contentLoader.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);
|
||||
|
||||
if (root.contentLoader) {
|
||||
root.contentLoader.active = false;
|
||||
root.contentLoader.sourceComponent = null;
|
||||
}
|
||||
root.pluginInstance = null;
|
||||
root.currentPluginId = "";
|
||||
}
|
||||
|
||||
// Register with PanelService
|
||||
Component.onCompleted: {
|
||||
PanelService.registerPanel(root);
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ SmartPanel {
|
||||
Location,
|
||||
Network,
|
||||
Notifications,
|
||||
Plugins,
|
||||
ScreenRecorder,
|
||||
SessionMenu,
|
||||
SystemMonitor,
|
||||
@@ -173,6 +174,10 @@ SmartPanel {
|
||||
id: systemMonitorTab
|
||||
SystemMonitorTab {}
|
||||
}
|
||||
Component {
|
||||
id: pluginsTab
|
||||
PluginsTab {}
|
||||
}
|
||||
|
||||
// Order *DOES* matter
|
||||
function updateTabsModel() {
|
||||
@@ -285,6 +290,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",
|
||||
|
||||
@@ -4,6 +4,7 @@ import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Compositor
|
||||
import qs.Services.Noctalia
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
@@ -219,6 +220,9 @@ ColumnLayout {
|
||||
|
||||
NHeader {
|
||||
label: I18n.tr("settings.bar.widgets.section.label")
|
||||
}
|
||||
|
||||
NLabel {
|
||||
description: I18n.tr("settings.bar.widgets.section.description")
|
||||
}
|
||||
|
||||
@@ -242,6 +246,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
|
||||
@@ -257,6 +262,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
|
||||
@@ -272,6 +278,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -404,24 +411,61 @@ 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() {
|
||||
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,
|
||||
"badges": createBadges(isPlugin, getWidgetLocations(entry))
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -441,4 +485,11 @@ ColumnLayout {
|
||||
updateAvailableWidgetsModel();
|
||||
}
|
||||
}
|
||||
|
||||
// Shared Plugin Settings Popup
|
||||
NPluginSettingsPopup {
|
||||
id: pluginSettingsDialog
|
||||
parent: Overlay.overlay
|
||||
showToastOnSave: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
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: NBox {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: rowLayout.implicitHeight + Style.marginL * 2
|
||||
color: Color.mSurface
|
||||
|
||||
RowLayout {
|
||||
id: rowLayout
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "settings"
|
||||
tooltipText: I18n.tr("settings.plugins.settings.tooltip")
|
||||
baseSize: Style.baseWidgetSize * 0.7
|
||||
visible: modelData.entryPoints?.settings !== undefined
|
||||
onClicked: {
|
||||
pluginSettingsDialog.openPluginSettings(modelData);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NLabel {
|
||||
visible: PluginRegistry.getAllInstalledPluginIds().length === 0
|
||||
label: I18n.tr("settings.plugins.installed.no-plugins-label")
|
||||
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: 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// ------------------------------
|
||||
|
||||
// Add source dialog
|
||||
Popup {
|
||||
id: addSourceDialog
|
||||
modal: true
|
||||
dim: false
|
||||
anchors.centerIn: parent
|
||||
width: 500
|
||||
padding: Style.marginL
|
||||
|
||||
background: Rectangle {
|
||||
color: Color.mSurface
|
||||
radius: Style.radiusS
|
||||
border.color: Color.mPrimary
|
||||
border.width: Style.borderM
|
||||
}
|
||||
|
||||
contentItem: 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.showNotice(I18n.tr("settings.plugins.sources.add-dialog.success"));
|
||||
PluginService.refreshAvailablePlugins();
|
||||
addSourceDialog.close();
|
||||
sourceNameInput.text = "";
|
||||
sourceUrlInput.text = "";
|
||||
} else {
|
||||
ToastService.showNotice(I18n.tr("settings.plugins.sources.add-dialog.error"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Uninstall confirmation dialog
|
||||
Popup {
|
||||
id: uninstallDialog
|
||||
modal: true
|
||||
dim: false
|
||||
anchors.centerIn: parent
|
||||
width: 400 * Style.uiScaleRatio
|
||||
padding: Style.marginL
|
||||
|
||||
property var pluginToUninstall: null
|
||||
|
||||
background: Rectangle {
|
||||
color: Color.mSurface
|
||||
radius: Style.radiusS
|
||||
border.color: Color.mPrimary
|
||||
border.width: Style.borderM
|
||||
}
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: Style.marginL
|
||||
|
||||
NHeader {
|
||||
label: I18n.tr("settings.plugins.uninstall-dialog.title")
|
||||
description: I18n.tr("settings.plugins.uninstall-dialog.description", {
|
||||
"plugin": 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) {
|
||||
root.uninstallPlugin(uninstallDialog.pluginToUninstall.id);
|
||||
uninstallDialog.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin settings popup
|
||||
NPluginSettingsPopup {
|
||||
id: pluginSettingsDialog
|
||||
parent: Overlay.overlay
|
||||
showToastOnSave: true
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Functions
|
||||
// ------------------------------
|
||||
|
||||
function installPlugin(pluginMetadata) {
|
||||
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", {
|
||||
"plugin": pluginMetadata.name
|
||||
}));
|
||||
// Auto-enable the plugin after installation
|
||||
PluginService.enablePlugin(pluginMetadata.id);
|
||||
} else {
|
||||
ToastService.showNotice(I18n.tr("settings.plugins.install-error", {
|
||||
"error": error || "Unknown error"
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function uninstallPlugin(pluginId) {
|
||||
var manifest = PluginRegistry.getPluginManifest(pluginId);
|
||||
var pluginName = manifest?.name || pluginId;
|
||||
|
||||
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", {
|
||||
"plugin": pluginName
|
||||
}));
|
||||
} else {
|
||||
ToastService.showNotice(I18n.tr("settings.plugins.uninstall-error", {
|
||||
"error": 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;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
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<var> sources: []
|
||||
}
|
||||
|
||||
onLoaded: {
|
||||
Logger.i("PluginRegistry", "Loaded plugin states from:", path);
|
||||
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();
|
||||
}
|
||||
|
||||
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: {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
// 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];
|
||||
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.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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,878 @@
|
||||
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
|
||||
|
||||
// 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
|
||||
|
||||
function onPluginsChanged() {
|
||||
if (!root.initialized) {
|
||||
root.init();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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");
|
||||
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 + "/refs/heads/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.stdout.onStreamFinished.connect(function () {
|
||||
var response = fetchProcess.stdout.text;
|
||||
|
||||
// Debug: log the raw response
|
||||
Logger.d("PluginService", "Registry response length:", response ? response.length : 0);
|
||||
|
||||
if (!response || response.trim() === "") {
|
||||
Logger.e("PluginService", "Empty response from", source.name);
|
||||
delete activeFetches[source.url];
|
||||
fetchProcess.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 --wildcards '*/${pluginPath}/*' ||
|
||||
wget -q -O- '${repoUrl}/archive/refs/heads/main.tar.gz' | tar -xz --strip-components=2 --wildcards '*/${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);
|
||||
|
||||
// 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
|
||||
});
|
||||
root.pluginEnabled(pluginId);
|
||||
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)) {
|
||||
Logger.w("PluginService", "Plugin already disabled:", pluginId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove plugin widget from bar before unloading
|
||||
var widgetId = "plugin:" + pluginId;
|
||||
removeWidgetFromBar(widgetId);
|
||||
|
||||
PluginRegistry.setPluginEnabled(pluginId, false);
|
||||
unloadPlugin(pluginId);
|
||||
updatePluginInAvailable(pluginId, {
|
||||
enabled: false
|
||||
});
|
||||
root.pluginDisabled(pluginId);
|
||||
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]) {
|
||||
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);
|
||||
|
||||
// Initialize plugin entry with API and manifest
|
||||
root.loadedPlugins[pluginId] = {
|
||||
barWidgetComponent: null,
|
||||
mainInstance: 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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
|
||||
// Load bar widget component if provided (don't instantiate - BarWidgetRegistry will do that)
|
||||
if (manifest.entryPoints && 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
|
||||
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.entryPoints && plugin.manifest.entryPoints.barWidget) {
|
||||
BarWidgetRegistry.unregisterPluginWidget(pluginId);
|
||||
}
|
||||
|
||||
// Destroy Main instance if any
|
||||
if (plugin.mainInstance) {
|
||||
plugin.mainInstance.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: ({})
|
||||
property var manifest: ({})
|
||||
|
||||
// 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);
|
||||
|
||||
// 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) {
|
||||
// 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 (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;
|
||||
};
|
||||
|
||||
// ----------------------------------------
|
||||
// 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);
|
||||
|
||||
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);
|
||||
|
||||
// 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, "'\\''");
|
||||
|
||||
// Get parent directory and ensure it exists
|
||||
var settingsDir = settingsFile.substring(0, settingsFile.lastIndexOf('/'));
|
||||
var dirEsc = settingsDir.replace(/'/g, "'\\''");
|
||||
|
||||
// Build the shell command with heredoc (create dir first)
|
||||
var writeCmd = "mkdir -p '" + dirEsc + "' && cat > '" + fileEsc + "' << '" + delimiter + "'\n" + settingsJson + "\n" + delimiter + "\n";
|
||||
|
||||
Logger.d("PluginService", "Saving settings to:", settingsFile);
|
||||
Logger.d("PluginService", "Settings JSON:", settingsJson);
|
||||
|
||||
// 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
|
||||
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];
|
||||
}
|
||||
|
||||
// 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.entryPoints || !plugin.manifest.entryPoints.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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -22,12 +24,8 @@ ColumnLayout {
|
||||
id: headerContainer
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: headerContent.implicitHeight + (Style.marginM * 2)
|
||||
|
||||
// Material 3 style background
|
||||
color: root.expanded ? Color.mSecondary : Color.mSurfaceVariant
|
||||
radius: Style.iRadiusL
|
||||
|
||||
// Subtle border
|
||||
color: root.expanded ? Color.mSecondary : Color.mPrimary
|
||||
radius: Style.iRadiusM
|
||||
border.color: root.expanded ? Color.mOnSecondary : Color.mOutline
|
||||
border.width: Style.borderS
|
||||
|
||||
@@ -83,7 +81,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 +109,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 +123,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 !== ""
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -219,23 +219,30 @@ RowLayout {
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
spacing: Style.marginS
|
||||
spacing: 0
|
||||
Layout.alignment: Qt.AlignRight
|
||||
|
||||
// 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 !== ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+134
-58
@@ -3,6 +3,7 @@ import QtQuick.Controls
|
||||
import QtQuick.Effects
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Services.Noctalia
|
||||
import qs.Widgets
|
||||
|
||||
NBox {
|
||||
@@ -18,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)
|
||||
@@ -28,9 +31,18 @@ NBox {
|
||||
signal moveWidget(string fromSection, int index, string toSection)
|
||||
signal dragPotentialStarted
|
||||
signal dragPotentialEnded
|
||||
signal openPluginSettingsRequested(var pluginManifest)
|
||||
|
||||
color: Color.mSurface
|
||||
Layout.fillWidth: true
|
||||
|
||||
// Calculate width to fit gridColumns widgets with spacing
|
||||
function calculateWidgetWidth(gridWidth) {
|
||||
var columnSpacing = (root.gridColumns - 1) * Style.marginM;
|
||||
var widgetWidth = (gridWidth - columnSpacing) / root.gridColumns;
|
||||
return Math.floor(widgetWidth);
|
||||
}
|
||||
|
||||
Layout.minimumHeight: {
|
||||
// header + minimal content area
|
||||
var absoluteMin = (Style.marginL * 2) + (Style.fontSizeL * 2) + Style.marginM + (65 * Style.uiScaleRatio);
|
||||
@@ -40,39 +52,48 @@ 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 rows = Math.ceil(widgetCount / root.gridColumns);
|
||||
|
||||
// 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.marginM;
|
||||
|
||||
return Math.max(absoluteMin, (Style.marginL * 2) + headerHeight + Style.marginM + widgetAreaHeight);
|
||||
}
|
||||
|
||||
// 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)
|
||||
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 {
|
||||
@@ -152,18 +173,35 @@ 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 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;
|
||||
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: root.gridColumns
|
||||
rowSpacing: Style.marginS
|
||||
columnSpacing: Style.marginM
|
||||
|
||||
Repeater {
|
||||
id: widgetRepeater
|
||||
model: widgetModel
|
||||
|
||||
delegate: Rectangle {
|
||||
@@ -171,8 +209,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
|
||||
@@ -181,7 +219,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
|
||||
@@ -222,26 +260,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);
|
||||
}
|
||||
}
|
||||
@@ -249,24 +298,47 @@ NBox {
|
||||
}
|
||||
RowLayout {
|
||||
id: widgetContent
|
||||
anchors.centerIn: parent
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginXS
|
||||
anchors.rightMargin: Style.marginS
|
||||
spacing: Style.marginXXS
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// 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")
|
||||
@@ -277,6 +349,23 @@ NBox {
|
||||
colorBgHover: Qt.alpha(Color.mOnPrimary, Style.opacityLight)
|
||||
colorFgHover: Color.mOnPrimary
|
||||
onClicked: {
|
||||
// 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, {
|
||||
@@ -308,19 +397,6 @@ NBox {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -356,7 +432,7 @@ NBox {
|
||||
width: 3
|
||||
height: Style.baseWidgetSize * 1.15
|
||||
radius: Style.iRadiusXXS
|
||||
color: Color.mPrimary
|
||||
color: Color.mSecondary
|
||||
opacity: 0
|
||||
visible: opacity > 0
|
||||
z: 1999
|
||||
@@ -427,7 +503,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;
|
||||
|
||||
@@ -452,9 +528,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;
|
||||
@@ -506,7 +582,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;
|
||||
|
||||
@@ -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 {
|
||||
@@ -114,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user