import QtQuick import QtQuick.Controls import QtQuick.Effects import QtQuick.Layouts import qs.Commons import qs.Services.Noctalia import qs.Widgets NBox { id: root property string sectionName: "" property string sectionSubtitle: "" property string sectionId: "" property var widgetModel: [] property var availableWidgets: [] property var availableSections: ["left", "center", "right"] property var sectionLabels: ({}) // Map of sectionId -> display label property var sectionIcons: ({}) // Map of sectionId -> icon name property bool barIsVertical: false // When true, map left/right to top/bottom in labels property int maxWidgets: -1 // -1 means unlimited property bool draggable: true // Enable/disable drag reordering property bool crossSectionDraggable: false property alias dropTargetArea: gridContainer // Get display label for a section function getSectionLabel(sectionId) { if (sectionLabels && sectionLabels[sectionId]) { return sectionLabels[sectionId]; } return sectionId; // Fallback to section ID } // Get icon for a section function getSectionIcon(sectionId) { if (sectionIcons && sectionIcons[sectionId]) { return sectionIcons[sectionId]; } return "arrow-right"; // Default fallback icon } property var pluginSettingsEntryPoints: ["settings"] property var widgetRegistry: null property string settingsDialogComponent: "invalid-settings-dialog" property var screen: null // Screen reference for per-screen widget settings property var _activeDialog: null property bool crossDropHoverActive: false function clearCrossSectionHover() { crossDropHoverActive = false; var parentItem = root.parent; if (!parentItem || !parentItem.children) { return; } for (var i = 0; i < parentItem.children.length; i++) { var candidate = parentItem.children[i]; if (!candidate || candidate.crossDropHoverActive === undefined) continue; candidate.crossDropHoverActive = false; } } function updateCrossSectionHover(globalX, globalY) { var parentItem = root.parent; if (!parentItem || !parentItem.children) { crossDropHoverActive = false; return; } crossDropHoverActive = false; for (var i = 0; i < parentItem.children.length; i++) { var candidate = parentItem.children[i]; if (!candidate || candidate.sectionId === undefined || candidate.crossDropHoverActive === undefined) { continue; } var shouldHighlight = false; if (candidate !== root && candidate.visible && candidate.enabled) { var localPoint = candidate.mapFromGlobal(globalX, globalY); var area = candidate.dropTargetArea ? candidate.dropTargetArea : candidate; shouldHighlight = localPoint.x >= area.x && localPoint.y >= area.y && localPoint.x <= area.x + area.width && localPoint.y <= area.y + area.height; } candidate.crossDropHoverActive = shouldHighlight; } } function isPointInsideSelf(globalX, globalY) { if (globalX < 0 || globalY < 0) return false; var localPoint = root.mapFromGlobal(globalX, globalY); return localPoint.x >= 0 && localPoint.y >= 0 && localPoint.x <= root.width && localPoint.y <= root.height; } readonly property bool showCrossSectionDropHint: crossDropHoverActive Component.onDestruction: { if (_activeDialog && _activeDialog.close) { var dialog = _activeDialog; _activeDialog = null; dialog.close(); dialog.destroy(); } } 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) signal reorderWidget(string section, int fromIndex, int toIndex) signal updateWidgetSettings(string section, int index, var settings) signal moveWidget(string fromSection, int index, string toSection) signal dragPotentialStarted signal dragPotentialEnded signal openPluginSettingsRequested(var pluginManifest, string settingsEntryPoint) color: Color.mSurface Layout.fillWidth: true z: flowDragArea.dragStarted ? 5000 : 0 // 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); } function findSectionAtGlobalPosition(globalX, globalY) { var parentItem = root.parent; if (!parentItem || !parentItem.children) { return ""; } for (var i = 0; i < parentItem.children.length; i++) { var candidate = parentItem.children[i]; if (!candidate || candidate === root) { continue; } // Only consider sibling section editors if (candidate.sectionId === undefined || !candidate.visible || !candidate.enabled) { continue; } var localPoint = candidate.mapFromGlobal(globalX, globalY); var area = candidate.dropTargetArea ? candidate.dropTargetArea : candidate; if (localPoint.x >= area.x && localPoint.y >= area.y && localPoint.x <= area.x + area.width && localPoint.y <= area.y + area.height) { return candidate.sectionId; } } return ""; } Layout.minimumHeight: { // header + minimal content area var absoluteMin = Style.margin2L + (Style.fontSizeL * 2) + Style.marginM + (65 * Style.uiScaleRatio); var widgetCount = widgetModel.length; if (widgetCount === 0) { return absoluteMin; } // Calculate rows based on grid layout // Use actual parent width if available, otherwise estimate var availableWidth = (parent && parent.width > 0) ? (parent.width - Style.margin2L) : 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; // Account for grid margins var gridTopMargin = Style.marginS; var gridBottomMargin = Style.marginL; var widgetAreaHeight = gridTopMargin + (rows * widgetItemHeight) + ((rows - 1) * Style.marginL) + gridBottomMargin; return Math.max(absoluteMin, Style.margin2L + headerHeight + Style.marginM + widgetAreaHeight); } // Generate widget color from name checksum function getWidgetColor(widget) { if (widget.id.startsWith('plugin:')) { return [Color.mSecondary, Color.mOnSecondary]; } return [Color.mPrimary, Color.mOnPrimary]; } // Check if widget has settings (either core widget with metadata or plugin with settings entry point) function widgetHasSettings(widgetId) { // 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); if (!manifest?.entryPoints) return false; for (var i = 0; i < root.pluginSettingsEntryPoints.length; i++) { if (manifest.entryPoints[root.pluginSettingsEntryPoints[i]] !== undefined) return true; } return false; } // Check if it's a core widget with user settings if (root.widgetRegistry && root.widgetRegistry.widgetHasUserSettings(widgetId)) { return true; } return false; } // Open settings for a widget function openWidgetSettings(index, widgetData) { // Check if this is a plugin widget with a generic "settings" entry point var isPlugin = root.widgetRegistry && root.widgetRegistry.isPluginWidget(widgetData.id); if (isPlugin) { var pluginId = widgetData.id.replace("plugin:", ""); var manifest = PluginRegistry.getPluginManifest(pluginId); var settingsKey = null; if (manifest?.entryPoints) { for (var i = 0; i < root.pluginSettingsEntryPoints.length; i++) { if (manifest.entryPoints[root.pluginSettingsEntryPoints[i]] !== undefined) { settingsKey = root.pluginSettingsEntryPoints[i]; break; } } } if (!manifest || !settingsKey) { Logger.e("NSectionEditor", "Plugin settings not found for:", pluginId); return; } // "desktopWidgetSettings" is handled by the settingsDialogComponent // (DesktopWidgetSettingsDialog) which passes widgetSettings/save properly. // Only generic "settings" goes through the plugin settings popup. if (settingsKey !== "desktopWidgetSettings") { root.openPluginSettingsRequested(manifest, settingsKey); return; } } // Handle core widgets and plugin desktop widget settings { var component = Qt.createComponent(Qt.resolvedUrl(root.settingsDialogComponent)); function instantiateAndOpen() { if (root._activeDialog) { try { root._activeDialog.close(); root._activeDialog.destroy(); } catch (e) {} root._activeDialog = null; } var dialog = component.createObject(Overlay.overlay, { "widgetIndex": index, "widgetData": widgetData, "widgetId": widgetData.id, "sectionId": root.sectionId, "screen": root.screen }); if (dialog) { root._activeDialog = dialog; dialog.updateWidgetSettings.connect(root.updateWidgetSettings); dialog.closed.connect(() => { if (root._activeDialog === dialog) { root._activeDialog = null; dialog.destroy(); } }); 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 { component.statusChanged.connect(function () { if (component.status === Component.Ready) { instantiateAndOpen(); } else if (component.status === Component.Error) { Logger.e("NSectionEditor", component.errorString()); } }); } } } ColumnLayout { anchors.fill: parent anchors.margins: Style.marginL spacing: Style.marginM RowLayout { Layout.fillWidth: true Layout.rightMargin: Style.marginS ColumnLayout { spacing: Style.marginXXS Layout.alignment: Qt.AlignVCenter Layout.fillWidth: false Layout.leftMargin: Style.marginS Layout.maximumWidth: { // Reserve space for other elements: count indicator, combo box (~200), button (~50), and margins // Use a reasonable maximum that leaves room for controls on the right // On smaller screens, use a smaller percentage to ensure controls fit var rowLayout = parent; if (rowLayout && rowLayout.width > 0) { // Use smaller percentage on smaller screens, but ensure minimum space for text var minWidth = 150 * Style.uiScaleRatio; var maxWidth = rowLayout.width < 600 ? rowLayout.width * 0.35 : rowLayout.width * 0.4; return Math.max(minWidth, maxWidth); } return 250 * Style.uiScaleRatio; } NText { text: sectionName pointSize: Style.fontSizeL font.weight: Style.fontWeightBold color: Color.mOnSurface elide: Text.ElideRight Layout.fillWidth: true } NText { visible: sectionSubtitle !== "" text: sectionSubtitle pointSize: Style.fontSizeS color: Color.mOnSurfaceVariant elide: Text.ElideRight Layout.fillWidth: true } } // Widget count indicator (when max is set) NText { visible: root.maxWidgets >= 0 text: root.maxWidgets === 0 ? "(LOCKED)" : "(" + widgetModel.length + "/" + root.maxWidgets + ")" pointSize: Style.fontSizeS color: root.isAtMaxCapacity ? Color.mError : Color.mOnSurfaceVariant Layout.alignment: Qt.AlignVCenter Layout.leftMargin: Style.marginXS } Item { Layout.fillWidth: true } NSearchableComboBox { id: comboBox model: availableWidgets ?? null label: "" description: "" placeholder: I18n.tr("bar.section-editor.placeholder") searchPlaceholder: I18n.tr("bar.section-editor.search-placeholder") onSelected: key => comboBox.currentKey = key popupHeight: 300 * Style.uiScaleRatio minimumWidth: 200 * Style.uiScaleRatio enabled: !root.isAtMaxCapacity Layout.alignment: Qt.AlignVCenter // Re-filter when the model count changes (when widgets are loaded) Connections { target: availableWidgets ?? null ignoreUnknownSignals: true function onCountChanged() { // Trigger a re-filter by clearing and re-setting the search text var currentSearch = comboBox.searchText; comboBox.searchText = ""; comboBox.searchText = currentSearch; } } } NIconButton { icon: "add" colorBg: Color.mPrimary colorFg: Color.mOnPrimary colorBgHover: Color.mSecondary colorFgHover: Color.mOnSecondary enabled: comboBox.currentKey !== "" && !root.isAtMaxCapacity tooltipText: root.isAtMaxCapacity ? I18n.tr("tooltips.max-widgets-reached") : I18n.tr("tooltips.add-widget") Layout.alignment: Qt.AlignVCenter Layout.leftMargin: Style.marginS onClicked: { if (comboBox.currentKey !== "" && !root.isAtMaxCapacity) { addWidget(comboBox.currentKey, sectionId); comboBox.currentKey = ""; } } } } // Drag and Drop Widget Area Item { id: gridContainer Layout.fillWidth: 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 var gridTopMargin = Style.marginS; var gridBottomMargin = Style.marginS; var calculatedHeight = gridTopMargin + (rows * root.widgetItemHeight) + ((rows - 1) * Style.marginS) + gridBottomMargin; return calculatedHeight; } Layout.minimumHeight: widgetModel.length === 0 ? (65 * Style.uiScaleRatio) : (Style.margin2S + root.widgetItemHeight) clip: !flowDragArea.dragStarted Rectangle { anchors.fill: parent radius: Style.iRadiusL color: Qt.alpha(Color.mSecondary, 0.12) border.color: Color.mSecondary border.width: Style.borderM visible: root.showCrossSectionDropHint z: 1500 } Grid { id: widgetGrid anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top anchors.bottom: parent.bottom anchors.leftMargin: Style.marginS anchors.rightMargin: Style.marginS anchors.topMargin: Style.marginS anchors.bottomMargin: Style.marginS columns: root.gridColumns rowSpacing: Style.marginS columnSpacing: Style.marginM Repeater { id: widgetRepeater model: widgetModel delegate: Rectangle { id: widgetItem required property int index required property var modelData width: root.calculateWidgetWidth(parent.width) height: root.widgetItemHeight radius: Style.iRadiusL color: root.getWidgetColor(modelData)[0] border.color: Color.mOutline border.width: Style.borderS // Store the widget index for drag operations property int widgetIndex: index readonly property int buttonsWidth: Math.round(20) readonly property int buttonsCount: root.widgetHasSettings(modelData.id) ? 1 : 0 // Visual feedback during drag opacity: flowDragArea.draggedIndex === index ? 0.5 : 1.0 scale: flowDragArea.draggedIndex === index ? 0.95 : 1.0 z: flowDragArea.draggedIndex === index ? 1000 : 0 Behavior on opacity { NumberAnimation { duration: Style.animationFast } } Behavior on scale { NumberAnimation { duration: Style.animationFast } } // Context menu for moving widget to other sections NContextMenu { id: contextMenu parent: Overlay.overlay width: 240 * Style.uiScaleRatio model: { var items = []; // Add move options for each available section (except current) for (var i = 0; i < root.availableSections.length; i++) { var section = root.availableSections[i]; if (section !== root.sectionId) { var label = root.getSectionLabel(section); var displayLabel = ''; // Map section IDs to correct position keys based on bar orientation var positionKey = section; if (root.barIsVertical) { if (section === "left") positionKey = "top"; else if (section === "right") positionKey = "bottom"; } if (I18n.hasTranslation("positions." + positionKey)) { displayLabel = I18n.tr("positions." + positionKey); } else { displayLabel = label.charAt(0).toUpperCase() + label.slice(1); } items.push({ "label": I18n.tr("tooltips.move-to-section", { "section": displayLabel }), "action": section, "icon": root.getSectionIcon(section), "visible": true }); } } // Add remove option items.push({ "label": I18n.tr("tooltips.remove-widget"), "action": "remove", "icon": "trash", "visible": true }); return items; } onTriggered: action => { if (action === "remove") { root.removeWidget(root.sectionId, widgetItem.index); } else { root.moveWidget(root.sectionId, widgetItem.index, action); } } } // MouseArea for the context menu - only active when not dragging MouseArea { id: contextMouseArea anchors.fill: parent acceptedButtons: Qt.RightButton cursorShape: Qt.PointingHandCursor z: -1 // Below the buttons but above background enabled: !flowDragArea.dragStarted && !flowDragArea.potentialDrag onPressed: mouse => { mouse.accepted = true; // 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 || parent.buttonsCount === 0) { contextMenu.openAtItem(widgetItem, mouse.x, mouse.y); } } } RowLayout { id: widgetContent anchors.fill: parent anchors.margins: Style.marginXS anchors.rightMargin: Style.marginS spacing: Style.marginXXS NText { text: { // For plugin widgets, get the actual plugin name from manifest if (root.widgetRegistry && root.widgetRegistry.isPluginWidget(modelData.id)) { const pluginId = modelData.id.replace("plugin:", ""); const manifest = PluginRegistry.getPluginManifest(pluginId); if (manifest && manifest.name) { return manifest.name; } // Fallback: just strip the prefix return pluginId; } return modelData.id; } pointSize: Style.fontSizeXS color: root.getWidgetColor(modelData)[1] horizontalAlignment: Text.AlignLeft verticalAlignment: Text.AlignVCenter elide: Text.ElideRight 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 } // CPU intensive indicator icon NIcon { visible: root.widgetRegistry && root.widgetRegistry.isCpuIntensive(modelData.id) icon: "cpu-intensive" 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.widgetHasSettings(modelData.id) && root.enabled sourceComponent: NIconButton { icon: "settings" tooltipText: I18n.tr("actions.widget-settings") 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: { root.openWidgetSettings(index, modelData); } } } } } } } } // Ghost/Clone widget for dragging Rectangle { id: dragGhost width: 0 height: Style.baseWidgetSize * 1.15 radius: Style.iRadiusL color: "transparent" border.color: Color.mOutline border.width: Style.borderS opacity: 0.7 visible: flowDragArea.dragStarted z: 2000 clip: false // Ensure ghost isn't clipped NText { id: ghostText anchors.centerIn: parent pointSize: Style.fontSizeS color: Color.mOnPrimary } } // Drop indicator - visual feedback for where the widget will be inserted Rectangle { id: dropIndicator width: 3 height: Style.baseWidgetSize * 1.15 radius: Style.iRadiusXXS color: Color.mSecondary opacity: 0 visible: opacity > 0 z: 1999 SequentialAnimation on opacity { id: pulseAnimation running: false loops: Animation.Infinite NumberAnimation { to: 1 duration: 400 easing.type: Easing.InOutQuad } NumberAnimation { to: 0.6 duration: 400 easing.type: Easing.InOutQuad } } Behavior on x { NumberAnimation { duration: 100 easing.type: Easing.OutCubic } } Behavior on y { NumberAnimation { duration: 100 easing.type: Easing.OutCubic } } } // MouseArea for drag and drop MouseArea { id: flowDragArea anchors.fill: parent z: 100 // Above widgets to ensure it captures events first enabled: root.draggable || root.crossSectionDraggable acceptedButtons: Qt.LeftButton preventStealing: true // Always prevent stealing to ensure we get all events propagateComposedEvents: true // Allow events to propagate when not handled hoverEnabled: potentialDrag || dragStarted // Only track hover during drag operations cursorShape: dragStarted ? Qt.ClosedHandCursor : Qt.ArrowCursor property point startPos: Qt.point(0, 0) property bool dragStarted: false property bool potentialDrag: false // Track if we're in a potential drag interaction property int draggedIndex: -1 property real dragThreshold: 8 // Reduced threshold for more responsive drag property Item draggedWidget: null property int dropTargetIndex: -1 property var draggedModelData: null property bool isOverButtonArea: false // Drop position calculation // Map widget coordinates from grid-local to gridContainer coordinates function mapWidgetCoords(widget) { return { x: widget.x + widgetGrid.x, y: widget.y + widgetGrid.y, width: widget.width, height: widget.height }; } function updateDropIndicator(mouseX, mouseY) { if (!dragStarted || draggedIndex === -1) { dropIndicator.opacity = 0; pulseAnimation.running = false; return; } let bestIndex = -1; let bestPosition = null; let minDistance = Infinity; // Check position relative to each widget for (var i = 0; i < widgetModel.length; i++) { if (i === draggedIndex) continue; const widget = widgetRepeater.itemAt(i); if (!widget || widget.widgetIndex === undefined) continue; const mapped = mapWidgetCoords(widget); // Check distance to left edge (insert before) const leftDist = Math.sqrt(Math.pow(mouseX - mapped.x, 2) + Math.pow(mouseY - (mapped.y + mapped.height / 2), 2)); // Check distance to right edge (insert after) const rightDist = Math.sqrt(Math.pow(mouseX - (mapped.x + mapped.width), 2) + Math.pow(mouseY - (mapped.y + mapped.height / 2), 2)); if (leftDist < minDistance) { minDistance = leftDist; bestIndex = i; bestPosition = Qt.point(mapped.x - dropIndicator.width / 2 - Style.marginXS, mapped.y); } if (rightDist < minDistance) { minDistance = rightDist; bestIndex = i + 1; bestPosition = Qt.point(mapped.x + mapped.width + Style.marginXS - dropIndicator.width / 2, mapped.y); } } // Check if we should insert at position 0 (very beginning) if (widgetModel.length > 0 && draggedIndex !== 0) { const firstWidget = widgetRepeater.itemAt(0); if (firstWidget) { const mapped = mapWidgetCoords(firstWidget); const dist = Math.sqrt(Math.pow(mouseX - mapped.x, 2) + Math.pow(mouseY - mapped.y, 2)); if (dist < minDistance && mouseX < mapped.x + mapped.width / 2) { minDistance = dist; bestIndex = 0; // Position indicator to the left of the first widget bestPosition = Qt.point(mapped.x - dropIndicator.width / 2 - Style.marginXS, mapped.y); } } } // Only show indicator if we're close enough and it's a different position if (minDistance < 80 && bestIndex !== -1) { // Adjust index if we're moving forward let adjustedIndex = bestIndex; if (bestIndex > draggedIndex) { adjustedIndex = bestIndex - 1; } // Don't show if it's the same position if (adjustedIndex === draggedIndex) { dropIndicator.opacity = 0; pulseAnimation.running = false; dropTargetIndex = -1; return; } dropTargetIndex = adjustedIndex; if (bestPosition) { dropIndicator.x = bestPosition.x; dropIndicator.y = bestPosition.y; dropIndicator.opacity = 1; if (!pulseAnimation.running) { pulseAnimation.running = true; } } } else { dropIndicator.opacity = 0; pulseAnimation.running = false; dropTargetIndex = -1; } } function resetDragState() { root.clearCrossSectionHover(); dragStarted = false; potentialDrag = false; draggedIndex = -1; draggedWidget = null; dropTargetIndex = -1; draggedModelData = null; isOverButtonArea = false; dropIndicator.opacity = 0; pulseAnimation.running = false; dragGhost.width = 0; } onPressed: mouse => { // Reset state startPos = Qt.point(mouse.x, mouse.y); dragStarted = false; potentialDrag = false; draggedIndex = -1; draggedWidget = null; dropTargetIndex = -1; draggedModelData = null; isOverButtonArea = false; // Find which widget was clicked for (var i = 0; i < widgetModel.length; 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; const buttonsStartX = widget.width - (widget.buttonsCount * widget.buttonsWidth * Style.uiScaleRatio); if (localX >= buttonsStartX && widget.buttonsCount > 0) { // Click is on button area - don't start drag, propagate event isOverButtonArea = true; mouse.accepted = false; return; } else { // This is a draggable area draggedIndex = widget.widgetIndex; draggedWidget = widget; draggedModelData = widget.modelData; potentialDrag = true; mouse.accepted = true; // Signal that interaction started (prevents panel close) root.dragPotentialStarted(); return; } } } } // Click was not on any widget mouse.accepted = false; } onPositionChanged: mouse => { if (potentialDrag && draggedIndex !== -1) { const deltaX = mouse.x - startPos.x; const deltaY = mouse.y - startPos.y; const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); // Start drag if threshold exceeded if (!dragStarted && distance > dragThreshold) { dragStarted = true; // Setup ghost widget if (draggedWidget) { dragGhost.width = draggedWidget.width; dragGhost.color = root.getWidgetColor(draggedModelData)[0]; ghostText.text = draggedModelData.id; } var startGlobal = flowDragArea.mapToGlobal(mouse.x, mouse.y); root.updateCrossSectionHover(startGlobal.x, startGlobal.y); } if (dragStarted) { // Move ghost widget dragGhost.x = mouse.x - dragGhost.width / 2; dragGhost.y = mouse.y - dragGhost.height / 2; var moveGlobal = flowDragArea.mapToGlobal(mouse.x, mouse.y); root.updateCrossSectionHover(moveGlobal.x, moveGlobal.y); // Update drop indicator updateDropIndicator(mouse.x, mouse.y); } } } onReleased: mouse => { if (root.draggable && dragStarted && dropTargetIndex !== -1 && dropTargetIndex !== draggedIndex) { // Perform the reorder reorderWidget(sectionId, draggedIndex, dropTargetIndex); } else if (dragStarted && draggedIndex !== -1) { var globalPos = flowDragArea.mapToGlobal(mouse.x, mouse.y); var targetSectionId = root.findSectionAtGlobalPosition(globalPos.x, globalPos.y); if (targetSectionId !== "" && targetSectionId !== root.sectionId) { root.moveWidget(root.sectionId, draggedIndex, targetSectionId); } } // Always signal end of interaction if we started one if (potentialDrag) { root.dragPotentialEnded(); } // Reset everything resetDragState(); mouse.accepted = true; } onCanceled: { // Handle cancel (e.g., ESC key pressed during drag) if (potentialDrag) { root.dragPotentialEnded(); } resetDragState(); } } } } }