Files
noctalia-shell/Widgets/NSectionEditor.qml
T

958 lines
36 KiB
QML

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();
}
}
}
}
}