Files

548 lines
20 KiB
QML

import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Modules.Panels.Settings
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
// Returns a fresh array whenever Settings changes
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 OR in edit mode)
// During compositor overview, show widgets only when overviewEnabled is true.
active: modelData && Settings.data.desktopWidgets.enabled && (screenWidgets.length > 0 || DesktopWidgetRegistry.editMode) && (!CompositorService.overviewActive || Settings.data.desktopWidgets.overviewEnabled) && (!PowerProfileService.noctaliaPerformanceMode || !Settings.data.noctaliaPerformance.disableDesktopWidgets) && !PanelService.lockScreen?.active
sourceComponent: PanelWindow {
id: window
color: "transparent"
screen: screenLoader.modelData
mask: DesktopWidgetRegistry.editMode ? null : widgetsMask
// Dynamic mask: combine clickable regions for each loaded widget
property var _maskRegions: []
Component {
id: maskRegionComponent
Region {}
}
Region {
id: widgetsMask
regions: window._maskRegions
}
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);
}
// Add a new widget to the current screen
function addWidgetToCurrentScreen(widgetId) {
var monitorName = window.screen.name;
var newWidget = {
"id": widgetId
};
// Load default metadata if available
var metadata = DesktopWidgetRegistry.widgetMetadata[widgetId];
if (metadata) {
Object.keys(metadata).forEach(function (key) {
newWidget[key] = metadata[key];
});
}
// Place at screen center
newWidget.x = (window.screen.width / 2) - 100;
newWidget.y = (window.screen.height / 2) - 100;
newWidget.scale = 1.0;
// Get current widgets and add new one
var monitorWidgets = Settings.data.desktopWidgets.monitorWidgets || [];
var newMonitorWidgets = monitorWidgets.slice();
var found = false;
for (var i = 0; i < newMonitorWidgets.length; i++) {
if (newMonitorWidgets[i].name === monitorName) {
var widgets = (newMonitorWidgets[i].widgets || []).slice();
widgets.push(newWidget);
newMonitorWidgets[i] = {
"name": monitorName,
"widgets": widgets
};
found = true;
break;
}
}
if (!found) {
newMonitorWidgets.push({
"name": monitorName,
"widgets": [newWidget]
});
}
Settings.data.desktopWidgets.monitorWidgets = newMonitorWidgets;
Logger.i("DesktopWidgets", "Added widget", widgetId, "to", monitorName);
}
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: DesktopWidgetRegistry.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();
}
}
}
Connections {
target: DesktopWidgetRegistry
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)
required property var modelData
required property int index
property var _maskRegion: null
readonly property bool _isPlugin: DesktopWidgetRegistry.isPluginWidget(modelData.id)
// All widgets use setSource() so that screen, widgetData, and
// widgetIndex are set as initial properties, available during
// Component.onCompleted. This prevents registration-key
// mismatches in widgets that build IDs from screen.name.
Component.onCompleted: _loadWidget()
onActiveChanged: {
if (active)
_loadWidget();
}
function _loadWidget() {
var widgetId = modelData.id;
var comp = root.registeredWidgets[widgetId];
if (!comp)
return;
var props = {
"screen": window.screen,
"widgetData": modelData,
"widgetIndex": index
};
if (_isPlugin) {
var pluginId = widgetId.replace("plugin:", "");
var api = PluginService.getPluginAPI(pluginId);
if (api)
props.pluginApi = api;
setSource(comp.url, props);
} else {
// Core widgets: use explicit URL (inline Component.url
// returns the registry file, not the widget file)
var url = DesktopWidgetRegistry.widgetUrls[widgetId];
if (url)
setSource(url, props);
}
}
onLoaded: {
if (item) {
item.parent = widgetsContainer;
// Create mask region so this widget receives mouse input
_maskRegion = maskRegionComponent.createObject(window);
_maskRegion.item = item;
var newRegions = window._maskRegions.slice();
newRegions.push(_maskRegion);
window._maskRegions = newRegions;
}
}
// Clean up mask region when widget unloads
onItemChanged: {
if (!item && _maskRegion) {
var region = _maskRegion;
_maskRegion = null;
window._maskRegions = window._maskRegions.filter(function (r) {
return r !== region;
});
region.destroy();
}
}
}
}
// Edit mode controls panel
Rectangle {
id: editModeControlsPanel
visible: DesktopWidgetRegistry.editMode && Settings.data.desktopWidgets.enabled
readonly property string barPos: Settings.getBarPositionForScreen(window.screen?.name)
readonly property bool barFloating: Settings.data.bar.barType === "floating"
readonly property real barHeight: Style.getBarHeightForScreen(window.screen?.name)
readonly property int barOffsetTop: {
if (barPos !== "top")
return Style.marginM;
const floatMarginV = barFloating ? Math.ceil(Settings.data.bar.marginVertical) : 0;
return barHeight + floatMarginV + Style.marginM;
}
readonly property int barOffsetRight: {
if (barPos !== "right")
return Style.marginM;
const floatMarginH = barFloating ? Math.ceil(Settings.data.bar.marginHorizontal) : 0;
return barHeight + floatMarginH + Style.marginM;
}
// Internal state for drag tracking (session-only, resets on restart)
QtObject {
id: panelInternal
property bool isDragging: false
property real dragOffsetX: 0
property real dragOffsetY: 0
// Default position: top-right corner accounting for bar
property real baseX: widgetsContainer.width - editModeControlsPanel.width - editModeControlsPanel.barOffsetRight
property real baseY: editModeControlsPanel.barOffsetTop
}
// Reset position when bar position changes
Connections {
target: Settings.data.bar
function onPositionChanged() {
panelInternal.baseX = widgetsContainer.width - editModeControlsPanel.width - editModeControlsPanel.barOffsetRight;
panelInternal.baseY = editModeControlsPanel.barOffsetTop;
}
}
x: panelInternal.isDragging ? panelInternal.dragOffsetX : panelInternal.baseX
y: panelInternal.isDragging ? panelInternal.dragOffsetY : panelInternal.baseY
width: controlsLayout.implicitWidth + Style.margin2XL
height: controlsLayout.implicitHeight + Style.margin2XL
color: Qt.rgba(Color.mSurface.r, Color.mSurface.g, Color.mSurface.b, 0.85)
radius: Style.radiusL
border {
width: Style.borderS
color: Color.mOutline
}
z: 9999
// Drag area for relocating the panel
MouseArea {
id: dragArea
anchors.fill: parent
cursorShape: panelInternal.isDragging ? Qt.ClosedHandCursor : Qt.OpenHandCursor
property point pressPos: Qt.point(0, 0)
onPressed: mouse => {
pressPos = mapToItem(widgetsContainer, mouse.x, mouse.y);
panelInternal.dragOffsetX = editModeControlsPanel.x;
panelInternal.dragOffsetY = editModeControlsPanel.y;
panelInternal.isDragging = true;
}
onPositionChanged: mouse => {
if (panelInternal.isDragging && pressed) {
var currentPos = mapToItem(widgetsContainer, mouse.x, mouse.y);
var deltaX = currentPos.x - pressPos.x;
var deltaY = currentPos.y - pressPos.y;
var newX = panelInternal.baseX + deltaX;
var newY = panelInternal.baseY + deltaY;
// Boundary clamping
newX = Math.max(0, Math.min(newX, widgetsContainer.width - editModeControlsPanel.width));
newY = Math.max(0, Math.min(newY, widgetsContainer.height - editModeControlsPanel.height));
panelInternal.dragOffsetX = newX;
panelInternal.dragOffsetY = newY;
}
}
onReleased: {
if (panelInternal.isDragging) {
panelInternal.baseX = panelInternal.dragOffsetX;
panelInternal.baseY = panelInternal.dragOffsetY;
panelInternal.isDragging = false;
}
}
onCanceled: {
panelInternal.isDragging = false;
}
}
ColumnLayout {
id: controlsLayout
anchors {
fill: parent
margins: Style.marginXL
}
spacing: Style.marginL
RowLayout {
Layout.alignment: Qt.AlignRight
spacing: Style.marginS
NIconButton {
id: addWidgetButton
icon: "layout-grid-add"
tooltipText: I18n.tr("tooltips.add-widget")
onClicked: {
var popupMenuWindow = PanelService.getPopupMenuWindow(window.screen);
if (popupMenuWindow) {
// Build menu items from registry
var items = [];
var widgets = DesktopWidgetRegistry.widgets;
for (var id in widgets) {
items.push({
action: id,
text: DesktopWidgetRegistry.getWidgetDisplayName(id),
icon: "layout-grid-add"
});
}
var globalPos = addWidgetButton.mapToItem(null, 0, addWidgetButton.height + Style.marginS);
popupMenuWindow.showDynamicContextMenu(items, globalPos.x, globalPos.y, function (widgetId) {
addWidgetToCurrentScreen(widgetId);
return false;
});
}
}
}
NIconButton {
icon: "grid-3x3"
visible: Settings.data.desktopWidgets.gridSnap
tooltipText: I18n.tr("panels.desktop-widgets.edit-mode-grid-snap-scale-label")
colorBg: Settings.data.desktopWidgets.gridSnapScale ? Color.mPrimary : Color.mSurfaceVariant
colorFg: Settings.data.desktopWidgets.gridSnapScale ? Color.mOnPrimary : Color.mPrimary
onClicked: Settings.data.desktopWidgets.gridSnapScale = !Settings.data.desktopWidgets.gridSnapScale
}
NIconButton {
icon: "grid-4x4"
tooltipText: I18n.tr("panels.desktop-widgets.edit-mode-grid-snap-label")
colorBg: Settings.data.desktopWidgets.gridSnap ? Color.mPrimary : Color.mSurfaceVariant
colorFg: Settings.data.desktopWidgets.gridSnap ? Color.mOnPrimary : Color.mPrimary
onClicked: Settings.data.desktopWidgets.gridSnap = !Settings.data.desktopWidgets.gridSnap
}
NIconButton {
icon: "settings"
tooltipText: I18n.tr("actions.open-settings")
onClicked: {
SettingsPanelService.toggle(SettingsPanel.Tab.DesktopWidgets, -1, screenLoader.modelData);
}
}
NButton {
text: I18n.tr("panels.desktop-widgets.edit-mode-exit-button")
icon: "logout"
outlined: false
fontSize: Style.fontSizeS
iconSize: Style.fontSizeM
onClicked: DesktopWidgetRegistry.editMode = false
}
}
NText {
Layout.alignment: Qt.AlignRight
Layout.maximumWidth: 300 * Style.uiScaleRatio
text: I18n.tr("panels.desktop-widgets.edit-mode-controls-explanation")
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
horizontalAlignment: Text.AlignRight
wrapMode: Text.WordWrap
}
}
}
}
}
}
}