Files
noctalia-shell/Widgets/NSectionEditor.qml

618 lines
23 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
NBox {
id: root
property string sectionName: ""
property string sectionId: ""
property var widgetModel: []
property var availableWidgets: []
property var availableSections: ["left", "center", "right"]
property int maxWidgets: -1 // -1 means unlimited
property var widgetRegistry: null
property string settingsDialogComponent: "BarWidgetSettingsDialog.qml"
readonly property real miniButtonSize: Style.baseWidgetSize * 0.65
readonly property bool isAtMaxCapacity: maxWidgets >= 0 && widgetModel.length >= maxWidgets
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
color: Color.mSurface
Layout.fillWidth: true
Layout.minimumHeight: {
// header + minimal content area
var absoluteMin = (Style.marginL * 2) + (Style.fontSizeL * 2) + Style.marginM + (65 * Style.uiScaleRatio);
var widgetCount = widgetModel.length;
if (widgetCount === 0) {
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);
// 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);
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:
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];
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginL
spacing: Style.marginM
RowLayout {
Layout.fillWidth: true
NText {
text: sectionName
pointSize: Style.fontSizeL
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.alignment: Qt.AlignVCenter
}
// 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
label: ""
description: ""
placeholder: I18n.tr("bar.widget-settings.section-editor.placeholder")
searchPlaceholder: I18n.tr("bar.widget-settings.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
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 {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 65 * Style.uiScaleRatio
clip: false // Don't clip children so ghost can move freely
Flow {
id: widgetFlow
anchors.fill: parent
spacing: Style.marginS
flow: Flow.LeftToRight
Repeater {
model: widgetModel
delegate: Rectangle {
id: widgetItem
required property int index
required property var modelData
width: widgetContent.implicitWidth + Style.marginL
height: Style.baseWidgetSize * 1.15 * Style.uiScaleRatio
radius: Style.radiusL
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: 1 + (root.widgetRegistry ? root.widgetRegistry.widgetHasUserSettings(modelData.id) : 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: [
{
"label": I18n.tr("tooltips.move-to-left-section"),
"action": "left",
"icon": "arrow-bar-to-left",
"visible": root.availableSections.includes("left") && root.sectionId !== "left"
},
{
"label": I18n.tr("tooltips.move-to-center-section"),
"action": "center",
"icon": "layout-columns",
"visible": root.availableSections.includes("center") && root.sectionId !== "center"
},
{
"label": I18n.tr("tooltips.move-to-right-section"),
"action": "right",
"icon": "arrow-bar-to-right",
"visible": root.availableSections.includes("right") && root.sectionId !== "right"
}
]
onTriggered: action => 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
const localX = mouse.x;
const buttonsStartX = parent.width - (parent.buttonsCount * parent.buttonsWidth);
if (localX < buttonsStartX) {
contextMenu.openAtItem(widgetItem, mouse.x, mouse.y);
}
}
}
}
RowLayout {
id: widgetContent
anchors.centerIn: parent
spacing: Style.marginXXS
NText {
text: modelData.id
pointSize: Style.fontSizeXS
color: root.getWidgetColor(modelData)[1]
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
Layout.preferredWidth: 60 * Style.uiScaleRatio
}
RowLayout {
spacing: 0
Layout.preferredWidth: buttonsCount * buttonsWidth * Style.uiScaleRatio
Loader {
active: root.widgetRegistry && root.widgetRegistry.widgetHasUserSettings(modelData.id)
sourceComponent: NIconButton {
icon: "settings"
tooltipText: I18n.tr("tooltips.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: {
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 {
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);
}
}
}
}
}
}
}
// Ghost/Clone widget for dragging
Rectangle {
id: dragGhost
width: 0
height: Style.baseWidgetSize * 1.15
radius: Style.radiusL
color: 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: width / 2
color: Color.mPrimary
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: -1
acceptedButtons: Qt.LeftButton
preventStealing: false
propagateComposedEvents: false
hoverEnabled: true // Always track mouse for drag operations
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: 15
property Item draggedWidget: null
property int dropTargetIndex: -1
property var draggedModelData: null
// Drop position calculation
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 = widgetFlow.children[i];
if (!widget || widget.widgetIndex === undefined)
continue;
// Check distance to left edge (insert before)
const leftDist = Math.sqrt(Math.pow(mouseX - widget.x, 2) + Math.pow(mouseY - (widget.y + widget.height / 2), 2));
// Check distance to right edge (insert after)
const rightDist = Math.sqrt(Math.pow(mouseX - (widget.x + widget.width), 2) + Math.pow(mouseY - (widget.y + widget.height / 2), 2));
if (leftDist < minDistance) {
minDistance = leftDist;
bestIndex = i;
bestPosition = Qt.point(widget.x - dropIndicator.width / 2 - Style.marginXS, widget.y);
}
if (rightDist < minDistance) {
minDistance = rightDist;
bestIndex = i + 1;
bestPosition = Qt.point(widget.x + widget.width + Style.marginXS - dropIndicator.width / 2, widget.y);
}
}
// Check if we should insert at position 0 (very beginning)
if (widgetModel.length > 0 && draggedIndex !== 0) {
const firstWidget = widgetFlow.children[0];
if (firstWidget) {
const dist = Math.sqrt(Math.pow(mouseX, 2) + Math.pow(mouseY - firstWidget.y, 2));
if (dist < minDistance && mouseX < firstWidget.x + firstWidget.width / 2) {
minDistance = dist;
bestIndex = 0;
bestPosition = Qt.point(Math.max(0, firstWidget.x - dropIndicator.width - Style.marginS), firstWidget.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;
}
}
onPressed: mouse => {
startPos = Qt.point(mouse.x, mouse.y);
dragStarted = false;
potentialDrag = false;
draggedIndex = -1;
draggedWidget = null;
dropTargetIndex = -1;
draggedModelData = null;
// Find which widget was clicked
for (var i = 0; i < widgetModel.length; i++) {
const widget = widgetFlow.children[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);
if (localX < buttonsStartX) {
// This is a draggable area - prevent panel close immediately
draggedIndex = widget.widgetIndex;
draggedWidget = widget;
draggedModelData = widget.modelData;
potentialDrag = true;
preventStealing = true;
// Signal that interaction started (prevents panel close)
root.dragPotentialStarted();
break;
} else {
// This is a button area - let the click through
mouse.accepted = false;
return;
}
}
}
}
}
onPositionChanged: mouse => {
if (draggedIndex !== -1 && potentialDrag) {
const deltaX = mouse.x - startPos.x;
const deltaY = mouse.y - startPos.y;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
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;
}
}
if (dragStarted) {
// Move ghost widget
dragGhost.x = mouse.x - dragGhost.width / 2;
dragGhost.y = mouse.y - dragGhost.height / 2;
// Update drop indicator
updateDropIndicator(mouse.x, mouse.y);
}
}
}
onReleased: mouse => {
if (dragStarted && dropTargetIndex !== -1 && dropTargetIndex !== draggedIndex) {
// Perform the reorder
reorderWidget(sectionId, draggedIndex, dropTargetIndex);
}
// Always signal end of interaction if we started one
if (potentialDrag) {
root.dragPotentialEnded();
}
// Reset everything
dragStarted = false;
potentialDrag = false;
draggedIndex = -1;
draggedWidget = null;
dropTargetIndex = -1;
draggedModelData = null;
preventStealing = false;
dropIndicator.opacity = 0;
pulseAnimation.running = false;
dragGhost.width = 0;
}
onExited: {
if (dragStarted) {
// Hide drop indicator when mouse leaves, but keep ghost visible
dropIndicator.opacity = 0;
pulseAnimation.running = false;
}
}
onCanceled: {
// Handle cancel (e.g., ESC key pressed during drag)
if (potentialDrag) {
root.dragPotentialEnded();
}
// Reset everything
dragStarted = false;
potentialDrag = false;
draggedIndex = -1;
draggedWidget = null;
dropTargetIndex = -1;
draggedModelData = null;
preventStealing = false;
dropIndicator.opacity = 0;
pulseAnimation.running = false;
dragGhost.width = 0;
}
}
}
}
}