mirror of
https://github.com/zoriya/noctalia-shell.git
synced 2025-12-06 06:36:15 +00:00
618 lines
23 KiB
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|