Files
noctalia-shell/Modules/DesktopWidgets/DesktopWidgets.qml
T
2025-12-21 18:18:04 -05:00

336 lines
12 KiB
QML

import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Services.Compositor
import qs.Services.Noctalia
import qs.Services.Power
import qs.Services.UI
import qs.Widgets
Variants {
id: root
model: Quickshell.screens
// Direct binding to registry's widgets property for reactivity
readonly property var registeredWidgets: DesktopWidgetRegistry.widgets
// Force reload counter - incremented when plugin widget registry changes
property int pluginReloadCounter: 0
Connections {
target: DesktopWidgetRegistry
function onPluginWidgetRegistryUpdated() {
root.pluginReloadCounter++;
Logger.d("DesktopWidgets", "Plugin widget registry updated, reload counter:", root.pluginReloadCounter);
}
}
delegate: Loader {
id: screenLoader
required property ShellScreen modelData
// Reactive property for widgets on this specific screen
property var screenWidgets: {
if (!modelData || !modelData.name) {
return [];
}
var monitorWidgets = Settings.data.desktopWidgets.monitorWidgets || [];
for (var i = 0; i < monitorWidgets.length; i++) {
if (monitorWidgets[i].name === modelData.name) {
return monitorWidgets[i].widgets || [];
}
}
return [];
}
// Only create PanelWindow if enabled AND screen has widgets
active: modelData && Settings.data.desktopWidgets.enabled && screenWidgets.length > 0 && !PowerProfileService.noctaliaPerformanceMode
sourceComponent: PanelWindow {
id: window
color: Color.transparent
screen: screenLoader.modelData
WlrLayershell.layer: WlrLayer.Bottom
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "noctalia-desktop-widgets-" + (screen?.name || "unknown")
anchors {
top: true
bottom: true
right: true
left: true
}
Component.onCompleted: {
Logger.d("DesktopWidgets", "Created panel window for", screen?.name);
}
Item {
id: widgetsContainer
anchors.fill: parent
// Visual grid overlay - shown when grid snap is enabled in edit mode
// Using Loader to properly unload Canvas when not needed
Loader {
id: gridOverlayLoader
active: Settings.data.desktopWidgets.editMode && Settings.data.desktopWidgets.enabled && Settings.data.desktopWidgets.gridSnap
anchors.fill: parent
z: -1 // Behind widgets but above background
asynchronous: false
sourceComponent: Canvas {
id: gridOverlay
anchors.fill: parent
opacity: 0.3
// Grid size calculated based on screen resolution - matches DraggableDesktopWidget
// Ensures grid lines pass through the screen center on both axes
readonly property int gridSize: {
if (!window.screen)
return 30; // Fallback
var baseSize = Math.round(window.screen.width * 0.015);
baseSize = Math.max(20, Math.min(60, baseSize));
// Calculate center coordinates
var centerX = window.screen.width / 2;
var centerY = window.screen.height / 2;
// Find a grid size that divides evenly into both center coordinates
// This ensures a grid line crosses through the center on both axes
var bestSize = baseSize;
var bestDistance = Infinity;
// Try values around baseSize to find one that divides evenly into both centers
for (var offset = -10; offset <= 10; offset++) {
var candidate = baseSize + offset;
if (candidate < 20 || candidate > 60)
continue;
// Check if this size divides evenly into both center coordinates
var remainderX = centerX % candidate;
var remainderY = centerY % candidate;
// If both remainders are 0, this is perfect - center is on grid lines
if (remainderX === 0 && remainderY === 0) {
return candidate; // Perfect match, use it immediately
}
// Otherwise, find the closest to perfect alignment
var distance = Math.abs(remainderX) + Math.abs(remainderY);
if (distance < bestDistance) {
bestDistance = distance;
bestSize = candidate;
}
}
// If we found a perfect match, it would have returned already
// Otherwise, try to find a divisor of both centerX and centerY
// that's close to our best size
var gcd = function (a, b) {
while (b !== 0) {
var temp = b;
b = a % b;
a = temp;
}
return a;
};
// Find common divisors of centerX and centerY
var centerGcd = gcd(Math.round(centerX), Math.round(centerY));
if (centerGcd > 0) {
// Find a divisor of centerGcd that's close to bestSize
for (var divisor = Math.floor(centerGcd / 60); divisor <= Math.ceil(centerGcd / 20); divisor++) {
if (centerGcd % divisor !== 0)
continue;
var candidate = centerGcd / divisor;
if (candidate >= 20 && candidate <= 60) {
if (Math.abs(candidate - baseSize) < Math.abs(bestSize - baseSize)) {
bestSize = candidate;
}
}
}
}
return bestSize;
}
onPaint: {
const ctx = getContext("2d");
ctx.reset();
ctx.strokeStyle = Color.mPrimary;
ctx.lineWidth = 1;
// Draw vertical lines
for (let x = 0; x <= width; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
// Draw horizontal lines
for (let y = 0; y <= height; y += gridSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
}
// Repaint when size changes
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
Component.onCompleted: {
requestPaint();
}
Connections {
target: Settings.data.desktopWidgets
function onGridSnapChanged() {
if (gridOverlayLoader.active) {
gridOverlay.requestPaint();
}
}
function onEditModeChanged() {
if (gridOverlayLoader.active) {
gridOverlay.requestPaint();
}
}
}
}
}
// Load widgets dynamically from per-monitor array
Repeater {
model: screenLoader.screenWidgets
delegate: Loader {
id: widgetLoader
// Bind to registeredWidgets and pluginReloadCounter to re-evaluate when plugins register/unregister
active: (modelData.id in root.registeredWidgets) && (root.pluginReloadCounter >= 0)
property var widgetData: modelData
property int widgetIndex: index
sourceComponent: {
// Access registeredWidgets and pluginReloadCounter to create reactive binding
var _ = root.pluginReloadCounter;
var widgets = root.registeredWidgets;
return widgets[modelData.id] || null;
}
onLoaded: {
if (item) {
item.screen = window.screen;
item.parent = widgetsContainer;
item.widgetData = widgetData;
item.widgetIndex = widgetIndex;
// Inject plugin API for plugin widgets
if (DesktopWidgetRegistry.isPluginWidget(modelData.id)) {
var pluginId = modelData.id.replace("plugin:", "");
var api = PluginService.getPluginAPI(pluginId);
if (api && item.hasOwnProperty("pluginApi")) {
item.pluginApi = api;
}
}
}
}
}
}
// Edit mode controls panel
Rectangle {
id: editModeControlsPanel
visible: Settings.data.desktopWidgets.editMode && Settings.data.desktopWidgets.enabled
readonly property string barPos: Settings.data.bar.position || "top"
readonly property bool barFloating: Settings.data.bar.floating || false
readonly property int barOffsetTop: {
if (barPos !== "top")
return Style.marginM;
const floatMarginV = barFloating ? Math.ceil(Settings.data.bar.marginVertical * Style.marginXL) : 0;
return Style.barHeight + floatMarginV + Style.marginM;
}
readonly property int barOffsetRight: {
if (barPos !== "right")
return Style.marginM;
const floatMarginH = barFloating ? Math.ceil(Settings.data.bar.marginHorizontal * Style.marginXL) : 0;
return Style.barHeight + floatMarginH + Style.marginM;
}
anchors {
top: parent.top
right: parent.right
topMargin: barOffsetTop
rightMargin: barOffsetRight
}
width: controlsLayout.implicitWidth + (Style.marginXL * 2)
height: controlsLayout.implicitHeight + (Style.marginXL * 2)
color: Qt.rgba(Color.mSurface.r, Color.mSurface.g, Color.mSurface.b, 0.85)
radius: Style.radiusL
border {
width: 1
color: Qt.alpha(Color.mOutline, 0.2)
}
z: 9999
ColumnLayout {
id: controlsLayout
anchors {
fill: parent
margins: Style.marginXL
}
spacing: Style.marginL
NButton {
Layout.alignment: Qt.AlignRight
text: I18n.tr("settings.desktop-widgets.edit-mode.exit-button")
icon: "logout"
outlined: false
fontSize: Style.fontSizeM * 1.1
iconSize: Style.fontSizeL * 1.1
onClicked: Settings.data.desktopWidgets.editMode = false
}
NText {
Layout.alignment: Qt.AlignRight
Layout.maximumWidth: 300 * Style.uiScaleRatio
text: I18n.tr("settings.desktop-widgets.edit-mode.controls-explanation")
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
horizontalAlignment: Text.AlignRight
wrapMode: Text.WordWrap
}
RowLayout {
Layout.alignment: Qt.AlignRight
spacing: Style.marginS
NText {
text: I18n.tr("settings.desktop-widgets.edit-mode.grid-snap.label")
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
}
NCheckbox {
checked: Settings.data.desktopWidgets.gridSnap
onToggled: checked => Settings.data.desktopWidgets.gridSnap = checked
}
}
}
}
}
}
}
}