Merge plugin-system

This commit is contained in:
ItsLemmy
2025-12-03 09:41:16 -05:00
24 changed files with 2819 additions and 142 deletions
+68 -1
View File
@@ -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": {
+11 -5
View File
@@ -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,
+2 -1
View File
@@ -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
View File
@@ -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"];
// -----------------
+28
View File
@@ -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);
+2 -1
View File
@@ -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
+22 -15
View File
@@ -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
}
// ----------------------------------------------
+14 -8
View File
@@ -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 {
+175
View File
@@ -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);
}
}
+11
View File
@@ -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",
+57 -6
View File
@@ -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
}
}
+645
View File
@@ -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;
});
});
}
}
}
+1
View File
@@ -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
+403
View File
@@ -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
};
}
}
+878
View File
@@ -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;
}
}
+56
View File
@@ -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);
}
}
+7 -9
View File
@@ -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 !== ""
+162
View File
@@ -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();
}
}
+18 -11
View File
@@ -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 !== ""
}
}
}
+160 -84
View File
@@ -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,51 +349,55 @@ NBox {
colorBgHover: Qt.alpha(Color.mOnPrimary, Style.opacityLight)
colorFgHover: Color.mOnPrimary
onClicked: {
var component = Qt.createComponent(Qt.resolvedUrl(root.settingsDialogComponent));
function instantiateAndOpen() {
var dialog = component.createObject(Overlay.overlay, {
"widgetIndex": index,
"widgetData": modelData,
"widgetId": modelData.id,
"sectionId": root.sectionId
});
if (dialog) {
dialog.updateWidgetSettings.connect(root.updateWidgetSettings);
dialog.open();
// Check if this is a plugin widget
var isPlugin = root.widgetRegistry && root.widgetRegistry.isPluginWidget(modelData.id);
if (isPlugin) {
// Handle plugin settings - emit signal for parent to handle
var pluginId = modelData.id.replace("plugin:", "");
var manifest = PluginRegistry.getPluginManifest(pluginId);
if (!manifest || !manifest.entryPoints?.settings) {
Logger.e("NSectionEditor", "Plugin settings not found for:", pluginId);
return;
}
// Emit signal to request opening plugin settings
root.openPluginSettingsRequested(manifest);
} else {
// Handle core widget settings
var component = Qt.createComponent(Qt.resolvedUrl(root.settingsDialogComponent));
function instantiateAndOpen() {
var dialog = component.createObject(Overlay.overlay, {
"widgetIndex": index,
"widgetData": modelData,
"widgetId": modelData.id,
"sectionId": root.sectionId
});
if (dialog) {
dialog.updateWidgetSettings.connect(root.updateWidgetSettings);
dialog.open();
} else {
Logger.e("NSectionEditor", "Failed to create settings dialog instance");
}
}
if (component.status === Component.Ready) {
instantiateAndOpen();
} else if (component.status === Component.Error) {
Logger.e("NSectionEditor", component.errorString());
} else {
Logger.e("NSectionEditor", "Failed to create settings dialog instance");
component.statusChanged.connect(function () {
if (component.status === Component.Ready) {
instantiateAndOpen();
} else if (component.status === Component.Error) {
Logger.e("NSectionEditor", component.errorString());
}
});
}
}
if (component.status === Component.Ready) {
instantiateAndOpen();
} else if (component.status === Component.Error) {
Logger.e("NSectionEditor", component.errorString());
} else {
component.statusChanged.connect(function () {
if (component.status === Component.Ready) {
instantiateAndOpen();
} else if (component.status === Component.Error) {
Logger.e("NSectionEditor", component.errorString());
}
});
}
}
}
}
NIconButton {
icon: "close"
tooltipText: I18n.tr("tooltips.remove-widget")
baseSize: miniButtonSize
colorBorder: Qt.alpha(Color.mOutline, Style.opacityLight)
colorBg: Color.mOnSurface
colorFg: Color.mOnPrimary
colorBgHover: Qt.alpha(Color.mOnPrimary, Style.opacityLight)
colorFgHover: Color.mOnPrimary
onClicked: {
removeWidget(sectionId, index);
}
}
}
}
}
@@ -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;
+13 -1
View File
@@ -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;
}
}
}
}