fedora
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
|
||||
Item {
|
||||
id: root
|
||||
property color fillColor: Color.mPrimary
|
||||
property color strokeColor: Color.mOnSurface
|
||||
property int strokeWidth: 0
|
||||
property var values: []
|
||||
property bool vertical: false
|
||||
property string barPosition: "top" // "top", "bottom", "left", "right"
|
||||
property bool mirrored: true
|
||||
|
||||
// Minimum signal properties
|
||||
property bool showMinimumSignal: false
|
||||
property real minimumSignalValue: 0.01 // Default to 1% of height
|
||||
|
||||
// Pre compute horizontal mirroring
|
||||
readonly property int valuesCount: (values && values.length !== undefined) ? values.length : 0
|
||||
readonly property int totalBars: mirrored ? valuesCount * 2 : valuesCount
|
||||
readonly property real barSlotSize: totalBars > 0 ? (vertical ? height : width) / totalBars : 0
|
||||
readonly property bool highQuality: (Settings.data.audio.visualizerType === "low") ? false : true
|
||||
|
||||
Repeater {
|
||||
model: root.totalBars
|
||||
|
||||
Rectangle {
|
||||
property int valueIndex: root.mirrored ? (index < root.valuesCount ? root.valuesCount - 1 - index : index - root.valuesCount) : index
|
||||
|
||||
property real rawAmp: (root.values && root.values[valueIndex] !== undefined) ? root.values[valueIndex] : 0
|
||||
property real amp: (root.showMinimumSignal && rawAmp === 0) ? root.minimumSignalValue : rawAmp
|
||||
|
||||
color: root.fillColor
|
||||
border.color: root.strokeColor
|
||||
border.width: root.strokeWidth
|
||||
antialiasing: root.highQuality
|
||||
smooth: root.highQuality
|
||||
|
||||
// Only update when value actually changes - reduces GPU load
|
||||
width: vertical ? root.width * amp : root.barSlotSize * 0.5
|
||||
height: vertical ? root.barSlotSize * 0.5 : root.height * amp
|
||||
x: vertical ? (root.barPosition === "left" ? 0 : root.width - width) : index * root.barSlotSize + (root.barSlotSize * 0.25)
|
||||
y: vertical ? index * root.barSlotSize + (root.barSlotSize * 0.25) : root.height - height
|
||||
|
||||
// Disable updates when invisible to save GPU
|
||||
visible: root.visible
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
|
||||
Item {
|
||||
id: root
|
||||
property color fillColor: Color.mPrimary
|
||||
property color strokeColor: Color.mOnSurface
|
||||
property int strokeWidth: 0
|
||||
property var values: []
|
||||
property bool vertical: false
|
||||
property bool mirrored: true
|
||||
|
||||
// Minimum signal properties
|
||||
property bool showMinimumSignal: false
|
||||
property real minimumSignalValue: 0.01 // Default to 1% of height
|
||||
|
||||
// Pre-compute mirroring
|
||||
readonly property int valuesCount: (values && values.length !== undefined) ? values.length : 0
|
||||
readonly property int totalBars: mirrored ? valuesCount * 2 : valuesCount
|
||||
readonly property real barSlotSize: totalBars > 0 ? (vertical ? height : width) / totalBars : 0
|
||||
readonly property bool highQuality: (Settings.data.audio.visualizerType === "low") ? false : true
|
||||
readonly property real centerY: height / 2
|
||||
readonly property real centerX: width / 2
|
||||
|
||||
Repeater {
|
||||
model: root.totalBars
|
||||
|
||||
Rectangle {
|
||||
property int valueIndex: root.mirrored ? (index < root.valuesCount ? root.valuesCount - 1 - index : index - root.valuesCount) : index
|
||||
|
||||
property real rawAmp: (root.values && root.values[valueIndex] !== undefined) ? root.values[valueIndex] : 0
|
||||
property real amp: (root.showMinimumSignal && rawAmp === 0) ? root.minimumSignalValue : rawAmp
|
||||
|
||||
property real barSize: (vertical ? root.width : root.height) * amp
|
||||
|
||||
color: root.fillColor
|
||||
border.color: root.strokeColor
|
||||
border.width: root.strokeWidth
|
||||
antialiasing: root.highQuality
|
||||
smooth: root.highQuality
|
||||
|
||||
width: vertical ? barSize : root.barSlotSize * 0.8
|
||||
height: vertical ? root.barSlotSize * 0.8 : barSize
|
||||
x: vertical ? root.centerX - (barSize / 2) : index * root.barSlotSize + (root.barSlotSize * 0.25)
|
||||
y: vertical ? index * root.barSlotSize + (root.barSlotSize * 0.25) : root.centerY - (barSize / 2)
|
||||
|
||||
// Disable updates when invisible to save GPU
|
||||
visible: root.visible
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
|
||||
Item {
|
||||
id: root
|
||||
property color fillColor: Color.mPrimary
|
||||
property color strokeColor: Color.mOnSurface
|
||||
property int strokeWidth: 0
|
||||
property var values: []
|
||||
property bool vertical: false
|
||||
property bool mirrored: true
|
||||
|
||||
// Minimum signal properties
|
||||
property bool showMinimumSignal: false
|
||||
property real minimumSignalValue: 0.01 // Default to 1% of height
|
||||
|
||||
readonly property int valuesCount: (values && values.length !== undefined) ? values.length : 0
|
||||
readonly property bool hasData: valuesCount >= 2
|
||||
|
||||
// Data texture: one pixel per value, R channel = amplitude
|
||||
Item {
|
||||
id: dataRow
|
||||
width: Math.max(root.valuesCount, 4)
|
||||
height: 1
|
||||
|
||||
Repeater {
|
||||
model: dataRow.width
|
||||
|
||||
Rectangle {
|
||||
required property int index
|
||||
x: index
|
||||
width: 1
|
||||
height: 1
|
||||
color: {
|
||||
if (index >= root.valuesCount)
|
||||
return Qt.rgba(0, 0, 0, 1);
|
||||
var v = root.values[index];
|
||||
if (v === undefined || v === null || !isFinite(v))
|
||||
v = 0;
|
||||
if (root.showMinimumSignal && v === 0)
|
||||
v = root.minimumSignalValue;
|
||||
return Qt.rgba(Math.max(0, Math.min(1, v)), 0, 0, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ShaderEffectSource {
|
||||
id: dataTex
|
||||
sourceItem: dataRow
|
||||
textureSize: Qt.size(dataRow.width, 1)
|
||||
live: true
|
||||
smooth: false
|
||||
hideSource: true
|
||||
}
|
||||
|
||||
ShaderEffect {
|
||||
anchors.fill: parent
|
||||
visible: root.hasData && root.width > 0 && root.height > 0
|
||||
|
||||
property variant dataSource: dataTex
|
||||
property color fillColor: root.fillColor
|
||||
property real count: root.valuesCount
|
||||
property real texWidth: dataRow.width
|
||||
property real vertical: root.vertical ? 1.0 : 0.0
|
||||
property real mirrored: root.mirrored ? 1.0 : 0.0
|
||||
|
||||
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/wave_spectrum.frag.qsb")
|
||||
blending: true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
// Battery widget with Android 16 style rendering (horizontal or vertical)
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// Data (must be provided by parent)
|
||||
required property real percentage
|
||||
required property bool charging
|
||||
required property bool pluggedIn
|
||||
required property bool ready
|
||||
required property bool low
|
||||
required property bool critical
|
||||
|
||||
// Sizing - baseSize controls overall scaleFactor for bar/panel usage
|
||||
property real baseSize: Style.fontSizeM
|
||||
|
||||
// Styling - no hardcoded colors, only theme colors
|
||||
property color baseColor: Color.mOnSurface
|
||||
property color lowColor: Color.mError
|
||||
property color chargingColor: Color.mPrimary
|
||||
property color textColor: Color.mSurface
|
||||
|
||||
// Display options
|
||||
property bool showPercentageText: true
|
||||
property bool vertical: false
|
||||
|
||||
// Alternating state icon display (toggles between percentage and icon when charging)
|
||||
property bool showStateIcon: false
|
||||
|
||||
onChargingChanged: {
|
||||
if (!charging)
|
||||
showStateIcon = false;
|
||||
}
|
||||
|
||||
// Internal sizing calculations based on baseSize
|
||||
readonly property real scaleFactor: baseSize / Style.fontSizeM
|
||||
readonly property real bodyWidth: {
|
||||
const min = Style.toOdd(22 * scaleFactor);
|
||||
if (!showPercentageText) {
|
||||
return min;
|
||||
}
|
||||
|
||||
// increase length when showing 100%
|
||||
if (percentage > 99) {
|
||||
const max = Style.toOdd(30 * scaleFactor);
|
||||
return max;
|
||||
}
|
||||
return min;
|
||||
}
|
||||
|
||||
readonly property real bodyHeight: Style.toOdd(14 * scaleFactor)
|
||||
readonly property real terminalWidth: Math.round(2.5 * scaleFactor)
|
||||
readonly property real terminalHeight: Math.round(7 * scaleFactor)
|
||||
readonly property real cornerRadius: Math.round(3 * scaleFactor)
|
||||
|
||||
// Total size is just body + terminal (no external icon)
|
||||
readonly property real totalWidth: vertical ? bodyHeight : bodyWidth + terminalWidth
|
||||
readonly property real totalHeight: vertical ? bodyWidth + terminalWidth : bodyHeight
|
||||
|
||||
// Determine active color based on state
|
||||
readonly property color activeColor: {
|
||||
if (!ready) {
|
||||
return Qt.alpha(baseColor, Style.opacityMedium);
|
||||
}
|
||||
if (charging) {
|
||||
return chargingColor;
|
||||
}
|
||||
if (low || critical) {
|
||||
return lowColor;
|
||||
}
|
||||
return baseColor;
|
||||
}
|
||||
|
||||
// Background color for empty portion (semi-transparent)
|
||||
readonly property color emptyColor: Qt.alpha(baseColor, 0.66)
|
||||
|
||||
// State icon logic
|
||||
readonly property string stateIcon: {
|
||||
if (!ready)
|
||||
return "x";
|
||||
if (charging)
|
||||
return "bolt-filled";
|
||||
if (pluggedIn)
|
||||
return "plug-filled";
|
||||
return "";
|
||||
}
|
||||
|
||||
// Animated percentage for smooth transitions
|
||||
property real animatedPercentage: percentage
|
||||
|
||||
Behavior on animatedPercentage {
|
||||
enabled: !Settings.data.general.animationDisabled
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
// Timer to alternate between percentage text and state icon when charging/plugged
|
||||
Timer {
|
||||
id: alternateTimer
|
||||
interval: 4000
|
||||
repeat: true
|
||||
running: root.charging && root.showPercentageText
|
||||
onTriggered: root.showStateIcon = !root.showStateIcon
|
||||
}
|
||||
|
||||
implicitWidth: Math.round(totalWidth)
|
||||
implicitHeight: Math.round(totalHeight)
|
||||
Layout.maximumWidth: implicitWidth
|
||||
Layout.maximumHeight: implicitHeight
|
||||
|
||||
// Battery body container
|
||||
Item {
|
||||
id: batteryBody
|
||||
width: root.vertical ? root.bodyHeight : root.bodyWidth + root.terminalWidth
|
||||
height: root.vertical ? root.bodyWidth + root.terminalWidth : root.bodyHeight
|
||||
anchors.left: root.vertical ? undefined : parent.left
|
||||
anchors.bottom: root.vertical ? parent.bottom : undefined
|
||||
anchors.horizontalCenter: root.vertical ? parent.horizontalCenter : undefined
|
||||
anchors.verticalCenter: root.vertical ? undefined : parent.verticalCenter
|
||||
|
||||
// Battery body background
|
||||
Rectangle {
|
||||
id: bodyBackground
|
||||
y: root.vertical ? root.terminalWidth : 0
|
||||
width: root.vertical ? root.bodyHeight : root.bodyWidth
|
||||
height: root.vertical ? root.bodyWidth : root.bodyHeight
|
||||
radius: root.cornerRadius
|
||||
color: root.emptyColor
|
||||
}
|
||||
|
||||
// Terminal cap
|
||||
Rectangle {
|
||||
x: root.vertical ? (root.bodyHeight - root.terminalHeight) / 2 : root.bodyWidth
|
||||
y: root.vertical ? 0 : (root.bodyHeight - root.terminalHeight) / 2
|
||||
width: root.vertical ? root.terminalHeight : root.terminalWidth
|
||||
height: root.vertical ? root.terminalWidth : root.terminalHeight
|
||||
radius: root.cornerRadius / 2
|
||||
color: root.critical ? root.lowColor : root.emptyColor
|
||||
}
|
||||
|
||||
// Fill level
|
||||
Rectangle {
|
||||
id: fillRect
|
||||
visible: root.ready && (root.animatedPercentage > 0 || root.critical)
|
||||
x: 0
|
||||
y: root.vertical ? root.terminalWidth + root.bodyWidth * (1 - (root.critical ? 1 : root.animatedPercentage / 100)) : 0
|
||||
width: root.vertical ? root.bodyHeight : root.bodyWidth * (root.critical ? 1 : root.animatedPercentage / 100)
|
||||
height: root.vertical ? root.bodyWidth * (root.critical ? 1 : root.animatedPercentage / 100) : root.bodyHeight
|
||||
radius: root.cornerRadius
|
||||
color: root.activeColor
|
||||
}
|
||||
}
|
||||
|
||||
// Percentage text overlaid on battery center
|
||||
NText {
|
||||
id: percentageText
|
||||
visible: opacity > 0
|
||||
opacity: root.showPercentageText && root.ready && (root.charging ? !root.showStateIcon : !root.pluggedIn) ? 1 : 0
|
||||
x: batteryBody.x + Style.pixelAlignCenter(bodyBackground.width, width)
|
||||
y: batteryBody.y + bodyBackground.y + Style.pixelAlignCenter(bodyBackground.height, height)
|
||||
font.family: Settings.data.ui.fontFixed
|
||||
font.weight: Style.fontWeightBold
|
||||
text: root.vertical ? String(Math.round(root.animatedPercentage)).split('').join('\n') : Math.round(root.animatedPercentage)
|
||||
pointSize: root.baseSize * (root.vertical ? 0.82 : 0.82)
|
||||
color: Qt.alpha(root.textColor, 0.75)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
lineHeight: root.vertical ? 0.7 : 1.0
|
||||
lineHeightMode: Text.ProportionalHeight
|
||||
|
||||
Behavior on opacity {
|
||||
enabled: !Settings.data.general.animationDisabled
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// State icon centered inside battery body (shown when alternating)
|
||||
NIcon {
|
||||
id: stateIconOverlay
|
||||
visible: opacity > 0
|
||||
opacity: !root.ready || (root.charging ? (root.showStateIcon || !root.showPercentageText) : root.pluggedIn) ? 1 : 0
|
||||
x: batteryBody.x + Style.pixelAlignCenter(bodyBackground.width, width)
|
||||
y: batteryBody.y + bodyBackground.y + Style.pixelAlignCenter(bodyBackground.height, height)
|
||||
icon: root.stateIcon
|
||||
pointSize: Style.toOdd(root.baseSize)
|
||||
color: Qt.alpha(root.textColor, 0.75)
|
||||
|
||||
Behavior on opacity {
|
||||
enabled: !Settings.data.general.animationDisabled
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
|
||||
// Rounded group container using the variant surface color.
|
||||
// To be used in side panels and settings panes to group fields or buttons.
|
||||
// Opacity is based on panelBackgroundOpacity but clamped to a minimum to avoid full transparency.
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property color color: Color.mSurfaceVariant
|
||||
property bool forceOpaque: false
|
||||
property alias radius: bg.radius
|
||||
property alias border: bg.border
|
||||
|
||||
Rectangle {
|
||||
id: bg
|
||||
anchors.fill: parent
|
||||
radius: Style.radiusM
|
||||
border.color: Style.boxBorderColor
|
||||
border.width: Style.borderS
|
||||
color: {
|
||||
if (forceOpaque) {
|
||||
return root.color;
|
||||
}
|
||||
|
||||
return Color.smartAlpha(root.color);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property bool running: true
|
||||
property color color: Color.mPrimary
|
||||
property int size: Style.baseWidgetSize
|
||||
property int strokeWidth: Style.borderL
|
||||
property int duration: Style.animationSlow * 2
|
||||
|
||||
implicitWidth: size
|
||||
implicitHeight: size
|
||||
|
||||
// GPU-optimized spinner - draw once, rotate with GPU transform
|
||||
Item {
|
||||
id: spinner
|
||||
anchors.fill: parent
|
||||
|
||||
// Static canvas - drawn ONCE, then cached
|
||||
Canvas {
|
||||
id: canvas
|
||||
anchors.fill: parent
|
||||
renderStrategy: Canvas.Cooperative // Better performance than Threaded for simple shapes
|
||||
renderTarget: Canvas.FramebufferObject // GPU texture
|
||||
|
||||
// Enable layer caching - critical for performance!
|
||||
layer.enabled: true
|
||||
layer.smooth: true
|
||||
|
||||
Component.onCompleted: {
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
onPaint: {
|
||||
var ctx = getContext("2d");
|
||||
ctx.reset();
|
||||
|
||||
var centerX = width / 2;
|
||||
var centerY = height / 2;
|
||||
var radius = Math.min(width, height) / 2 - strokeWidth / 2;
|
||||
|
||||
ctx.strokeStyle = root.color;
|
||||
ctx.lineWidth = Math.max(1, root.strokeWidth);
|
||||
ctx.lineCap = "round";
|
||||
|
||||
// Draw arc with gap (270 degrees = 3/4 of circle)
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + Math.PI * 1.5);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth rotation animation - uses GPU transform, NO canvas repaints!
|
||||
RotationAnimation on rotation {
|
||||
running: root.running
|
||||
from: 0
|
||||
to: 360
|
||||
duration: root.duration
|
||||
loops: Animation.Infinite
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// Public properties
|
||||
property string text: ""
|
||||
property string icon: ""
|
||||
property var tooltipText
|
||||
property color backgroundColor: Color.mPrimary
|
||||
property color textColor: Color.mOnPrimary
|
||||
property color hoverColor: Color.mHover
|
||||
property color textHoverColor: Color.mOnHover
|
||||
property real fontSize: Style.fontSizeM
|
||||
property int fontWeight: Style.fontWeightSemiBold
|
||||
property real iconSize: Style.fontSizeL
|
||||
property bool outlined: false
|
||||
property int horizontalAlignment: Qt.AlignHCenter
|
||||
property real buttonRadius: Style.iRadiusS
|
||||
|
||||
// Signals
|
||||
signal clicked
|
||||
signal rightClicked
|
||||
signal middleClicked
|
||||
signal entered
|
||||
signal exited
|
||||
|
||||
// Internal properties
|
||||
property bool hovered: false
|
||||
readonly property color contentColor: {
|
||||
if (!root.enabled) {
|
||||
return Color.mOnSurfaceVariant;
|
||||
}
|
||||
if (root.hovered) {
|
||||
return root.textHoverColor;
|
||||
}
|
||||
if (root.outlined) {
|
||||
return root.backgroundColor;
|
||||
}
|
||||
return root.textColor;
|
||||
}
|
||||
|
||||
// Dimensions - include margin so border renders cleanly at fractional scales
|
||||
implicitWidth: bg.implicitWidth + 2 * Style.borderS
|
||||
implicitHeight: bg.implicitHeight + 2 * Style.borderS
|
||||
|
||||
opacity: enabled ? 1.0 : 0.6
|
||||
|
||||
Rectangle {
|
||||
id: bg
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.borderS
|
||||
|
||||
implicitWidth: contentRow.implicitWidth + (root.fontSize * 2)
|
||||
implicitHeight: contentRow.implicitHeight + (root.fontSize)
|
||||
|
||||
radius: root.buttonRadius
|
||||
color: {
|
||||
if (!root.enabled)
|
||||
return root.outlined ? "transparent" : Qt.lighter(Color.mSurfaceVariant, 1.2);
|
||||
if (root.hovered)
|
||||
return root.hoverColor;
|
||||
return root.outlined ? "transparent" : root.backgroundColor;
|
||||
}
|
||||
|
||||
border.width: root.outlined ? Style.borderS : 0
|
||||
border.color: {
|
||||
if (!root.enabled)
|
||||
return Color.mOutline;
|
||||
if (root.hovered)
|
||||
return root.hoverColor;
|
||||
return root.outlined ? root.backgroundColor : "transparent";
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
enabled: !Color.isTransitioning
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on border.color {
|
||||
enabled: !Color.isTransitioning
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
RowLayout {
|
||||
id: contentRow
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: root.horizontalAlignment === Qt.AlignLeft ? parent.left : undefined
|
||||
anchors.horizontalCenter: root.horizontalAlignment === Qt.AlignHCenter ? parent.horizontalCenter : undefined
|
||||
anchors.leftMargin: root.horizontalAlignment === Qt.AlignLeft ? Style.marginL : 0
|
||||
spacing: Style.marginXS
|
||||
|
||||
// Icon (optional)
|
||||
NIcon {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
visible: root.icon !== ""
|
||||
icon: root.icon
|
||||
pointSize: root.iconSize
|
||||
color: root.contentColor
|
||||
|
||||
Behavior on color {
|
||||
enabled: !Color.isTransitioning
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Text
|
||||
NText {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
visible: root.text !== ""
|
||||
text: root.text
|
||||
pointSize: root.fontSize
|
||||
font.weight: root.fontWeight
|
||||
color: root.contentColor
|
||||
|
||||
Behavior on color {
|
||||
enabled: !Color.isTransitioning
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse interaction
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
enabled: root.enabled
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
|
||||
onEntered: {
|
||||
root.hovered = root.enabled ? true : false;
|
||||
root.entered();
|
||||
if (root.hovered && root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
|
||||
TooltipService.show(root, root.tooltipText);
|
||||
}
|
||||
}
|
||||
onExited: {
|
||||
root.hovered = false;
|
||||
root.exited();
|
||||
if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
|
||||
TooltipService.hide();
|
||||
}
|
||||
}
|
||||
onPressed: mouse => {
|
||||
if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
|
||||
TooltipService.hide();
|
||||
}
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
root.clicked();
|
||||
} else if (mouse.button == Qt.RightButton) {
|
||||
root.rightClicked();
|
||||
} else if (mouse.button == Qt.MiddleButton) {
|
||||
root.middleClicked();
|
||||
}
|
||||
}
|
||||
|
||||
onCanceled: {
|
||||
root.hovered = false;
|
||||
if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
|
||||
TooltipService.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
|
||||
RowLayout {
|
||||
id: root
|
||||
|
||||
// Public API
|
||||
property string label: ""
|
||||
property string description: ""
|
||||
property bool checked: false
|
||||
property bool hovering: false
|
||||
property color activeColor: Color.mPrimary
|
||||
property color activeOnColor: Color.mOnPrimary
|
||||
property int baseSize: root.defaultSize
|
||||
property real labelSize: Style.fontSizeL
|
||||
|
||||
readonly property int defaultSize: Style.baseWidgetSize * 0.7
|
||||
|
||||
signal toggled(bool checked)
|
||||
signal entered
|
||||
signal exited
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
NLabel {
|
||||
label: root.label
|
||||
labelSize: root.labelSize
|
||||
description: root.description
|
||||
visible: root.label !== "" || root.description !== ""
|
||||
}
|
||||
|
||||
// Spacer to push the checkbox to the far right
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: box
|
||||
|
||||
opacity: enabled ? 1.0 : 0.6
|
||||
Layout.margins: Style.borderS
|
||||
implicitWidth: Style.toOdd(root.baseSize)
|
||||
implicitHeight: Style.toOdd(root.baseSize)
|
||||
radius: Style.iRadiusXS * (root.baseSize / root.defaultSize)
|
||||
color: root.checked ? root.activeColor : Color.mSurface
|
||||
border.color: Color.mOutline
|
||||
border.width: Style.borderS
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
NIcon {
|
||||
visible: root.checked
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
y: Style.pixelAlignCenter(parent.height, height)
|
||||
icon: "check"
|
||||
color: root.activeOnColor
|
||||
pointSize: Style.toOdd(root.baseSize * 0.5)
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
onEntered: {
|
||||
hovering = true;
|
||||
root.entered();
|
||||
}
|
||||
onExited: {
|
||||
hovering = false;
|
||||
root.exited();
|
||||
}
|
||||
onClicked: root.toggled(!root.checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
// Compact circular statistic display using Layout management
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property real ratio: 0 // 0..1 range
|
||||
property string icon: ""
|
||||
property string suffix: "%"
|
||||
property real contentScale: 1.0
|
||||
property color fillColor: Color.mPrimary
|
||||
property var tooltipText
|
||||
property string tooltipDirection: "top"
|
||||
|
||||
// Arc geometry constants
|
||||
readonly property real _gaugeSize: 60 * contentScale
|
||||
readonly property real _lineWidth: 6 * contentScale
|
||||
readonly property real _arcRadius: _gaugeSize / 2 - 5 * contentScale
|
||||
// Arc goes from 150° to 390° (30°), gap at bottom
|
||||
// Bottom of arc is at y = center + radius * sin(30°) = center + radius * 0.5
|
||||
// Plus half line width for stroke
|
||||
readonly property real _arcBottomY: _gaugeSize / 2 + _arcRadius * 0.5 + _lineWidth / 2
|
||||
// Height needs to include the icon which sits inside the arc gap
|
||||
// Icon is ~12px tall, positioned 4px below text center, need ~4px more padding
|
||||
readonly property real _contentHeight: _arcBottomY + 4 * contentScale
|
||||
|
||||
implicitWidth: Math.round(_gaugeSize)
|
||||
implicitHeight: Math.round(_contentHeight)
|
||||
Layout.maximumWidth: implicitWidth
|
||||
Layout.maximumHeight: implicitHeight
|
||||
|
||||
// Animated ratio for smooth transitions - reduces repaint frequency
|
||||
property real animatedRatio: ratio
|
||||
|
||||
Behavior on animatedRatio {
|
||||
enabled: !Settings.data.general.animationDisabled
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
// Repaint gauge when animated ratio changes (throttled by animation)
|
||||
onAnimatedRatioChanged: {
|
||||
if (!repaintTimer.running) {
|
||||
repaintTimer.start();
|
||||
}
|
||||
}
|
||||
onFillColorChanged: gauge.requestPaint()
|
||||
|
||||
// Throttle timer to limit repaint frequency during animation (~30 FPS)
|
||||
Timer {
|
||||
id: repaintTimer
|
||||
interval: 33
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
gauge.requestPaint();
|
||||
// Stop repeating once animation settles
|
||||
if (Math.abs(root.animatedRatio - root.ratio) < 0.001) {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Canvas {
|
||||
id: gauge
|
||||
width: root._gaugeSize
|
||||
height: root._gaugeSize
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
y: 0
|
||||
|
||||
// Optimized Canvas settings for better GPU performance
|
||||
renderStrategy: Canvas.Cooperative
|
||||
renderTarget: Canvas.FramebufferObject
|
||||
|
||||
// Enable layer caching - critical for performance!
|
||||
layer.enabled: true
|
||||
layer.smooth: true
|
||||
|
||||
// Hide until first paint to avoid white FBO flash
|
||||
visible: false
|
||||
onPainted: visible = true
|
||||
|
||||
Component.onCompleted: {
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
onPaint: {
|
||||
const ctx = getContext("2d");
|
||||
const w = width, h = height;
|
||||
const cx = w / 2, cy = h / 2;
|
||||
const r = root._arcRadius;
|
||||
|
||||
// Rotated 90° to the right: gap at the bottom
|
||||
// Start at 150° and end at 390° (30°) → bottom opening
|
||||
const start = Math.PI * 5 / 6; // 150°
|
||||
const endBg = Math.PI * 13 / 6; // 390° (equivalent to 30°)
|
||||
|
||||
ctx.reset();
|
||||
ctx.lineWidth = root._lineWidth;
|
||||
ctx.lineCap = Settings.data.general.iRadiusRatio > 0 ? "round" : "butt";
|
||||
|
||||
// Track uses outline for contrast against surfaceVariant backgrounds
|
||||
ctx.strokeStyle = Color.mSurface;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, start, endBg);
|
||||
ctx.stroke();
|
||||
|
||||
// Value arc - only draw if ratio is meaningful (> 0.5%)
|
||||
const r2 = Math.max(0, Math.min(1, root.animatedRatio));
|
||||
if (r2 > 0.005) {
|
||||
const end = start + (endBg - start) * r2;
|
||||
ctx.strokeStyle = root.fillColor;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, start, end);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Percent centered in the circle
|
||||
NText {
|
||||
id: valueLabel
|
||||
anchors.horizontalCenter: gauge.horizontalCenter
|
||||
anchors.verticalCenter: gauge.verticalCenter
|
||||
anchors.verticalCenterOffset: -4 * root.contentScale
|
||||
text: `${Math.round(root.animatedRatio * 100)}${root.suffix}`
|
||||
pointSize: Style.fontSizeM * root.contentScale * 0.9
|
||||
font.weight: Style.fontWeightBold
|
||||
color: root.fillColor
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
NIcon {
|
||||
id: iconText
|
||||
anchors.horizontalCenter: gauge.horizontalCenter
|
||||
anchors.top: valueLabel.bottom
|
||||
anchors.topMargin: 4 * root.contentScale
|
||||
icon: root.icon
|
||||
color: root.fillColor
|
||||
pointSize: Style.fontSizeM * root.contentScale
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onEntered: {
|
||||
if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
|
||||
TooltipService.show(root, root.tooltipText, root.tooltipDirection);
|
||||
}
|
||||
}
|
||||
onExited: {
|
||||
if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
|
||||
TooltipService.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import "../Helpers/ColorsConvert.js" as ColorsConvert
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var now: Time.now
|
||||
|
||||
// Style: "analog" or "digital"
|
||||
property string clockStyle: "analog"
|
||||
|
||||
// Show seconds progress ring (digital only)
|
||||
property bool showProgress: true
|
||||
|
||||
// Color properties
|
||||
property color backgroundColor: Color.mPrimary
|
||||
property color clockColor: Color.mOnPrimary
|
||||
|
||||
property color secondHandColor: {
|
||||
var defaultColor = Color.mError;
|
||||
var bestContrast = 1.0; // 1.0 is "no contrast"
|
||||
var bestColor = defaultColor;
|
||||
var candidates = [Color.mSecondary, Color.mTertiary, Color.mError];
|
||||
|
||||
const minContrast = 1.149;
|
||||
|
||||
for (var i = 0; i < candidates.length; i++) {
|
||||
var candidate = candidates[i];
|
||||
var contrastClock = ColorsConvert.getContrastRatio(candidate.toString(), clockColor.toString());
|
||||
if (contrastClock < minContrast) {
|
||||
continue;
|
||||
}
|
||||
var contrastBg = ColorsConvert.getContrastRatio(candidate.toString(), backgroundColor.toString());
|
||||
if (contrastBg < minContrast) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var currentWorstContrast = Math.min(contrastBg, contrastClock);
|
||||
|
||||
if (currentWorstContrast > bestContrast) {
|
||||
bestContrast = currentWorstContrast;
|
||||
bestColor = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return bestColor;
|
||||
}
|
||||
|
||||
property color progressColor: root.secondHandColor
|
||||
|
||||
// Font size properties for digital clock
|
||||
property real hoursFontSize: Style.fontSizeXS
|
||||
property real minutesFontSize: Style.fontSizeXXS
|
||||
property int hoursFontWeight: Style.fontWeightBold
|
||||
property int minutesFontWeight: Style.fontWeightBold
|
||||
|
||||
// Scale ratio for canvas line widths (used by desktop widget scaling)
|
||||
property real scaleRatio: Style.uiScaleRatio
|
||||
|
||||
height: Math.round((Style.fontSizeXXXL * 1.9) / 2 * Style.uiScaleRatio) * 2
|
||||
width: root.height
|
||||
|
||||
Loader {
|
||||
id: clockLoader
|
||||
anchors.fill: parent
|
||||
|
||||
sourceComponent: {
|
||||
if (root.clockStyle === "analog")
|
||||
return analogClockComponent;
|
||||
if (root.clockStyle === "binary")
|
||||
return binaryClockComponent;
|
||||
return digitalClockComponent;
|
||||
}
|
||||
|
||||
onLoaded: {
|
||||
item.now = Qt.binding(function () {
|
||||
return root.now;
|
||||
});
|
||||
item.backgroundColor = Qt.binding(function () {
|
||||
return root.backgroundColor;
|
||||
});
|
||||
item.clockColor = Qt.binding(function () {
|
||||
return root.clockColor;
|
||||
});
|
||||
if (item.hasOwnProperty("secondHandColor")) {
|
||||
item.secondHandColor = Qt.binding(function () {
|
||||
return root.secondHandColor;
|
||||
});
|
||||
}
|
||||
if (item.hasOwnProperty("progressColor")) {
|
||||
item.progressColor = Qt.binding(function () {
|
||||
return root.progressColor;
|
||||
});
|
||||
}
|
||||
if (item.hasOwnProperty("hoursFontSize")) {
|
||||
item.hoursFontSize = Qt.binding(function () {
|
||||
return root.hoursFontSize;
|
||||
});
|
||||
}
|
||||
if (item.hasOwnProperty("minutesFontSize")) {
|
||||
item.minutesFontSize = Qt.binding(function () {
|
||||
return root.minutesFontSize;
|
||||
});
|
||||
}
|
||||
if ("hoursFontWeight" in item) {
|
||||
item.hoursFontWeight = Qt.binding(function () {
|
||||
return root.hoursFontWeight;
|
||||
});
|
||||
}
|
||||
if ("minutesFontWeight" in item) {
|
||||
item.minutesFontWeight = Qt.binding(function () {
|
||||
return root.minutesFontWeight;
|
||||
});
|
||||
}
|
||||
if (item.hasOwnProperty("scaleRatio")) {
|
||||
item.scaleRatio = Qt.binding(function () {
|
||||
return root.scaleRatio;
|
||||
});
|
||||
}
|
||||
if ("showProgress" in item) {
|
||||
item.showProgress = Qt.binding(function () {
|
||||
return root.showProgress;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Analog Clock Component
|
||||
component NClockAnalog: Item {
|
||||
property var now
|
||||
property color backgroundColor: Color.mPrimary
|
||||
property color clockColor: Color.mOnPrimary
|
||||
property color secondHandColor: Color.mError
|
||||
property real scaleRatio: Style.uiScaleRatio
|
||||
anchors.fill: parent
|
||||
|
||||
Canvas {
|
||||
id: clockCanvas
|
||||
anchors.fill: parent
|
||||
|
||||
Connections {
|
||||
target: Time
|
||||
function onNowChanged() {
|
||||
clockCanvas.requestPaint();
|
||||
}
|
||||
}
|
||||
|
||||
onPaint: {
|
||||
var currentTime = Time.now;
|
||||
var hours = currentTime.getHours();
|
||||
var minutes = currentTime.getMinutes();
|
||||
var seconds = currentTime.getSeconds();
|
||||
|
||||
const markAlpha = 0.7;
|
||||
var ctx = getContext("2d");
|
||||
ctx.reset();
|
||||
ctx.translate(width / 2, height / 2);
|
||||
var radius = Math.min(width, height) / 2;
|
||||
|
||||
// Hour marks
|
||||
ctx.strokeStyle = Qt.alpha(clockColor, markAlpha);
|
||||
ctx.lineWidth = 2 * scaleRatio;
|
||||
var scaleFactor = 0.7;
|
||||
|
||||
for (var i = 0; i < 12; i++) {
|
||||
var scaleFactor = 0.8;
|
||||
if (i % 3 === 0) {
|
||||
scaleFactor = 0.65;
|
||||
}
|
||||
ctx.save();
|
||||
ctx.rotate(i * Math.PI / 6);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -radius * scaleFactor);
|
||||
ctx.lineTo(0, -radius);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Hour hand
|
||||
ctx.save();
|
||||
var hourAngle = (hours % 12 + minutes / 60) * Math.PI / 6;
|
||||
ctx.rotate(hourAngle);
|
||||
ctx.strokeStyle = clockColor;
|
||||
ctx.lineWidth = 3 * scaleRatio;
|
||||
ctx.lineCap = "round";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(0, -radius * 0.6);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
// Minute hand
|
||||
ctx.save();
|
||||
var minuteAngle = (minutes + seconds / 60) * Math.PI / 30;
|
||||
ctx.rotate(minuteAngle);
|
||||
ctx.strokeStyle = clockColor;
|
||||
ctx.lineWidth = 2 * scaleRatio;
|
||||
ctx.lineCap = "round";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(0, -radius * 0.9);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
// Second hand
|
||||
ctx.save();
|
||||
var secondAngle = seconds * Math.PI / 30;
|
||||
ctx.rotate(secondAngle);
|
||||
ctx.strokeStyle = secondHandColor;
|
||||
ctx.lineWidth = 1.6 * scaleRatio;
|
||||
ctx.lineCap = "round";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(0, -radius);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
// Center dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 3 * scaleRatio, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = clockColor;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
Component.onCompleted: requestPaint()
|
||||
}
|
||||
}
|
||||
|
||||
// Digital Clock Component
|
||||
component NClockDigital: Item {
|
||||
property var now
|
||||
property color backgroundColor: Color.mPrimary
|
||||
property color clockColor: Color.mOnPrimary
|
||||
property color progressColor: Color.mError
|
||||
property real hoursFontSize: Style.fontSizeXS
|
||||
property real minutesFontSize: Style.fontSizeXXS
|
||||
property int hoursFontWeight: Style.fontWeightBold
|
||||
property int minutesFontWeight: Style.fontWeightBold
|
||||
property real scaleRatio: Style.uiScaleRatio
|
||||
property bool showProgress: true
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
// Digital clock's seconds circular progress
|
||||
Canvas {
|
||||
id: secondsProgress
|
||||
anchors.fill: parent
|
||||
visible: showProgress
|
||||
property real progress: now.getSeconds() / 60
|
||||
onProgressChanged: requestPaint()
|
||||
Connections {
|
||||
target: Time
|
||||
function onNowChanged() {
|
||||
const total = now.getSeconds() * 1000 + now.getMilliseconds();
|
||||
secondsProgress.progress = total / 60000;
|
||||
}
|
||||
}
|
||||
onPaint: {
|
||||
var ctx = getContext("2d");
|
||||
var centerX = width / 2;
|
||||
var centerY = height / 2;
|
||||
var radius = Math.min(width, height) / 2 - 3 * scaleRatio;
|
||||
ctx.reset();
|
||||
|
||||
// Background circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
|
||||
ctx.lineWidth = 2.5 * scaleRatio;
|
||||
ctx.strokeStyle = Qt.alpha(clockColor, 0.15);
|
||||
ctx.stroke();
|
||||
|
||||
// Progress arc
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + progress * 2 * Math.PI);
|
||||
ctx.lineWidth = 2.5 * scaleRatio;
|
||||
ctx.strokeStyle = progressColor;
|
||||
ctx.lineCap = "round";
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Digital clock
|
||||
ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
spacing: -Style.marginXXS
|
||||
|
||||
NText {
|
||||
text: {
|
||||
var t = Settings.data.location.use12hourFormat ? I18n.locale.toString(now, "hh AP") : I18n.locale.toString(now, "HH");
|
||||
return t.split(" ")[0];
|
||||
}
|
||||
|
||||
pointSize: hoursFontSize
|
||||
font.weight: hoursFontWeight
|
||||
color: clockColor
|
||||
family: Settings.data.ui.fontFixed
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
|
||||
NText {
|
||||
text: Qt.formatTime(now, "mm")
|
||||
pointSize: minutesFontSize
|
||||
font.weight: minutesFontWeight
|
||||
color: clockColor
|
||||
family: Settings.data.ui.fontFixed
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Binary Clock Component
|
||||
component NClockBinary: Item {
|
||||
property var now
|
||||
property color backgroundColor
|
||||
property color clockColor: Color.mOnPrimary
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
readonly property int h: now.getHours()
|
||||
readonly property int m: now.getMinutes()
|
||||
readonly property int s: now.getSeconds()
|
||||
|
||||
// BCD (Binary Coded Decimal) Format:
|
||||
// H1 H2 : M1 M2 : S1 S2
|
||||
// H1 (tens): 0-2 (2 bits)
|
||||
// H2 (ones): 0-9 (4 bits)
|
||||
// M1 (tens): 0-5 (3 bits)
|
||||
// M2 (ones): 0-9 (4 bits)
|
||||
// S1 (tens): 0-5 (3 bits)
|
||||
// S2 (ones): 0-9 (4 bits)
|
||||
|
||||
RowLayout {
|
||||
anchors.centerIn: parent
|
||||
spacing: parent.width * 0.05
|
||||
|
||||
// Hours
|
||||
RowLayout {
|
||||
spacing: parent.parent.width * 0.02
|
||||
BinaryColumn {
|
||||
value: Math.floor(h / 10)
|
||||
bits: 2
|
||||
dotSize: root.width * 0.08
|
||||
activeColor: clockColor
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
}
|
||||
BinaryColumn {
|
||||
value: h % 10
|
||||
bits: 4
|
||||
dotSize: root.width * 0.08
|
||||
activeColor: clockColor
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
}
|
||||
}
|
||||
|
||||
// Minutes
|
||||
RowLayout {
|
||||
spacing: parent.parent.width * 0.02
|
||||
BinaryColumn {
|
||||
value: Math.floor(m / 10)
|
||||
bits: 3
|
||||
dotSize: root.width * 0.08
|
||||
activeColor: clockColor
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
}
|
||||
BinaryColumn {
|
||||
value: m % 10
|
||||
bits: 4
|
||||
dotSize: root.width * 0.08
|
||||
activeColor: clockColor
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
}
|
||||
}
|
||||
|
||||
// Seconds
|
||||
RowLayout {
|
||||
spacing: parent.parent.width * 0.02
|
||||
BinaryColumn {
|
||||
value: Math.floor(s / 10)
|
||||
bits: 3
|
||||
dotSize: root.width * 0.08
|
||||
activeColor: clockColor
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
}
|
||||
BinaryColumn {
|
||||
value: s % 10
|
||||
bits: 4
|
||||
dotSize: root.width * 0.08
|
||||
activeColor: clockColor
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component BinaryColumn: Column {
|
||||
property int value: 0
|
||||
property int bits: 4
|
||||
property real dotSize: 10
|
||||
property color activeColor: "white"
|
||||
|
||||
spacing: dotSize * 0.4
|
||||
|
||||
Repeater {
|
||||
model: bits
|
||||
|
||||
Rectangle {
|
||||
property int bitIndex: (bits - 1) - index
|
||||
property bool isActive: (value >> bitIndex) & 1
|
||||
|
||||
width: dotSize
|
||||
height: dotSize
|
||||
radius: dotSize / 2
|
||||
color: isActive ? activeColor : Qt.alpha(activeColor, 0.2)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: 200
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: analogClockComponent
|
||||
NClockAnalog {}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: digitalClockComponent
|
||||
NClockDigital {}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: binaryClockComponent
|
||||
NClockBinary {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
|
||||
ColumnLayout {
|
||||
id: root
|
||||
|
||||
property string label: ""
|
||||
property string description: ""
|
||||
property bool expanded: false
|
||||
property real contentSpacing: Style.marginM
|
||||
property bool _userInteracted: false
|
||||
|
||||
signal toggled(bool expanded)
|
||||
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
// Default property to accept children
|
||||
default property alias content: contentLayout.children
|
||||
|
||||
// Header with clickable area
|
||||
Rectangle {
|
||||
id: headerContainer
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: headerContent.implicitHeight + Style.margin2M
|
||||
color: root.expanded ? Color.mSecondary : Color.mPrimary
|
||||
radius: Style.iRadiusM
|
||||
border.color: root.expanded ? Color.mOnSecondary : Color.mOutline
|
||||
border.width: Style.borderS
|
||||
|
||||
// Smooth color transitions
|
||||
Behavior on color {
|
||||
enabled: root._userInteracted
|
||||
ColorAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on border.color {
|
||||
enabled: root._userInteracted
|
||||
ColorAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: headerArea
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
|
||||
onClicked: {
|
||||
root._userInteracted = true;
|
||||
root.expanded = !root.expanded;
|
||||
root.toggled(root.expanded);
|
||||
}
|
||||
|
||||
// Hover effect overlay
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: headerArea.containsMouse ? Color.mOnSurface : "transparent"
|
||||
opacity: headerArea.containsMouse ? 0.08 : 0
|
||||
radius: headerContainer.radius // Reference the container's radius directly
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: headerContent
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginM
|
||||
|
||||
// Expand/collapse icon with rotation animation
|
||||
NIcon {
|
||||
id: chevronIcon
|
||||
icon: "chevron-right"
|
||||
pointSize: Style.fontSizeL
|
||||
color: root.expanded ? Color.mOnSecondary : Color.mOnPrimary
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
rotation: root.expanded ? 90 : 0
|
||||
Behavior on rotation {
|
||||
enabled: root._userInteracted
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
enabled: root._userInteracted
|
||||
ColorAnimation {
|
||||
duration: Style.animationNormal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Header text content - properly contained
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
spacing: Style.marginL
|
||||
|
||||
NText {
|
||||
text: root.label
|
||||
pointSize: Style.fontSizeL
|
||||
font.weight: Style.fontWeightSemiBold
|
||||
color: root.expanded ? Color.mOnSecondary : Color.mOnPrimary
|
||||
wrapMode: Text.WordWrap
|
||||
|
||||
Behavior on color {
|
||||
enabled: root._userInteracted
|
||||
ColorAnimation {
|
||||
duration: Style.animationNormal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
text: root.description
|
||||
pointSize: Style.fontSizeS
|
||||
font.weight: Style.fontWeightRegular
|
||||
color: root.expanded ? Color.mOnSecondary : Color.mOnPrimary
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
visible: root.description !== ""
|
||||
opacity: 0.87
|
||||
|
||||
Behavior on color {
|
||||
enabled: root._userInteracted
|
||||
ColorAnimation {
|
||||
duration: Style.animationNormal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collapsible content with Material 3 styling
|
||||
Rectangle {
|
||||
id: contentContainer
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: Style.marginS
|
||||
|
||||
visible: root.expanded
|
||||
color: Color.mSurface
|
||||
radius: Style.iRadiusL
|
||||
border.color: Color.mOutline
|
||||
border.width: Style.borderS
|
||||
|
||||
// Dynamic height based on content
|
||||
Layout.preferredHeight: expanded ? contentLayout.implicitHeight + Style.margin2L : 0
|
||||
|
||||
// Smooth height animation
|
||||
Behavior on Layout.preferredHeight {
|
||||
enabled: root._userInteracted
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
// Content layout
|
||||
ColumnLayout {
|
||||
id: contentLayout
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginL
|
||||
spacing: root.contentSpacing
|
||||
}
|
||||
|
||||
// Fade in animation for content
|
||||
opacity: root.expanded ? 1.0 : 0.0
|
||||
Behavior on opacity {
|
||||
enabled: root._userInteracted
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
|
||||
RowLayout {
|
||||
id: root
|
||||
|
||||
property string label: I18n.tr("common.select-color")
|
||||
property string description: I18n.tr("common.select-color-description")
|
||||
property string tooltip: ""
|
||||
property string currentKey: ""
|
||||
property var defaultValue: undefined
|
||||
property var noneColor: undefined // color declared as var so we can nullify
|
||||
property var noneOnColor: undefined // color declared as var so we can nullify
|
||||
|
||||
readonly property bool isValueChanged: (defaultValue !== undefined) && (currentKey !== defaultValue)
|
||||
readonly property string indicatorTooltip: {
|
||||
I18n.tr("panels.indicator.default-value", {
|
||||
"value": defaultValue === "" ? "(empty)" : String(defaultValue)
|
||||
});
|
||||
}
|
||||
|
||||
readonly property int diameter: Style.baseWidgetSize * 0.9 * Style.uiScaleRatio
|
||||
|
||||
signal selected(string key)
|
||||
|
||||
NLabel {
|
||||
label: root.label
|
||||
description: root.description
|
||||
showIndicator: root.isValueChanged
|
||||
indicatorTooltip: root.indicatorTooltip
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: colourRow
|
||||
|
||||
opacity: enabled ? 1.0 : 0.6
|
||||
Layout.minimumWidth: root.diameter * Color.colorKeyModel.length
|
||||
|
||||
Repeater {
|
||||
model: Color.colorKeyModel
|
||||
|
||||
Rectangle {
|
||||
id: colorCircle
|
||||
|
||||
property bool isSelected: root.currentKey === modelData.key
|
||||
property bool isHovered: circleMouseArea.containsMouse
|
||||
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
implicitWidth: root.diameter
|
||||
implicitHeight: root.diameter
|
||||
radius: root.diameter * 0.5
|
||||
color: (modelData.key === "none" && root.noneColor !== undefined) ? root.noneColor : Color.resolveColorKey(modelData.key)
|
||||
border.color: (isSelected || isHovered) ? Color.mOnSurface : Color.mOutline
|
||||
border.width: Style.borderM
|
||||
|
||||
MouseArea {
|
||||
id: circleMouseArea
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onEntered: TooltipService.show(parent, modelData.name)
|
||||
onExited: TooltipService.hide()
|
||||
onClicked: {
|
||||
root.currentKey = modelData.key;
|
||||
root.selected(modelData.key);
|
||||
}
|
||||
}
|
||||
|
||||
NIcon {
|
||||
anchors.centerIn: parent
|
||||
icon: "check"
|
||||
pointSize: Math.max(Style.fontSizeXS, colorCircle.width * 0.4)
|
||||
color: (modelData.key === "none" && root.noneOnColor !== undefined) ? root.noneOnColor : Color.resolveOnColorKey(modelData.key)
|
||||
font.weight: Style.fontWeightBold
|
||||
visible: colorCircle.isSelected
|
||||
}
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property var screen
|
||||
property color selectedColor: "black"
|
||||
|
||||
signal colorSelected(color color)
|
||||
|
||||
Layout.margins: Style.borderS
|
||||
implicitWidth: 150
|
||||
implicitHeight: Math.round(Style.baseWidgetSize * 1.1)
|
||||
|
||||
radius: Style.iRadiusM
|
||||
color: Color.mSurface
|
||||
border.color: Color.mOutline
|
||||
border.width: Style.borderS
|
||||
|
||||
// Minimized Look
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
var dialog = Qt.createComponent("NColorPickerDialog.qml").createObject(root, {
|
||||
"selectedColor": selectedColor,
|
||||
"parent": Overlay.overlay,
|
||||
"screen": root.screen
|
||||
});
|
||||
// Connect the dialog's signal to the picker's signal
|
||||
dialog.colorSelected.connect(function (color) {
|
||||
root.selectedColor = color;
|
||||
root.colorSelected(color);
|
||||
});
|
||||
|
||||
dialog.open();
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors {
|
||||
leftMargin: Style.marginL
|
||||
rightMargin: Style.marginL
|
||||
}
|
||||
spacing: Style.marginS
|
||||
|
||||
// Color preview circle
|
||||
Rectangle {
|
||||
Layout.preferredWidth: root.height * 0.6
|
||||
Layout.preferredHeight: root.height * 0.6
|
||||
radius: Math.min(Style.iRadiusL, Layout.preferredWidth / 2)
|
||||
color: root.selectedColor
|
||||
border.color: Color.mOutline
|
||||
border.width: Style.borderS
|
||||
}
|
||||
|
||||
NText {
|
||||
text: root.selectedColor.toString().toUpperCase()
|
||||
family: Settings.data.ui.fontFixed
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
NIcon {
|
||||
icon: "color-picker"
|
||||
color: Color.mOnSurfaceVariant
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
import QtQml
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import "../Helpers/ColorList.js" as ColorList
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Popup {
|
||||
id: root
|
||||
|
||||
property var screen
|
||||
property color selectedColor: "black"
|
||||
|
||||
enum EditMode {
|
||||
R,
|
||||
G,
|
||||
B,
|
||||
H,
|
||||
S,
|
||||
V
|
||||
}
|
||||
property int editMode: NColorPickerDialog.EditMode.R
|
||||
|
||||
// Code to deal with Hue when color is achromatic
|
||||
property real stableHue: 0
|
||||
onSelectedColorChanged: {
|
||||
if (selectedColor.hsvHue >= 0) {
|
||||
stableHue = selectedColor.hsvHue;
|
||||
}
|
||||
if (liveMode && visible) {
|
||||
colorSelected(selectedColor);
|
||||
}
|
||||
}
|
||||
readonly property real displayHue: selectedColor.hsvHue < 0 ? stableHue : selectedColor.hsvHue
|
||||
|
||||
signal colorSelected(color color)
|
||||
|
||||
// When true: hides Cancel/Apply, emits colorSelected on every color change
|
||||
property bool liveMode: false
|
||||
|
||||
width: 580
|
||||
padding: Style.marginXL
|
||||
|
||||
modal: true
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
onOpened: {
|
||||
// Center on screen after popup is opened and parent position is known
|
||||
if (screen && parent) {
|
||||
var parentPos = parent.mapToItem(null, 0, 0);
|
||||
x = Math.round((screen.width - width) / 2 - parentPos.x);
|
||||
y = Math.round((screen.height - height) / 2 - parentPos.y);
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: Color.mSurface
|
||||
radius: Style.iRadiusS
|
||||
border.color: Color.mPrimary
|
||||
border.width: Style.borderM
|
||||
}
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
id: mainContent
|
||||
spacing: Style.marginL
|
||||
|
||||
// Header
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
RowLayout {
|
||||
spacing: Style.marginS
|
||||
|
||||
NIcon {
|
||||
icon: "color-picker"
|
||||
pointSize: Style.fontSizeXXL
|
||||
color: Color.mPrimary
|
||||
}
|
||||
|
||||
NText {
|
||||
text: I18n.tr("widgets.color-picker.title")
|
||||
pointSize: Style.fontSizeXL
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Color.mPrimary
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "close"
|
||||
onClicked: root.close()
|
||||
}
|
||||
}
|
||||
|
||||
// Color preview section
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 80
|
||||
radius: Style.iRadiusS
|
||||
color: root.selectedColor
|
||||
border.color: Color.mOutline
|
||||
border.width: Style.borderS
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 0
|
||||
anchors.fill: parent
|
||||
|
||||
Item {
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
|
||||
NText {
|
||||
text: root.selectedColor.toString().toUpperCase()
|
||||
family: Settings.data.ui.fontFixed
|
||||
pointSize: Style.fontSizeL
|
||||
font.weight: Style.fontWeightMedium
|
||||
color: root.selectedColor.r + root.selectedColor.g + root.selectedColor.b > 1.5 ? "black" : "white"
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
|
||||
NText {
|
||||
text: "RGB(" + Math.round(root.selectedColor.r * 255) + ", " + Math.round(root.selectedColor.g * 255) + ", " + Math.round(root.selectedColor.b * 255) + ")"
|
||||
family: Settings.data.ui.fontFixed
|
||||
pointSize: Style.fontSizeM
|
||||
color: root.selectedColor.r + root.selectedColor.g + root.selectedColor.b > 1.5 ? "black" : "white"
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main Box
|
||||
NBox {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: controlsOutterColumn.implicitHeight + Style.margin2L
|
||||
|
||||
ButtonGroup {
|
||||
id: colorValues
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: controlsOutterColumn
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginL
|
||||
spacing: Style.marginM
|
||||
|
||||
RowLayout {
|
||||
spacing: Style.marginL // Ensure nice gap between Left and Right groups
|
||||
|
||||
// SpinBoxes Column
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.alignment: Qt.AlignTop
|
||||
Layout.preferredWidth: 3
|
||||
|
||||
// --- RED ---
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginM
|
||||
|
||||
NRadioButton {
|
||||
ButtonGroup.group: colorValues
|
||||
text: "R"
|
||||
font.weight: Style.fontWeightMedium
|
||||
checked: true
|
||||
onClicked: root.editMode = NColorPickerDialog.EditMode.R
|
||||
Layout.fillWidth: false
|
||||
}
|
||||
|
||||
NSpinBox {
|
||||
id: redSpinBox
|
||||
from: 0
|
||||
to: 255
|
||||
|
||||
onValueChanged: {
|
||||
if (!selectedSlider.pressed && !fieldMouse.pressed && !hexInput.activeFocus && value !== Math.round(root.selectedColor.r * 255)) {
|
||||
root.selectedColor = Qt.rgba(value / 255, root.selectedColor.g, root.selectedColor.b, 1);
|
||||
}
|
||||
}
|
||||
Binding {
|
||||
target: redSpinBox
|
||||
property: "value"
|
||||
value: Math.round(root.selectedColor.r * 255)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- GREEN ---
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginM
|
||||
|
||||
NRadioButton {
|
||||
ButtonGroup.group: colorValues
|
||||
text: "G"
|
||||
font.weight: Style.fontWeightMedium
|
||||
onClicked: root.editMode = NColorPickerDialog.EditMode.G
|
||||
Layout.fillWidth: false
|
||||
}
|
||||
|
||||
NSpinBox {
|
||||
id: greenSpinBox
|
||||
from: 0
|
||||
to: 255
|
||||
|
||||
onValueChanged: {
|
||||
if (!selectedSlider.pressed && !fieldMouse.pressed && !hexInput.activeFocus && value !== Math.round(root.selectedColor.g * 255)) {
|
||||
root.selectedColor = Qt.rgba(root.selectedColor.r, value / 255, root.selectedColor.b, 1);
|
||||
}
|
||||
}
|
||||
Binding {
|
||||
target: greenSpinBox
|
||||
property: "value"
|
||||
value: Math.round(root.selectedColor.g * 255)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- BLUE ---
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginM
|
||||
|
||||
NRadioButton {
|
||||
ButtonGroup.group: colorValues
|
||||
text: "B"
|
||||
font.weight: Style.fontWeightMedium
|
||||
onClicked: root.editMode = NColorPickerDialog.EditMode.B
|
||||
Layout.fillWidth: false
|
||||
}
|
||||
|
||||
NSpinBox {
|
||||
id: blueSpinBox
|
||||
from: 0
|
||||
to: 255
|
||||
|
||||
onValueChanged: {
|
||||
if (!selectedSlider.pressed && !fieldMouse.pressed && !hexInput.activeFocus && value !== Math.round(root.selectedColor.b * 255)) {
|
||||
root.selectedColor = Qt.rgba(root.selectedColor.r, root.selectedColor.g, value / 255, 1);
|
||||
}
|
||||
}
|
||||
Binding {
|
||||
target: blueSpinBox
|
||||
property: "value"
|
||||
value: Math.round(root.selectedColor.b * 255)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spacer
|
||||
Item {
|
||||
Layout.fillHeight: true
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
// --- HUE ---
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginM
|
||||
|
||||
NRadioButton {
|
||||
ButtonGroup.group: colorValues
|
||||
text: "H"
|
||||
font.weight: Style.fontWeightMedium
|
||||
checked: true
|
||||
onClicked: root.editMode = NColorPickerDialog.EditMode.H
|
||||
Layout.fillWidth: false
|
||||
}
|
||||
|
||||
NSpinBox {
|
||||
id: hueSpinBox
|
||||
from: 0
|
||||
to: 360
|
||||
|
||||
onValueChanged: {
|
||||
if (!selectedSlider.pressed && !fieldMouse.pressed && !hexInput.activeFocus && value !== Math.round(root.displayHue * 360)) {
|
||||
var newHue = value / 360;
|
||||
root.selectedColor = Qt.hsva(newHue, root.selectedColor.hsvSaturation, root.selectedColor.hsvValue, 1);
|
||||
root.stableHue = newHue;
|
||||
}
|
||||
}
|
||||
|
||||
Binding {
|
||||
target: hueSpinBox
|
||||
property: "value"
|
||||
value: Math.round(root.displayHue * 360)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- SATURATION ---
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginM
|
||||
|
||||
NRadioButton {
|
||||
ButtonGroup.group: colorValues
|
||||
text: "S"
|
||||
font.weight: Style.fontWeightMedium
|
||||
onClicked: root.editMode = NColorPickerDialog.EditMode.S
|
||||
Layout.fillWidth: false
|
||||
}
|
||||
|
||||
NSpinBox {
|
||||
id: satSpinBox
|
||||
from: 0
|
||||
to: 100
|
||||
|
||||
onValueChanged: {
|
||||
if (!selectedSlider.pressed && !fieldMouse.pressed && !hexInput.activeFocus && value !== Math.round(root.selectedColor.hsvSaturation * 100)) {
|
||||
root.selectedColor = Qt.hsva(root.selectedColor.hsvHue, value / 100, root.selectedColor.hsvValue, 1);
|
||||
}
|
||||
}
|
||||
Binding {
|
||||
target: satSpinBox
|
||||
property: "value"
|
||||
value: Math.round(root.selectedColor.hsvSaturation * 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- VALUE ---
|
||||
RowLayout {
|
||||
spacing: Style.marginM
|
||||
|
||||
NRadioButton {
|
||||
ButtonGroup.group: colorValues
|
||||
text: "V"
|
||||
font.weight: Style.fontWeightMedium
|
||||
onClicked: root.editMode = NColorPickerDialog.EditMode.V
|
||||
Layout.fillWidth: false
|
||||
}
|
||||
|
||||
NSpinBox {
|
||||
id: valSpinBox
|
||||
from: 0
|
||||
to: 100
|
||||
|
||||
onValueChanged: {
|
||||
if (!selectedSlider.pressed && !fieldMouse.pressed && !hexInput.activeFocus && value !== Math.round(root.selectedColor.hsvValue * 100)) {
|
||||
root.selectedColor = Qt.hsva(root.selectedColor.hsvHue, root.selectedColor.hsvSaturation, value / 100, 1);
|
||||
}
|
||||
}
|
||||
Binding {
|
||||
target: valSpinBox
|
||||
property: "value"
|
||||
value: Math.round(root.selectedColor.hsvValue * 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spacer
|
||||
Item {
|
||||
Layout.fillHeight: true
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
// Hex input
|
||||
RowLayout {
|
||||
id: hexInput
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginM
|
||||
|
||||
NLabel {
|
||||
label: "Hex:"
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
NTextInput {
|
||||
text: root.selectedColor.toString().toUpperCase()
|
||||
fontFamily: Settings.data.ui.fontFixed
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumWidth: implicitWidth
|
||||
onEditingFinished: {
|
||||
if (/^#[0-9A-F]{6}$/i.test(text)) {
|
||||
root.selectedColor = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.preferredWidth: 5
|
||||
Layout.alignment: Qt.AlignTop
|
||||
spacing: Style.marginS
|
||||
|
||||
Layout.topMargin: Math.round(2 * Style.uiScaleRatio) //Shim to try and line up colorField with SpinBoxes
|
||||
|
||||
// --- SLIDER ---
|
||||
NColorSlider {
|
||||
id: selectedSlider
|
||||
Layout.fillHeight: true
|
||||
rainbowMode: root.editMode === NColorPickerDialog.EditMode.H
|
||||
topColor: {
|
||||
if (rainbowMode)
|
||||
return "transparent";
|
||||
switch (root.editMode) {
|
||||
case NColorPickerDialog.EditMode.R:
|
||||
return "#FF0000";
|
||||
case NColorPickerDialog.EditMode.G:
|
||||
return "#00FF00";
|
||||
case NColorPickerDialog.EditMode.B:
|
||||
return "#0000FF";
|
||||
default:
|
||||
return "#FFFFFF";
|
||||
}
|
||||
}
|
||||
|
||||
Binding {
|
||||
target: selectedSlider
|
||||
property: "value"
|
||||
when: !selectedSlider.pressed // Only update from model when NOT dragging
|
||||
value: {
|
||||
switch (root.editMode) {
|
||||
case NColorPickerDialog.EditMode.R:
|
||||
return root.selectedColor.r;
|
||||
case NColorPickerDialog.EditMode.G:
|
||||
return root.selectedColor.g;
|
||||
case NColorPickerDialog.EditMode.B:
|
||||
return root.selectedColor.b;
|
||||
case NColorPickerDialog.EditMode.H:
|
||||
return root.displayHue;
|
||||
case NColorPickerDialog.EditMode.S:
|
||||
return root.selectedColor.hsvSaturation;
|
||||
case NColorPickerDialog.EditMode.V:
|
||||
return root.selectedColor.hsvValue;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMoved: {
|
||||
var v = value;
|
||||
switch (root.editMode) {
|
||||
case NColorPickerDialog.EditMode.R:
|
||||
root.selectedColor = Qt.rgba(v, root.selectedColor.g, root.selectedColor.b, 1);
|
||||
break;
|
||||
case NColorPickerDialog.EditMode.G:
|
||||
root.selectedColor = Qt.rgba(root.selectedColor.r, v, root.selectedColor.b, 1);
|
||||
break;
|
||||
case NColorPickerDialog.EditMode.B:
|
||||
root.selectedColor = Qt.rgba(root.selectedColor.r, root.selectedColor.g, v, 1);
|
||||
break;
|
||||
case NColorPickerDialog.EditMode.H:
|
||||
root.selectedColor = Qt.hsva(v, root.selectedColor.hsvSaturation, root.selectedColor.hsvValue, 1);
|
||||
root.stableHue = v;
|
||||
break;
|
||||
case NColorPickerDialog.EditMode.S:
|
||||
root.selectedColor = Qt.hsva(root.selectedColor.hsvHue, v, root.selectedColor.hsvValue, 1);
|
||||
break;
|
||||
case NColorPickerDialog.EditMode.V:
|
||||
root.selectedColor = Qt.hsva(root.selectedColor.hsvHue, root.selectedColor.hsvSaturation, v, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Color Field
|
||||
Rectangle {
|
||||
id: colorField
|
||||
Layout.fillWidth: true
|
||||
|
||||
Layout.preferredHeight: width
|
||||
Layout.alignment: Qt.AlignTop
|
||||
|
||||
radius: 0
|
||||
border.color: Color.mOutline
|
||||
border.width: Style.borderS
|
||||
clip: true
|
||||
|
||||
ShaderEffect {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 1 // Avoid drawing over the border
|
||||
|
||||
fragmentShader: "../Shaders/qsb/color_picker.frag.qsb"
|
||||
|
||||
// Pass which radio is selected
|
||||
readonly property real fixedVal: {
|
||||
switch (root.editMode) {
|
||||
case NColorPickerDialog.EditMode.R:
|
||||
return root.selectedColor.r;
|
||||
case NColorPickerDialog.EditMode.G:
|
||||
return root.selectedColor.g;
|
||||
case NColorPickerDialog.EditMode.B:
|
||||
return root.selectedColor.b;
|
||||
case NColorPickerDialog.EditMode.H:
|
||||
return root.displayHue;
|
||||
case NColorPickerDialog.EditMode.S:
|
||||
return root.selectedColor.hsvSaturation;
|
||||
case NColorPickerDialog.EditMode.V:
|
||||
return root.selectedColor.hsvValue;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Send as one vector because GPUs are so damn picky
|
||||
property vector4d params: Qt.vector4d(1.0, fixedVal, root.editMode, 0)
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: fieldMouse
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.CrossCursor
|
||||
|
||||
// Update color when clicking or dragging
|
||||
function updateColor(mouse) {
|
||||
// Normalize X and Y (0.0 to 1.0)
|
||||
var xVal = Math.max(0, Math.min(1, mouse.x / width));
|
||||
var yVal = Math.max(0, Math.min(1, 1.0 - (mouse.y / height))); // Flip Y (0 at bottom)
|
||||
|
||||
// Get the current fixed value (from the slider)
|
||||
var fixed = 0.0;
|
||||
|
||||
switch (root.editMode) {
|
||||
case NColorPickerDialog.EditMode.R:
|
||||
fixed = root.selectedColor.r;
|
||||
root.selectedColor = Qt.rgba(fixed, xVal, yVal, 1);
|
||||
break;
|
||||
case NColorPickerDialog.EditMode.G:
|
||||
fixed = root.selectedColor.g;
|
||||
root.selectedColor = Qt.rgba(xVal, fixed, yVal, 1);
|
||||
break;
|
||||
case NColorPickerDialog.EditMode.B:
|
||||
fixed = root.selectedColor.b;
|
||||
root.selectedColor = Qt.rgba(xVal, yVal, fixed, 1);
|
||||
break;
|
||||
case NColorPickerDialog.EditMode.H:
|
||||
// Use stableHue to prevent flipping to -1
|
||||
fixed = root.displayHue;
|
||||
root.selectedColor = Qt.hsva(fixed, xVal, yVal, 1);
|
||||
root.stableHue = fixed;
|
||||
break;
|
||||
case NColorPickerDialog.EditMode.S:
|
||||
fixed = root.selectedColor.hsvSaturation;
|
||||
root.selectedColor = Qt.hsva(xVal, fixed, yVal, 1);
|
||||
// If we dragged Hue (xVal), update stableHue
|
||||
root.stableHue = yVal;
|
||||
break;
|
||||
case NColorPickerDialog.EditMode.V:
|
||||
fixed = root.selectedColor.hsvValue;
|
||||
root.selectedColor = Qt.hsva(xVal, yVal, fixed, 1);
|
||||
// If we dragged Hue (xVal), update stableHue
|
||||
root.stableHue = xVal;
|
||||
break;
|
||||
}
|
||||
}
|
||||
onPressed: mouse => updateColor(mouse)
|
||||
onPositionChanged: mouse => updateColor(mouse)
|
||||
}
|
||||
|
||||
// Color Indicator
|
||||
Rectangle {
|
||||
width: 10
|
||||
height: 10
|
||||
radius: Math.min(Style.iRadiusXS, width / 2)
|
||||
color: "transparent"
|
||||
border.color: root.selectedColor.hsvValue < 0.5 ? "white" : "black"
|
||||
border.width: 1
|
||||
|
||||
// Find position based on the current color
|
||||
readonly property point selectedPos: {
|
||||
switch (root.editMode) {
|
||||
case NColorPickerDialog.EditMode.R:
|
||||
return Qt.point(root.selectedColor.g, root.selectedColor.b);
|
||||
case NColorPickerDialog.EditMode.G:
|
||||
return Qt.point(root.selectedColor.r, root.selectedColor.b);
|
||||
case NColorPickerDialog.EditMode.B:
|
||||
return Qt.point(root.selectedColor.r, root.selectedColor.g);
|
||||
case NColorPickerDialog.EditMode.H:
|
||||
return Qt.point(root.selectedColor.hsvSaturation, root.selectedColor.hsvValue);
|
||||
case NColorPickerDialog.EditMode.S:
|
||||
return Qt.point(root.displayHue, root.selectedColor.hsvValue);
|
||||
case NColorPickerDialog.EditMode.V:
|
||||
return Qt.point(root.displayHue, root.selectedColor.hsvSaturation);
|
||||
default:
|
||||
return Qt.point(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert values to pixel position
|
||||
x: (selectedPos.x * parent.width) - width / 2
|
||||
y: ((1.0 - selectedPos.y) * parent.height) - height / 2
|
||||
}
|
||||
|
||||
// Redraw the border in case Color Indicator is near the edge
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "transparent"
|
||||
border.color: Color.mOutline
|
||||
border.width: Style.borderS
|
||||
antialiasing: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NLabel {
|
||||
label: I18n.tr("widgets.color-picker.palette-label")
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NScrollView {
|
||||
id: paletteScrollView
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Math.min(paletteGrid.implicitHeight, 200)
|
||||
verticalPolicy: paletteGrid.implicitHeight > 200 ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff
|
||||
horizontalPolicy: ScrollBar.AlwaysOff
|
||||
|
||||
GridLayout {
|
||||
id: paletteGrid
|
||||
width: paletteScrollView.availableWidth
|
||||
columns: 17
|
||||
columnSpacing: 6
|
||||
rowSpacing: 6
|
||||
|
||||
NLabel {
|
||||
Layout.columnSpan: 17
|
||||
Layout.fillWidth: true
|
||||
description: I18n.tr("widgets.color-picker.palette-theme-colors")
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{
|
||||
name: "mPrimary",
|
||||
color: Color.mPrimary
|
||||
},
|
||||
{
|
||||
name: "mSecondary",
|
||||
color: Color.mSecondary
|
||||
},
|
||||
{
|
||||
name: "mTertiary",
|
||||
color: Color.mTertiary
|
||||
},
|
||||
{
|
||||
name: "mError",
|
||||
color: Color.mError
|
||||
},
|
||||
{
|
||||
name: "mSurface",
|
||||
color: Color.mSurface
|
||||
},
|
||||
{
|
||||
name: "mSurfaceVariant",
|
||||
color: Color.mSurfaceVariant
|
||||
},
|
||||
{
|
||||
name: "mOutline",
|
||||
color: Color.mOutline
|
||||
}
|
||||
]
|
||||
|
||||
Rectangle {
|
||||
width: 24
|
||||
height: 24
|
||||
radius: Style.iRadiusXXS
|
||||
color: modelData.color
|
||||
border.color: root.selectedColor.toString() === modelData.color.toString() ? Color.mPrimary : Color.mOutline
|
||||
border.width: Math.max(1, root.selectedColor.toString() === modelData.color.toString() ? Style.borderM : Style.borderS)
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
|
||||
onEntered: {
|
||||
TooltipService.show(parent, modelData.name + "\n" + parent.color.toString().toUpperCase());
|
||||
}
|
||||
onExited: {
|
||||
TooltipService.hide();
|
||||
}
|
||||
onClicked: {
|
||||
root.selectedColor = modelData.color;
|
||||
TooltipService.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NDivider {
|
||||
Layout.columnSpan: 17
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: Style.marginXS
|
||||
Layout.bottomMargin: 0
|
||||
}
|
||||
|
||||
NLabel {
|
||||
Layout.columnSpan: 17
|
||||
Layout.fillWidth: true
|
||||
description: I18n.tr("widgets.color-picker.palette-description")
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: ColorList.colors
|
||||
|
||||
Rectangle {
|
||||
width: 24
|
||||
height: 24
|
||||
radius: Math.min(Style.iRadiusXS, width / 2)
|
||||
color: modelData.color
|
||||
border.color: root.selectedColor.toString() === modelData.color.toString() ? Color.mPrimary : Color.mOutline
|
||||
border.width: root.selectedColor.toString() === modelData.color.toString() ? 2 : 1
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
|
||||
onEntered: {
|
||||
TooltipService.show(parent, modelData.name + "\n" + parent.color.toString().toUpperCase(), "auto");
|
||||
}
|
||||
onExited: {
|
||||
TooltipService.hide();
|
||||
}
|
||||
onClicked: {
|
||||
root.selectedColor = modelData.color;
|
||||
TooltipService.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
visible: !root.liveMode
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 1
|
||||
Layout.bottomMargin: 1
|
||||
spacing: 10
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NButton {
|
||||
id: cancelButton
|
||||
text: I18n.tr("common.cancel")
|
||||
outlined: cancelButton.hovered ? false : true
|
||||
onClicked: {
|
||||
root.close();
|
||||
}
|
||||
}
|
||||
|
||||
NButton {
|
||||
text: I18n.tr("common.apply")
|
||||
icon: "check"
|
||||
onClicked: {
|
||||
root.colorSelected(root.selectedColor);
|
||||
// Delay close to prevent click propagation to elements behind the dialog
|
||||
Qt.callLater(() => {
|
||||
root.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Shapes
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
|
||||
Slider {
|
||||
id: root
|
||||
|
||||
property color fillColor: "transparent"
|
||||
property var cutoutColor: Color.mSurface
|
||||
property bool snapAlways: true
|
||||
property real widthRatio: 0.7
|
||||
property var tooltipText
|
||||
property string tooltipDirection: "auto"
|
||||
property bool hovering: false
|
||||
property color topColor: "white"
|
||||
property color bottomColor: "black"
|
||||
property bool rainbowMode: false
|
||||
|
||||
readonly property real knobDiameter: Math.round((Style.baseWidgetSize * widthRatio * Style.uiScaleRatio) / 2) * 2
|
||||
readonly property real trackWidth: Math.round((knobDiameter * 0.4 * Style.uiScaleRatio) / 2) * 2
|
||||
readonly property real trackRadius: Math.min(Style.iRadiusL, trackWidth / 2)
|
||||
readonly property real cutoutExtra: Math.round((Style.baseWidgetSize * 0.1 * Style.uiScaleRatio) / 2) * 2
|
||||
|
||||
orientation: Qt.Vertical
|
||||
|
||||
padding: cutoutExtra / 2
|
||||
|
||||
snapMode: snapAlways ? Slider.SnapAlways : Slider.SnapOnRelease
|
||||
implicitWidth: Math.max(trackWidth, knobDiameter)
|
||||
|
||||
background: Item {
|
||||
id: bgContainer
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: root.trackWidth
|
||||
height: root.availableHeight
|
||||
|
||||
LinearGradient {
|
||||
id: standardLinearGradient
|
||||
x1: 0
|
||||
y1: 0
|
||||
x2: 0
|
||||
y2: root.availableHeight
|
||||
GradientStop {
|
||||
position: 0.0
|
||||
color: root.topColor
|
||||
}
|
||||
GradientStop {
|
||||
position: 1.0
|
||||
color: root.bottomColor
|
||||
}
|
||||
}
|
||||
|
||||
LinearGradient {
|
||||
id: rainbowLinearGradient
|
||||
x1: 0
|
||||
y1: 0
|
||||
x2: 0
|
||||
y2: root.availableHeight
|
||||
GradientStop {
|
||||
position: 0.000
|
||||
color: "#FF0000"
|
||||
}
|
||||
GradientStop {
|
||||
position: 0.167
|
||||
color: "#FF00FF"
|
||||
}
|
||||
GradientStop {
|
||||
position: 0.333
|
||||
color: "#0000FF"
|
||||
}
|
||||
GradientStop {
|
||||
position: 0.500
|
||||
color: "#00FFFF"
|
||||
}
|
||||
GradientStop {
|
||||
position: 0.667
|
||||
color: "#00FF00"
|
||||
}
|
||||
GradientStop {
|
||||
position: 0.833
|
||||
color: "#FFFF00"
|
||||
}
|
||||
GradientStop {
|
||||
position: 1.000
|
||||
color: "#FF0000"
|
||||
}
|
||||
}
|
||||
|
||||
Shape {
|
||||
anchors.centerIn: parent
|
||||
width: root.trackWidth
|
||||
height: bgContainer.height
|
||||
visible: root.trackWidth > 0 && bgContainer.height > 0
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
asynchronous: true
|
||||
|
||||
ShapePath {
|
||||
id: trackPath
|
||||
strokeColor: Qt.alpha(Color.mOutline, 0.5)
|
||||
strokeWidth: Style.borderS
|
||||
fillGradient: root.rainbowMode ? rainbowLinearGradient : standardLinearGradient
|
||||
|
||||
readonly property real w: root.trackWidth
|
||||
readonly property real h: root.availableHeight
|
||||
readonly property real r: root.trackRadius
|
||||
|
||||
startX: r
|
||||
startY: 0
|
||||
|
||||
PathLine {
|
||||
x: trackPath.w - trackPath.r
|
||||
y: 0
|
||||
}
|
||||
PathArc {
|
||||
x: trackPath.w
|
||||
y: trackPath.r
|
||||
radiusX: trackPath.r
|
||||
radiusY: trackPath.r
|
||||
}
|
||||
PathLine {
|
||||
x: trackPath.w
|
||||
y: trackPath.h - trackPath.r
|
||||
}
|
||||
PathArc {
|
||||
x: trackPath.w - trackPath.r
|
||||
y: trackPath.h
|
||||
radiusX: trackPath.r
|
||||
radiusY: trackPath.r
|
||||
}
|
||||
PathLine {
|
||||
x: trackPath.r
|
||||
y: trackPath.h
|
||||
}
|
||||
PathArc {
|
||||
x: 0
|
||||
y: trackPath.h - trackPath.r
|
||||
radiusX: trackPath.r
|
||||
radiusY: trackPath.r
|
||||
}
|
||||
PathLine {
|
||||
x: 0
|
||||
y: trackPath.r
|
||||
}
|
||||
PathArc {
|
||||
x: trackPath.r
|
||||
y: 0
|
||||
radiusX: trackPath.r
|
||||
radiusY: trackPath.r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Circular cutout
|
||||
Rectangle {
|
||||
id: knobCutout
|
||||
implicitWidth: root.knobDiameter + root.cutoutExtra
|
||||
implicitHeight: root.knobDiameter + root.cutoutExtra
|
||||
radius: Math.min(Style.iRadiusL, width / 2)
|
||||
color: root.cutoutColor !== undefined ? root.cutoutColor : Color.mSurface
|
||||
y: root.visualPosition * (root.availableHeight - root.knobDiameter) - root.cutoutExtra / 2
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
}
|
||||
|
||||
handle: Item {
|
||||
implicitWidth: root.knobDiameter
|
||||
implicitHeight: root.knobDiameter
|
||||
y: root.topPadding + root.visualPosition * (root.availableHeight - height)
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
Rectangle {
|
||||
id: knob
|
||||
implicitWidth: root.knobDiameter
|
||||
implicitHeight: root.knobDiameter
|
||||
radius: Math.min(Style.iRadiusL, width / 2)
|
||||
color: {
|
||||
if (root.rainbowMode) {
|
||||
// Hue Logic: Map position (0.0 to 1.0) directly to Hue
|
||||
return Qt.hsva(1 - root.visualPosition, 1, 1, 1);
|
||||
} else {
|
||||
// Linear Interpolation for Standard Gradients
|
||||
// visualPosition 0.0 = Top, 1.0 = Bottom
|
||||
var t = root.visualPosition;
|
||||
var r = root.topColor.r * (1 - t) + root.bottomColor.r * t;
|
||||
var g = root.topColor.g * (1 - t) + root.bottomColor.g * t;
|
||||
var b = root.topColor.b * (1 - t) + root.bottomColor.b * t;
|
||||
return Qt.rgba(r, g, b, 1);
|
||||
}
|
||||
}
|
||||
|
||||
border.color: root.pressed ? Color.mHover : Color.mPrimary
|
||||
border.width: Style.borderL
|
||||
anchors.centerIn: parent
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
enabled: true
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton // Don't accept any mouse buttons - only hover
|
||||
propagateComposedEvents: true
|
||||
|
||||
onEntered: {
|
||||
root.hovering = true;
|
||||
if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
|
||||
TooltipService.show(knob, root.tooltipText, root.tooltipDirection);
|
||||
}
|
||||
}
|
||||
|
||||
onExited: {
|
||||
root.hovering = false;
|
||||
if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
|
||||
TooltipService.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hide tooltip when slider is pressed (anywhere on the slider)
|
||||
Connections {
|
||||
target: root
|
||||
function onPressedChanged() {
|
||||
if (root.pressed && root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
|
||||
TooltipService.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
RowLayout {
|
||||
id: root
|
||||
|
||||
property real minimumWidth: 200
|
||||
property real popupHeight: 180
|
||||
|
||||
property string label: ""
|
||||
property string description: ""
|
||||
property string tooltip: ""
|
||||
property var model
|
||||
property string currentKey: ""
|
||||
property string placeholder: ""
|
||||
property var defaultValue: undefined
|
||||
property string settingsPath: ""
|
||||
property real baseSize: 1.0
|
||||
|
||||
readonly property real preferredHeight: Math.round(Style.baseWidgetSize * 1.1 * root.baseSize)
|
||||
readonly property var comboBox: combo
|
||||
|
||||
signal selected(string key)
|
||||
|
||||
spacing: Style.marginL
|
||||
|
||||
// Less strict comparison with != (instead of !==) so it can properly compare int vs string (ex for FPS: 30 and "30")
|
||||
readonly property bool isValueChanged: (defaultValue !== undefined) && (currentKey != defaultValue)
|
||||
|
||||
readonly property string indicatorTooltip: {
|
||||
if (defaultValue === undefined)
|
||||
return "";
|
||||
var displayValue = "";
|
||||
if (defaultValue === "") {
|
||||
// Try to find the display name for empty key in the model
|
||||
var found = false;
|
||||
if (root.model) {
|
||||
if (Array.isArray(root.model)) {
|
||||
for (var i = 0; i < root.model.length; i++) {
|
||||
var item = root.model[i];
|
||||
if (item && item.key === "") {
|
||||
displayValue = item.name || I18n.tr("panels.indicator.system-default");
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (typeof root.model.get === 'function') {
|
||||
for (var i = 0; i < root.itemCount(); i++) {
|
||||
var item = root.getItem(i);
|
||||
if (item && item.key === "") {
|
||||
displayValue = item.name || I18n.tr("panels.indicator.system-default");
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// If not found in model, show "System Default" instead of "(empty)"
|
||||
if (!found) {
|
||||
displayValue = I18n.tr("panels.indicator.system-default");
|
||||
}
|
||||
} else {
|
||||
// Try to find the display name for the default key in the model
|
||||
var found = false;
|
||||
if (root.model) {
|
||||
if (Array.isArray(root.model)) {
|
||||
for (var i = 0; i < root.model.length; i++) {
|
||||
var item = root.model[i];
|
||||
if (item && item.key === defaultValue) {
|
||||
displayValue = item.name || String(defaultValue);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (typeof root.model.get === 'function') {
|
||||
for (var i = 0; i < root.itemCount(); i++) {
|
||||
var item = root.getItem(i);
|
||||
if (item && item.key === defaultValue) {
|
||||
displayValue = item.name || String(defaultValue);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
displayValue = String(defaultValue);
|
||||
}
|
||||
}
|
||||
return I18n.tr("panels.indicator.default-value", {
|
||||
"value": displayValue
|
||||
});
|
||||
}
|
||||
|
||||
function itemCount() {
|
||||
if (!root.model)
|
||||
return 0;
|
||||
if (typeof root.model.count === 'number')
|
||||
return root.model.count;
|
||||
if (Array.isArray(root.model))
|
||||
return root.model.length;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getItem(index) {
|
||||
if (!root.model)
|
||||
return null;
|
||||
if (typeof root.model.get === 'function')
|
||||
return root.model.get(index);
|
||||
if (Array.isArray(root.model))
|
||||
return root.model[index];
|
||||
return null;
|
||||
}
|
||||
|
||||
function findIndexByKey(key) {
|
||||
for (var i = 0; i < itemCount(); i++) {
|
||||
var item = getItem(i);
|
||||
if (item && item.key === key)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
NLabel {
|
||||
label: root.label
|
||||
description: root.description
|
||||
showIndicator: root.isValueChanged
|
||||
indicatorTooltip: root.indicatorTooltip
|
||||
}
|
||||
|
||||
ComboBox {
|
||||
id: combo
|
||||
|
||||
opacity: enabled ? 1.0 : 0.6
|
||||
Layout.margins: Style.borderS
|
||||
Layout.minimumWidth: Math.round(root.minimumWidth * Style.uiScaleRatio)
|
||||
Layout.preferredHeight: Math.round(root.preferredHeight * Style.uiScaleRatio)
|
||||
implicitWidth: Layout.minimumWidth
|
||||
model: root.model
|
||||
textRole: "name"
|
||||
currentIndex: root.findIndexByKey(root.currentKey)
|
||||
|
||||
onActivated: {
|
||||
var item = root.getItem(combo.currentIndex);
|
||||
if (item && item.key !== undefined)
|
||||
root.selected(item.key);
|
||||
}
|
||||
|
||||
Keys.onUpPressed: event => {
|
||||
if (combo.popup.visible) {
|
||||
if (listView.currentIndex > 0) {
|
||||
listView.currentIndex--;
|
||||
listView.positionViewAtIndex(listView.currentIndex, ListView.Contain);
|
||||
}
|
||||
event.accepted = true;
|
||||
} else {
|
||||
event.accepted = false;
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onDownPressed: event => {
|
||||
if (combo.popup.visible) {
|
||||
if (listView.currentIndex < root.itemCount() - 1) {
|
||||
listView.currentIndex++;
|
||||
listView.positionViewAtIndex(listView.currentIndex, ListView.Contain);
|
||||
}
|
||||
event.accepted = true;
|
||||
} else {
|
||||
event.accepted = false;
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onReturnPressed: event => {
|
||||
if (combo.popup.visible) {
|
||||
var item = root.getItem(listView.currentIndex);
|
||||
if (item && item.key !== undefined) {
|
||||
root.selected(item.key);
|
||||
combo.currentIndex = listView.currentIndex;
|
||||
combo.popup.close();
|
||||
}
|
||||
event.accepted = true;
|
||||
} else {
|
||||
event.accepted = false;
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onEnterPressed: event => {
|
||||
combo.Keys.returnPressed(event);
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
implicitWidth: Math.round(Style.baseWidgetSize * 3.75 * Style.uiScaleRatio)
|
||||
implicitHeight: Math.round(root.preferredHeight * Style.uiScaleRatio)
|
||||
color: Color.mSurface
|
||||
border.color: combo.activeFocus ? Color.mSecondary : Color.mOutline
|
||||
border.width: Style.borderS
|
||||
radius: Style.iRadiusM
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
onEntered: {
|
||||
if (root.tooltip != "") {
|
||||
TooltipService.show(root, root.tooltip);
|
||||
}
|
||||
}
|
||||
onExited: {
|
||||
if (root.tooltip != "") {
|
||||
TooltipService.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: NText {
|
||||
leftPadding: Style.marginL
|
||||
rightPadding: combo.indicator.width + Style.marginL
|
||||
pointSize: Style.fontSizeM
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
color: combo.currentIndex >= 0 ? Color.mOnSurface : Color.mOnSurfaceVariant
|
||||
text: {
|
||||
if (combo.currentIndex >= 0 && combo.currentIndex < root.itemCount()) {
|
||||
var item = root.getItem(combo.currentIndex);
|
||||
return item ? item.name : root.placeholder;
|
||||
}
|
||||
return root.placeholder;
|
||||
}
|
||||
}
|
||||
|
||||
indicator: NIcon {
|
||||
x: combo.width - width - Style.marginM
|
||||
y: combo.topPadding + (combo.availableHeight - height) / 2
|
||||
icon: "caret-down"
|
||||
pointSize: Style.fontSizeL
|
||||
}
|
||||
|
||||
popup: Popup {
|
||||
y: combo.height + Style.marginS
|
||||
implicitWidth: combo.width
|
||||
implicitHeight: Math.min(Math.round(root.popupHeight * Style.uiScaleRatio), listView.contentHeight + Style.margin2M)
|
||||
padding: Style.marginM
|
||||
|
||||
onOpened: {
|
||||
listView.currentIndex = combo.currentIndex;
|
||||
listView.positionViewAtIndex(combo.currentIndex, ListView.Beginning);
|
||||
}
|
||||
|
||||
contentItem: NListView {
|
||||
id: listView
|
||||
property var comboBox: combo
|
||||
model: combo.popup.visible ? root.model : null
|
||||
highlightMoveDuration: 0
|
||||
//showGradientMasks: false
|
||||
|
||||
delegate: Rectangle {
|
||||
id: delegateRect
|
||||
required property int index
|
||||
property bool isHighlighted: listView.currentIndex === index
|
||||
|
||||
width: listView.availableWidth
|
||||
height: delegateText.implicitHeight + Style.margin2S
|
||||
radius: Style.iRadiusS
|
||||
color: isHighlighted ? Color.mHover : "transparent"
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
id: delegateText
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Style.marginM
|
||||
anchors.rightMargin: Style.marginM
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
pointSize: Style.fontSizeM
|
||||
color: delegateRect.isHighlighted ? Color.mOnHover : Color.mOnSurface
|
||||
text: {
|
||||
var item = root.getItem(delegateRect.index);
|
||||
return item && item.name ? item.name : "";
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onContainsMouseChanged: {
|
||||
if (containsMouse) {
|
||||
listView.currentIndex = delegateRect.index;
|
||||
}
|
||||
}
|
||||
onClicked: {
|
||||
var item = root.getItem(delegateRect.index);
|
||||
if (item && item.key !== undefined) {
|
||||
root.selected(item.key);
|
||||
listView.comboBox.currentIndex = delegateRect.index;
|
||||
listView.comboBox.popup.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: Color.mSurfaceVariant
|
||||
border.color: Color.mOutline
|
||||
border.width: Style.borderS
|
||||
radius: Style.iRadiusM
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onCurrentKeyChanged() {
|
||||
combo.currentIndex = root.findIndexByKey(root.currentKey);
|
||||
}
|
||||
function onModelChanged() {
|
||||
combo.currentIndex = root.findIndexByKey(root.currentKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
|
||||
/*
|
||||
* NContextMenu - Popup-based context menu for use inside panels and dialogs
|
||||
*
|
||||
* Use this component when you need a context menu inside:
|
||||
* - Settings panels
|
||||
* - Dialogs
|
||||
* - Repeater delegates
|
||||
* - Any nested component context
|
||||
*
|
||||
* For bar widgets and top-level window contexts, use NPopupContextMenu instead,
|
||||
* which provides better screen boundary handling and compositor integration.
|
||||
*
|
||||
* Usage:
|
||||
* NContextMenu {
|
||||
* id: contextMenu
|
||||
* parent: Overlay.overlay
|
||||
* model: [
|
||||
* { "label": "Action 1", "action": "action1", "icon": "icon-name" },
|
||||
* { "label": "Action 2", "action": "action2" }
|
||||
* ]
|
||||
* onTriggered: action => { Logger.i("MyModule", "Selected:", action) }
|
||||
* }
|
||||
*
|
||||
* MouseArea {
|
||||
* onClicked: contextMenu.openAtItem(parent, mouse.x, mouse.y)
|
||||
* }
|
||||
*/
|
||||
Popup {
|
||||
id: root
|
||||
|
||||
property var model: []
|
||||
property real itemHeight: 36
|
||||
property real itemPadding: Style.marginM
|
||||
property int verticalPolicy: ScrollBar.AsNeeded
|
||||
property int horizontalPolicy: ScrollBar.AsNeeded
|
||||
// Optional: explicit item whose bounds the menu must stay within.
|
||||
// When unset, openAtItem auto-detects the nearest clipping ancestor.
|
||||
property Item constrainTo: null
|
||||
property Item _detectedConstraint: null
|
||||
|
||||
signal triggered(string action)
|
||||
|
||||
// Filter out hidden items to avoid spacing artifacts from zero-height items
|
||||
readonly property var filteredModel: {
|
||||
if (!model || model.length === 0)
|
||||
return [];
|
||||
var filtered = [];
|
||||
for (var i = 0; i < model.length; i++) {
|
||||
if (model[i].visible !== false) {
|
||||
filtered.push(model[i]);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
width: 180
|
||||
padding: Style.marginS
|
||||
|
||||
background: Rectangle {
|
||||
color: Color.mSurfaceVariant
|
||||
border.color: Color.mOutline
|
||||
border.width: Style.borderS
|
||||
radius: Style.iRadiusM
|
||||
}
|
||||
|
||||
contentItem: NListView {
|
||||
id: listView
|
||||
implicitHeight: Math.max(contentHeight, root.itemHeight)
|
||||
spacing: Style.marginXXS
|
||||
interactive: contentHeight > root.height
|
||||
verticalPolicy: root.verticalPolicy
|
||||
horizontalPolicy: root.horizontalPolicy
|
||||
reserveScrollbarSpace: false
|
||||
model: root.filteredModel
|
||||
|
||||
delegate: ItemDelegate {
|
||||
id: menuItem
|
||||
width: listView.availableWidth
|
||||
height: root.itemHeight
|
||||
opacity: modelData.enabled !== false ? 1.0 : 0.5
|
||||
enabled: modelData.enabled !== false
|
||||
|
||||
// Store reference to the popup
|
||||
property var popup: root
|
||||
|
||||
background: Rectangle {
|
||||
color: menuItem.hovered && menuItem.enabled ? Color.mHover : "transparent"
|
||||
radius: Style.iRadiusS
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: RowLayout {
|
||||
spacing: Style.marginS
|
||||
|
||||
// Optional icon
|
||||
NIcon {
|
||||
visible: modelData.icon !== undefined
|
||||
icon: modelData.icon || ""
|
||||
pointSize: Style.fontSizeM
|
||||
color: menuItem.hovered && menuItem.enabled ? Color.mOnHover : Color.mOnSurface
|
||||
Layout.leftMargin: root.itemPadding
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
text: modelData.label || modelData.text || ""
|
||||
pointSize: Style.fontSizeM
|
||||
color: menuItem.hovered && menuItem.enabled ? Color.mOnHover : Color.mOnSurface
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: modelData.icon === undefined ? root.itemPadding : 0
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
if (enabled) {
|
||||
popup.triggered(modelData.action || modelData.key || index.toString());
|
||||
popup.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to open at mouse position
|
||||
function openAt(x, y) {
|
||||
if (root.parent) {
|
||||
var menuWidth = root.width;
|
||||
var itemCount = root.filteredModel.length;
|
||||
var menuHeight = Math.max(itemCount * root.itemHeight + Math.max(0, itemCount - 1) * listView.spacing, root.itemHeight) + root.topPadding + root.bottomPadding;
|
||||
var constraint = root.constrainTo || root._detectedConstraint;
|
||||
if (constraint) {
|
||||
var tl = constraint.mapToItem(root.parent, 0, 0);
|
||||
x = Math.max(tl.x, Math.min(x, tl.x + constraint.width - menuWidth));
|
||||
y = Math.max(tl.y, Math.min(y, tl.y + constraint.height - menuHeight));
|
||||
} else {
|
||||
x = Math.max(0, Math.min(x, root.parent.width - menuWidth));
|
||||
y = Math.max(0, Math.min(y, root.parent.height - menuHeight));
|
||||
}
|
||||
}
|
||||
root.x = x;
|
||||
root.y = y;
|
||||
root.open();
|
||||
}
|
||||
|
||||
// Helper function to open at item
|
||||
function openAtItem(item, mouseX, mouseY) {
|
||||
if (!root.constrainTo) {
|
||||
root._detectedConstraint = null;
|
||||
var p = item;
|
||||
while (p && p !== root.parent) {
|
||||
if (p.clip && p.width > 0 && p.height > 0) {
|
||||
root._detectedConstraint = p;
|
||||
break;
|
||||
}
|
||||
p = p.parent;
|
||||
}
|
||||
}
|
||||
var pos = item.mapToItem(root.parent, mouseX || 0, mouseY || 0);
|
||||
openAt(pos.x, pos.y);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property date sampleDate: new Date() // Dec 25, 2023, 2:30:45.123 PM
|
||||
|
||||
signal tokenClicked(string token)
|
||||
|
||||
Layout.margins: Style.borderS
|
||||
color: Color.mSurface
|
||||
border.color: Color.mOutline
|
||||
border.width: Style.borderS
|
||||
radius: Style.iRadiusM
|
||||
|
||||
ColumnLayout {
|
||||
id: column
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginS
|
||||
spacing: Style.marginS
|
||||
|
||||
Flickable {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
contentHeight: tokensColumn.implicitHeight
|
||||
clip: true
|
||||
|
||||
Column {
|
||||
id: tokensColumn
|
||||
width: parent.width
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
// Common format combinations
|
||||
{
|
||||
"category": "Common",
|
||||
"token": "h:mm AP",
|
||||
"description": I18n.tr("widgets.datetime-tokens.common-12hour-time-minutes"),
|
||||
"example": "2:30 PM"
|
||||
},
|
||||
{
|
||||
"category": "Common",
|
||||
"token": "HH:mm",
|
||||
"description": I18n.tr("widgets.datetime-tokens.common-24hour-time-minutes"),
|
||||
"example": "14:30"
|
||||
},
|
||||
{
|
||||
"category": "Common",
|
||||
"token": "HH:mm:ss",
|
||||
"description": I18n.tr("widgets.datetime-tokens.common-24hour-time-seconds"),
|
||||
"example": "14:30:45"
|
||||
},
|
||||
{
|
||||
"category": "Common",
|
||||
"token": "ddd MMM d",
|
||||
"description": I18n.tr("widgets.datetime-tokens.common-weekday-month-day"),
|
||||
"example": "Mon Dec 25"
|
||||
},
|
||||
{
|
||||
"category": "Common",
|
||||
"token": "yyyy-MM-dd",
|
||||
"description": I18n.tr("widgets.datetime-tokens.common-iso-date"),
|
||||
"example": "2023-12-25"
|
||||
},
|
||||
{
|
||||
"category": "Common",
|
||||
"token": "MM/dd/yyyy",
|
||||
"description": I18n.tr("widgets.datetime-tokens.common-us-date"),
|
||||
"example": "12/25/2023"
|
||||
},
|
||||
{
|
||||
"category": "Common",
|
||||
"token": "dd.MM.yyyy",
|
||||
"description": I18n.tr("widgets.datetime-tokens.common-european-date"),
|
||||
"example": "25.12.2023"
|
||||
},
|
||||
{
|
||||
"category": "Common",
|
||||
"token": "ddd, MMM dd",
|
||||
"description": I18n.tr("widgets.datetime-tokens.common-weekday-date"),
|
||||
"example": "Fri, Dec 12"
|
||||
} // Hour tokens
|
||||
,
|
||||
{
|
||||
"category": "Hour",
|
||||
"token": "H",
|
||||
"description": I18n.tr("widgets.datetime-tokens.hour-no-leading-zero"),
|
||||
"example": "14"
|
||||
},
|
||||
{
|
||||
"category": "Hour",
|
||||
"token": "HH",
|
||||
"description": I18n.tr("widgets.datetime-tokens.hour-leading-zero"),
|
||||
"example": "14"
|
||||
} // Minute tokens
|
||||
,
|
||||
{
|
||||
"category": "Minute",
|
||||
"token": "m",
|
||||
"description": I18n.tr("widgets.datetime-tokens.minute-no-leading-zero"),
|
||||
"example": "30"
|
||||
},
|
||||
{
|
||||
"category": "Minute",
|
||||
"token": "mm",
|
||||
"description": I18n.tr("widgets.datetime-tokens.minute-leading-zero"),
|
||||
"example": "30"
|
||||
} // Second tokens
|
||||
,
|
||||
{
|
||||
"category": "Second",
|
||||
"token": "s",
|
||||
"description": I18n.tr("widgets.datetime-tokens.second-no-leading-zero"),
|
||||
"example": "45"
|
||||
},
|
||||
{
|
||||
"category": "Second",
|
||||
"token": "ss",
|
||||
"description": I18n.tr("widgets.datetime-tokens.second-leading-zero"),
|
||||
"example": "45"
|
||||
} // AM/PM tokens
|
||||
,
|
||||
{
|
||||
"category": "AM/PM",
|
||||
"token": "AP",
|
||||
"description": I18n.tr("widgets.datetime-tokens.ampm-uppercase"),
|
||||
"example": "PM"
|
||||
},
|
||||
{
|
||||
"category": "AM/PM",
|
||||
"token": "ap",
|
||||
"description": I18n.tr("widgets.datetime-tokens.ampm-lowercase"),
|
||||
"example": "pm"
|
||||
} // Timezone tokens
|
||||
,
|
||||
{
|
||||
"category": "Timezone",
|
||||
"token": "t",
|
||||
"description": I18n.tr("widgets.datetime-tokens.timezone-abbreviation"),
|
||||
"example": "UTC"
|
||||
} // Year tokens
|
||||
,
|
||||
{
|
||||
"category": "Year",
|
||||
"token": "yy",
|
||||
"description": I18n.tr("widgets.datetime-tokens.year-two-digit"),
|
||||
"example": "23"
|
||||
},
|
||||
{
|
||||
"category": "Year",
|
||||
"token": "yyyy",
|
||||
"description": I18n.tr("widgets.datetime-tokens.year-four-digit"),
|
||||
"example": "2023"
|
||||
} // Month tokens
|
||||
,
|
||||
{
|
||||
"category": "Month",
|
||||
"token": "M",
|
||||
"description": I18n.tr("widgets.datetime-tokens.month-number-no-zero"),
|
||||
"example": "12"
|
||||
},
|
||||
{
|
||||
"category": "Month",
|
||||
"token": "MM",
|
||||
"description": I18n.tr("widgets.datetime-tokens.month-number-leading-zero"),
|
||||
"example": "12"
|
||||
},
|
||||
{
|
||||
"category": "Month",
|
||||
"token": "MMM",
|
||||
"description": I18n.tr("widgets.datetime-tokens.month-abbreviated"),
|
||||
"example": "Dec"
|
||||
},
|
||||
{
|
||||
"category": "Month",
|
||||
"token": "MMMM",
|
||||
"description": I18n.tr("widgets.datetime-tokens.month-full"),
|
||||
"example": "December"
|
||||
} // Day tokens
|
||||
,
|
||||
{
|
||||
"category": "Day",
|
||||
"token": "d",
|
||||
"description": I18n.tr("widgets.datetime-tokens.day-no-leading-zero"),
|
||||
"example": "25"
|
||||
},
|
||||
{
|
||||
"category": "Day",
|
||||
"token": "dd",
|
||||
"description": I18n.tr("widgets.datetime-tokens.day-leading-zero"),
|
||||
"example": "25"
|
||||
},
|
||||
{
|
||||
"category": "Day",
|
||||
"token": "ddd",
|
||||
"description": I18n.tr("widgets.datetime-tokens.day-abbreviated"),
|
||||
"example": "Mon"
|
||||
},
|
||||
{
|
||||
"category": "Day",
|
||||
"token": "dddd",
|
||||
"description": I18n.tr("widgets.datetime-tokens.day-full"),
|
||||
"example": "Monday"
|
||||
}
|
||||
]
|
||||
|
||||
delegate: Rectangle {
|
||||
id: tokenDelegate
|
||||
width: tokensColumn.width
|
||||
height: layout.implicitHeight + Style.marginS
|
||||
radius: Style.iRadiusS
|
||||
color: {
|
||||
if (tokenMouseArea.containsMouse) {
|
||||
return Qt.alpha(Color.mPrimary, 0.1);
|
||||
}
|
||||
return index % 2 === 0 ? Color.mSurfaceVariant : Qt.alpha(Color.mSurfaceVariant, 0.6);
|
||||
}
|
||||
|
||||
// Mouse area for the entire delegate
|
||||
MouseArea {
|
||||
id: tokenMouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
|
||||
onClicked: {
|
||||
root.tokenClicked(modelData.token);
|
||||
clickAnimation.start();
|
||||
}
|
||||
}
|
||||
|
||||
// Click animation
|
||||
SequentialAnimation {
|
||||
id: clickAnimation
|
||||
PropertyAnimation {
|
||||
target: tokenDelegate
|
||||
property: "color"
|
||||
to: Qt.alpha(Color.mPrimary, 0.3)
|
||||
duration: 100
|
||||
}
|
||||
PropertyAnimation {
|
||||
target: tokenDelegate
|
||||
property: "color"
|
||||
to: tokenMouseArea.containsMouse ? Qt.alpha(Color.mPrimary, 0.1) : (index % 2 === 0 ? Color.mSurface : Color.mSurfaceVariant)
|
||||
duration: 200
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: layout
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginXS
|
||||
spacing: Style.marginM
|
||||
|
||||
// Category badge
|
||||
Rectangle {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
width: 70
|
||||
height: 22
|
||||
color: getCategoryColor(modelData.category)[0]
|
||||
radius: Style.iRadiusS
|
||||
opacity: tokenMouseArea.containsMouse ? 0.9 : 1.0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
anchors.centerIn: parent
|
||||
text: modelData.category
|
||||
color: getCategoryColor(modelData.category)[1]
|
||||
pointSize: Style.fontSizeXS
|
||||
}
|
||||
}
|
||||
|
||||
// Token - Made more prominent and clickable
|
||||
Rectangle {
|
||||
id: tokenButton
|
||||
Layout.alignment: Qt.AlignVCenter // Added this line
|
||||
width: 100
|
||||
height: 22
|
||||
color: tokenMouseArea.containsMouse ? Color.mPrimary : Color.mOnSurface
|
||||
radius: Style.iRadiusS
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
anchors.centerIn: parent
|
||||
text: modelData.token
|
||||
color: tokenMouseArea.containsMouse ? Color.mOnPrimary : Color.mSurface
|
||||
pointSize: Style.fontSizeS
|
||||
font.weight: Style.fontWeightBold
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Description
|
||||
NText {
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter // Added this line
|
||||
text: modelData.description
|
||||
color: tokenMouseArea.containsMouse ? Color.mOnSurface : Color.mOnSurfaceVariant
|
||||
pointSize: Style.fontSizeS
|
||||
wrapMode: Text.WordWrap
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Live example
|
||||
Rectangle {
|
||||
Layout.alignment: Qt.AlignVCenter // Added this line
|
||||
width: 90
|
||||
height: 22
|
||||
color: tokenMouseArea.containsMouse ? Color.mPrimary : Color.mOnSurfaceVariant
|
||||
radius: Style.iRadiusS
|
||||
border.color: tokenMouseArea.containsMouse ? Color.mPrimary : Color.mOutline
|
||||
border.width: Style.borderS
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
anchors.centerIn: parent
|
||||
text: I18n.locale.toString(root.sampleDate, modelData.token)
|
||||
color: tokenMouseArea.containsMouse ? Color.mOnPrimary : Color.mSurfaceVariant
|
||||
pointSize: Style.fontSizeS
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCategoryColor(category) {
|
||||
switch (category) {
|
||||
case "Year":
|
||||
return [Color.mPrimary, Color.mOnPrimary];
|
||||
case "Month":
|
||||
return [Color.mSecondary, Color.mOnSecondary];
|
||||
case "Day":
|
||||
return [Color.mTertiary, Color.mOnTertiary];
|
||||
case "Hour":
|
||||
return [Color.mPrimary, Color.mOnPrimary];
|
||||
case "Minute":
|
||||
return [Color.mSecondary, Color.mOnSecondary];
|
||||
case "Second":
|
||||
return [Color.mTertiary, Color.mOnTertiary];
|
||||
case "AM/PM":
|
||||
return [Color.mError, Color.mOnError];
|
||||
case "Timezone":
|
||||
return [Color.mOnSurface, Color.mSurface];
|
||||
case "Common":
|
||||
return [Color.mError, Color.mOnError];
|
||||
default:
|
||||
return [Color.mOnSurfaceVariant, Color.mSurfaceVariant];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import qs.Commons
|
||||
|
||||
Rectangle {
|
||||
property bool vertical: false
|
||||
|
||||
width: vertical ? Style.borderS : parent.width
|
||||
height: vertical ? parent.height : Style.borderS
|
||||
gradient: Gradient {
|
||||
orientation: vertical ? Gradient.Vertical : Gradient.Horizontal
|
||||
GradientStop {
|
||||
position: 0.0
|
||||
color: "transparent"
|
||||
}
|
||||
GradientStop {
|
||||
position: 0.1
|
||||
color: Color.mOutline
|
||||
}
|
||||
GradientStop {
|
||||
position: 0.9
|
||||
color: Color.mOutline
|
||||
}
|
||||
GradientStop {
|
||||
position: 1.0
|
||||
color: "transparent"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import qs.Commons
|
||||
import qs.Services.Power
|
||||
|
||||
// Unified shadow system
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var source
|
||||
|
||||
property bool autoPaddingEnabled: false
|
||||
property real shadowHorizontalOffset: Settings.data.general.shadowOffsetX
|
||||
property real shadowVerticalOffset: Settings.data.general.shadowOffsetY
|
||||
property real shadowOpacity: Style.shadowOpacity
|
||||
property color shadowColor: "black"
|
||||
property real shadowBlur: Style.shadowBlur
|
||||
|
||||
layer.enabled: Settings.data.general.enableShadows && !PowerProfileService.noctaliaPerformanceMode
|
||||
layer.effect: MultiEffect {
|
||||
source: root.source
|
||||
shadowEnabled: true
|
||||
blurMax: Style.shadowBlurMax
|
||||
shadowBlur: root.shadowBlur
|
||||
shadowOpacity: root.shadowOpacity
|
||||
shadowColor: root.shadowColor
|
||||
shadowHorizontalOffset: root.shadowHorizontalOffset
|
||||
shadowVerticalOffset: root.shadowVerticalOffset
|
||||
autoPaddingEnabled: root.autoPaddingEnabled
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,817 @@
|
||||
import Qt.labs.folderlistmodel
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
Popup {
|
||||
id: root
|
||||
|
||||
// Properties
|
||||
property string title: I18n.tr("widget.file-picker.title")
|
||||
property string initialPath: Quickshell.env("HOME") || "/home"
|
||||
property string selectionMode: "files" // "files" or "folders"
|
||||
property var nameFilters: ["*"]
|
||||
property bool showDirs: true
|
||||
property bool showHiddenFiles: false
|
||||
property bool allowMultiSelection: false
|
||||
|
||||
property var selectedPaths: []
|
||||
property string currentPath: initialPath
|
||||
property bool shouldResetSelection: false
|
||||
|
||||
// Signals
|
||||
signal accepted(var paths)
|
||||
signal cancelled
|
||||
|
||||
function openFilePicker() {
|
||||
if (!root.currentPath)
|
||||
root.currentPath = root.initialPath;
|
||||
shouldResetSelection = true;
|
||||
open();
|
||||
}
|
||||
|
||||
function getFileIcon(fileName) {
|
||||
const ext = fileName.split('.').pop().toLowerCase();
|
||||
const iconMap = {
|
||||
"txt": 'filepicker-file-text',
|
||||
"md": 'filepicker-file-text',
|
||||
"log": 'filepicker-file-text',
|
||||
"jpg": 'filepicker-photo',
|
||||
"jpeg": 'filepicker-photo',
|
||||
"png": 'filepicker-photo',
|
||||
"gif": 'filepicker-photo',
|
||||
"bmp": 'filepicker-photo',
|
||||
"svg": 'filepicker-photo',
|
||||
"mp4": 'filepicker-video',
|
||||
"avi": 'filepicker-video',
|
||||
"mkv": 'filepicker-video',
|
||||
"mov": 'filepicker-video',
|
||||
"mp3": 'filepicker-music',
|
||||
"wav": 'filepicker-music',
|
||||
"flac": 'filepicker-music',
|
||||
"ogg": 'filepicker-music',
|
||||
"zip": 'filepicker-archive',
|
||||
"tar": 'filepicker-archive',
|
||||
"gz": 'filepicker-archive',
|
||||
"rar": 'filepicker-archive',
|
||||
"7z": 'filepicker-archive',
|
||||
"pdf": 'filepicker-text',
|
||||
"doc": 'filepicker-text',
|
||||
"docx": 'filepicker-text',
|
||||
"xls": 'filepicker-table',
|
||||
"xlsx": 'filepicker-table',
|
||||
"ppt": 'filepicker-presentation',
|
||||
"pptx": 'filepicker-presentation',
|
||||
"html": 'filepicker-code',
|
||||
"htm": 'filepicker-code',
|
||||
"css": 'filepicker-code',
|
||||
"js": 'filepicker-code',
|
||||
"json": 'filepicker-code',
|
||||
"xml": 'filepicker-code',
|
||||
"exe": 'filepicker-settings',
|
||||
"app": 'filepicker-settings',
|
||||
"deb": 'filepicker-settings',
|
||||
"rpm": 'filepicker-settings'
|
||||
};
|
||||
return iconMap[ext] || 'filepicker-file';
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0)
|
||||
return "0 B";
|
||||
const k = 1024, sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
function confirmSelection() {
|
||||
if (filePickerPanel.currentSelection.length === 0)
|
||||
return;
|
||||
root.selectedPaths = filePickerPanel.currentSelection;
|
||||
root.accepted(filePickerPanel.currentSelection);
|
||||
root.close();
|
||||
}
|
||||
|
||||
function updateFilteredModel() {
|
||||
filteredModel.clear();
|
||||
const searchText = filePickerPanel.filterText.toLowerCase();
|
||||
|
||||
for (var i = 0; i < folderModel.count; i++) {
|
||||
const fileName = folderModel.get(i, "fileName");
|
||||
const filePath = folderModel.get(i, "filePath");
|
||||
const fileIsDir = folderModel.get(i, "fileIsDir");
|
||||
const fileSize = folderModel.get(i, "fileSize");
|
||||
|
||||
// Skip hidden items if showHiddenFiles is false
|
||||
// This additional check ensures hidden files are properly filtered
|
||||
if (!root.showHiddenFiles && fileName.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// In folder mode, hide files
|
||||
if (root.selectionMode === "folders" && !fileIsDir)
|
||||
continue;
|
||||
if (searchText === "" || fileName.toLowerCase().includes(searchText)) {
|
||||
filteredModel.append({
|
||||
"fileName": fileName,
|
||||
"filePath": filePath,
|
||||
"fileIsDir": fileIsDir,
|
||||
"fileSize": fileSize
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
width: 900
|
||||
height: 700
|
||||
modal: true
|
||||
closePolicy: Popup.CloseOnEscape
|
||||
anchors.centerIn: Overlay.overlay
|
||||
|
||||
background: Rectangle {
|
||||
color: Color.mSurfaceVariant
|
||||
radius: Style.iRadiusL
|
||||
border.color: Color.mOutline
|
||||
border.width: Style.borderS
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: filePickerPanel
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginL
|
||||
color: "transparent"
|
||||
|
||||
property string filterText: ""
|
||||
property var currentSelection: []
|
||||
property bool viewMode: true // true = grid, false = list
|
||||
property string searchText: ""
|
||||
property bool showSearchBar: false
|
||||
|
||||
focus: true
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (event.modifiers & Qt.ControlModifier && event.key === Qt.Key_F) {
|
||||
filePickerPanel.showSearchBar = !filePickerPanel.showSearchBar;
|
||||
if (filePickerPanel.showSearchBar)
|
||||
Qt.callLater(() => searchInput.forceActiveFocus());
|
||||
event.accepted = true;
|
||||
} else if (event.key === Qt.Key_Escape && filePickerPanel.showSearchBar) {
|
||||
filePickerPanel.showSearchBar = false;
|
||||
filePickerPanel.searchText = "";
|
||||
filePickerPanel.filterText = "";
|
||||
root.updateFilteredModel();
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Function when an item is clicked, either a folder or a file since both have the same functionality, reduces repetitive code
|
||||
function itemClicked(modifiers, path) {
|
||||
var list = currentSelection.slice();
|
||||
const index = list.indexOf(path);
|
||||
if (root.allowMultiSelection && (modifiers & Qt.ShiftModifier) && list.length > 0) {
|
||||
if (index > -1) {
|
||||
list.splice(index, 1);
|
||||
currentSelection = list;
|
||||
} else {
|
||||
var i = 0;
|
||||
var toggle = false;
|
||||
const firstItemPath = list[0];
|
||||
while (i < filteredModel.count) {
|
||||
const itemPath = filteredModel.get(i).filePath;
|
||||
|
||||
// This should be called twice, when we get to the item selected and when we get to the starting item.
|
||||
if (itemPath === firstItemPath || itemPath === path) {
|
||||
toggle = !toggle;
|
||||
}
|
||||
|
||||
// Add all files between the starting item and the item selected.
|
||||
if (toggle) {
|
||||
if (!list.includes(itemPath)) {
|
||||
list.push(itemPath);
|
||||
}
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
// Add the path selected as well since it's skipped in the while loop
|
||||
if (!list.includes(path)) {
|
||||
list.push(path);
|
||||
}
|
||||
currentSelection = list;
|
||||
}
|
||||
} else if (root.allowMultiSelection && (modifiers & Qt.ControlModifier)) {
|
||||
if (index > -1) {
|
||||
list.splice(index, 1);
|
||||
currentSelection = list;
|
||||
} else {
|
||||
list.push(path);
|
||||
currentSelection = list;
|
||||
}
|
||||
} else {
|
||||
currentSelection = [path];
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: Style.marginM
|
||||
|
||||
// Header
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginM
|
||||
|
||||
NIcon {
|
||||
icon: "filepicker-folder"
|
||||
color: Color.mPrimary
|
||||
pointSize: Style.fontSizeXXL
|
||||
}
|
||||
NText {
|
||||
text: root.title
|
||||
pointSize: Style.fontSizeXL
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Color.mPrimary
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
// "Select Current" button only visible in folder selection mode
|
||||
NButton {
|
||||
text: I18n.tr("widgets.file-picker.select-current")
|
||||
icon: "filepicker-folder-current"
|
||||
visible: root.selectionMode === "folders"
|
||||
onClicked: {
|
||||
filePickerPanel.currentSelection = [root.currentPath];
|
||||
root.confirmSelection();
|
||||
}
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "filepicker-refresh"
|
||||
tooltipText: I18n.tr("common.refresh")
|
||||
onClicked: {
|
||||
// Force a proper refresh by resetting the folder
|
||||
const currentFolder = folderModel.folder;
|
||||
folderModel.folder = "";
|
||||
folderModel.folder = currentFolder;
|
||||
Qt.callLater(root.updateFilteredModel);
|
||||
}
|
||||
}
|
||||
NIconButton {
|
||||
icon: "filepicker-close"
|
||||
tooltipText: I18n.tr("common.close")
|
||||
onClicked: {
|
||||
root.cancelled();
|
||||
root.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NDivider {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
// Navigation toolbar
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 45
|
||||
color: Color.mSurfaceVariant
|
||||
radius: Style.iRadiusS
|
||||
border.color: Color.mOutline
|
||||
border.width: Style.borderS
|
||||
|
||||
RowLayout {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: Style.marginS
|
||||
anchors.rightMargin: Style.marginS
|
||||
spacing: Style.marginS
|
||||
|
||||
NIconButton {
|
||||
icon: "filepicker-arrow-up"
|
||||
tooltipText: I18n.tr("tooltips.up")
|
||||
baseSize: Style.baseWidgetSize * 0.8
|
||||
enabled: folderModel.folder.toString() !== "file:///"
|
||||
onClicked: {
|
||||
const parentPath = folderModel.parentFolder.toString().replace("file://", "");
|
||||
folderModel.folder = "file://" + parentPath;
|
||||
root.currentPath = parentPath;
|
||||
}
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "filepicker-home"
|
||||
tooltipText: I18n.tr("tooltips.home")
|
||||
baseSize: Style.baseWidgetSize * 0.8
|
||||
onClicked: {
|
||||
const homePath = Quickshell.env("HOME") || "/home";
|
||||
folderModel.folder = "file://" + homePath;
|
||||
root.currentPath = homePath;
|
||||
}
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: filePickerPanel.showSearchBar ? "filepicker-x" : "filepicker-search"
|
||||
tooltipText: filePickerPanel.showSearchBar ? I18n.tr("tooltips.search-close") : I18n.tr("common.search")
|
||||
baseSize: Style.baseWidgetSize * 0.8
|
||||
onClicked: {
|
||||
filePickerPanel.showSearchBar = !filePickerPanel.showSearchBar;
|
||||
if (!filePickerPanel.showSearchBar) {
|
||||
filePickerPanel.searchText = "";
|
||||
filePickerPanel.filterText = "";
|
||||
root.updateFilteredModel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NTextInput {
|
||||
id: locationInput
|
||||
text: root.currentPath
|
||||
placeholderText: I18n.tr("placeholders.enter-path")
|
||||
Layout.fillWidth: true
|
||||
|
||||
visible: !filePickerPanel.showSearchBar
|
||||
enabled: !filePickerPanel.showSearchBar
|
||||
|
||||
onEditingFinished: {
|
||||
const newPath = text.trim();
|
||||
if (newPath !== "" && newPath !== root.currentPath) {
|
||||
folderModel.folder = "file://" + newPath;
|
||||
root.currentPath = newPath;
|
||||
} else {
|
||||
text = root.currentPath;
|
||||
}
|
||||
}
|
||||
Connections {
|
||||
target: root
|
||||
function onCurrentPathChanged() {
|
||||
if (!locationInput.activeFocus)
|
||||
locationInput.text = root.currentPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search bar
|
||||
NTextInput {
|
||||
id: searchInput
|
||||
inputIconName: "search"
|
||||
placeholderText: I18n.tr("placeholders.search")
|
||||
Layout.fillWidth: true
|
||||
|
||||
visible: filePickerPanel.showSearchBar
|
||||
enabled: filePickerPanel.showSearchBar
|
||||
|
||||
text: filePickerPanel.searchText
|
||||
onTextChanged: {
|
||||
filePickerPanel.searchText = text;
|
||||
filePickerPanel.filterText = text;
|
||||
root.updateFilteredModel();
|
||||
}
|
||||
Keys.onEscapePressed: {
|
||||
filePickerPanel.showSearchBar = false;
|
||||
filePickerPanel.searchText = "";
|
||||
filePickerPanel.filterText = "";
|
||||
root.updateFilteredModel();
|
||||
}
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: filePickerPanel.viewMode ? "filepicker-list" : "filepicker-layout-grid"
|
||||
tooltipText: filePickerPanel.viewMode ? I18n.tr("tooltips.list-view") : I18n.tr("tooltips.grid-view")
|
||||
baseSize: Style.baseWidgetSize * 0.8
|
||||
onClicked: filePickerPanel.viewMode = !filePickerPanel.viewMode
|
||||
}
|
||||
NIconButton {
|
||||
icon: root.showHiddenFiles ? "filepicker-eye-off" : "filepicker-eye"
|
||||
tooltipText: root.showHiddenFiles ? I18n.tr("tooltips.hidden-files-hide") : I18n.tr("tooltips.hidden-files-hide")
|
||||
baseSize: Style.baseWidgetSize * 0.8
|
||||
onClicked: {
|
||||
root.showHiddenFiles = !root.showHiddenFiles;
|
||||
// Force model refresh by resetting the folder
|
||||
const currentFolder = folderModel.folder;
|
||||
folderModel.folder = "";
|
||||
folderModel.folder = currentFolder;
|
||||
Qt.callLater(root.updateFilteredModel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// File list area
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Color.mSurface
|
||||
radius: Style.iRadiusM
|
||||
border.color: Color.mOutline
|
||||
border.width: Style.borderS
|
||||
|
||||
FolderListModel {
|
||||
id: folderModel
|
||||
folder: "file://" + root.currentPath
|
||||
// Use wildcard filters including hidden files when showHiddenFiles is true
|
||||
nameFilters: root.showHiddenFiles ? ["*", ".*"] : root.nameFilters
|
||||
showDirs: root.showDirs
|
||||
showHidden: true // Always true, we'll filter in updateFilteredModel
|
||||
showDotAndDotDot: false
|
||||
sortField: FolderListModel.Name
|
||||
sortReversed: false
|
||||
|
||||
onFolderChanged: {
|
||||
root.currentPath = folder.toString().replace("file://", "");
|
||||
filePickerPanel.currentSelection = [];
|
||||
Qt.callLater(root.updateFilteredModel);
|
||||
}
|
||||
|
||||
onStatusChanged: {
|
||||
if (status === FolderListModel.Error) {
|
||||
if (root.currentPath !== Quickshell.env("HOME")) {
|
||||
folder = "file://" + Quickshell.env("HOME");
|
||||
root.currentPath = Quickshell.env("HOME");
|
||||
}
|
||||
} else if (status === FolderListModel.Ready) {
|
||||
root.updateFilteredModel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update nameFilters when showHiddenFiles changes
|
||||
Connections {
|
||||
target: root
|
||||
function onShowHiddenFilesChanged() {
|
||||
folderModel.nameFilters = root.showHiddenFiles ? ["*", ".*"] : root.nameFilters;
|
||||
}
|
||||
}
|
||||
|
||||
ListModel {
|
||||
id: filteredModel
|
||||
}
|
||||
|
||||
// Grid view
|
||||
NGridView {
|
||||
id: gridView
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginM
|
||||
model: filteredModel
|
||||
visible: filePickerPanel.viewMode
|
||||
reuseItems: true
|
||||
gradientColor: Color.mSurface
|
||||
|
||||
property int columns: Math.max(1, Math.floor(availableWidth / 120))
|
||||
property int itemSize: Math.floor((availableWidth - leftMargin - rightMargin - (columns * Style.marginS)) / columns)
|
||||
|
||||
cellWidth: Math.floor((availableWidth - leftMargin - rightMargin) / columns)
|
||||
cellHeight: Math.floor(itemSize * 0.8) + Style.marginXS + Style.fontSizeS + Style.marginM
|
||||
|
||||
leftMargin: Style.marginS
|
||||
rightMargin: Style.marginS
|
||||
topMargin: Style.marginS
|
||||
bottomMargin: Style.marginS
|
||||
|
||||
delegate: Rectangle {
|
||||
id: gridItem
|
||||
width: gridView.itemSize
|
||||
height: gridView.cellHeight
|
||||
color: "transparent"
|
||||
radius: Style.iRadiusM
|
||||
|
||||
property bool isSelected: filePickerPanel.currentSelection.includes(model.filePath)
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "transparent"
|
||||
radius: parent.radius
|
||||
border.color: isSelected ? Color.mSecondary : Color.mSurface
|
||||
border.width: Style.borderL
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: (mouseArea.containsMouse && !isSelected) ? Color.mHover : "transparent"
|
||||
radius: parent.radius
|
||||
border.color: (mouseArea.containsMouse && !isSelected) ? Color.mHover : "transparent"
|
||||
border.width: Style.borderS
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
Behavior on border.color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginS
|
||||
spacing: Style.marginXS
|
||||
|
||||
Rectangle {
|
||||
id: iconContainer
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Math.round(gridView.itemSize * 0.67)
|
||||
color: "transparent"
|
||||
|
||||
property bool isImage: {
|
||||
if (model.fileIsDir)
|
||||
return false;
|
||||
const ext = model.fileName.split('.').pop().toLowerCase();
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'].includes(ext);
|
||||
}
|
||||
|
||||
Image {
|
||||
id: thumbnail
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginXS
|
||||
source: iconContainer.isImage ? "file://" + model.filePath : ""
|
||||
fillMode: Image.PreserveAspectFit
|
||||
visible: iconContainer.isImage && status === Image.Ready
|
||||
smooth: false
|
||||
cache: true
|
||||
asynchronous: true
|
||||
sourceSize.width: 120
|
||||
sourceSize.height: 120
|
||||
onStatusChanged: {
|
||||
if (status === Image.Error)
|
||||
visible = false;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Color.mSurfaceVariant
|
||||
radius: Style.iRadiusS
|
||||
visible: thumbnail.status === Image.Loading
|
||||
NIcon {
|
||||
icon: "filepicker-photo"
|
||||
pointSize: Style.fontSizeL
|
||||
color: Color.mOnSurfaceVariant
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NIcon {
|
||||
icon: model.fileIsDir ? "filepicker-folder" : root.getFileIcon(model.fileName)
|
||||
pointSize: Style.fontSizeXXL * 2
|
||||
color: {
|
||||
if (isSelected)
|
||||
return Color.mSecondary;
|
||||
else if (mouseArea.containsMouse)
|
||||
return Color.mOnHover;
|
||||
else
|
||||
return model.fileIsDir ? Color.mPrimary : Color.mOnSurfaceVariant;
|
||||
}
|
||||
anchors.centerIn: parent
|
||||
visible: !iconContainer.isImage || thumbnail.status !== Image.Ready
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.margins: Style.marginS
|
||||
width: 24
|
||||
height: 24
|
||||
radius: Math.min(Style.iRadiusL, width / 2)
|
||||
color: Color.mSecondary
|
||||
border.color: Color.mOutline
|
||||
border.width: Style.borderS
|
||||
visible: isSelected
|
||||
NIcon {
|
||||
icon: "filepicker-check"
|
||||
pointSize: Style.fontSizeS
|
||||
color: Color.mOnSecondary
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
text: model.fileName
|
||||
color: {
|
||||
if (isSelected)
|
||||
return Color.mSecondary;
|
||||
else if (mouseArea.containsMouse)
|
||||
return Color.mOnHover;
|
||||
else
|
||||
return Color.mOnSurfaceVariant;
|
||||
}
|
||||
pointSize: Style.fontSizeS
|
||||
font.weight: isSelected ? Style.fontWeightBold : Style.fontWeightRegular
|
||||
Layout.fillWidth: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WrapAnywhere
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 2
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
|
||||
onClicked: mouse => {
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
if (model.fileIsDir) {
|
||||
// In folder mode, single click selects the folder
|
||||
if (root.selectionMode === "folders") {
|
||||
filePickerPanel.itemClicked(mouse.modifiers, model.filePath);
|
||||
}
|
||||
// In file mode, single click on folder does nothing (must double-click to enter)
|
||||
} else {
|
||||
// Single click on file selects it (only in file mode)
|
||||
if (root.selectionMode === "files") {
|
||||
filePickerPanel.itemClicked(mouse.modifiers, model.filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDoubleClicked: mouse => {
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
if (model.fileIsDir) {
|
||||
// Double-click on folder always navigates into it
|
||||
folderModel.folder = "file://" + model.filePath;
|
||||
root.currentPath = model.filePath;
|
||||
} else {
|
||||
// Double-click on file selects and confirms (only in file mode)
|
||||
if (root.selectionMode === "files") {
|
||||
filePickerPanel.currentSelection = [model.filePath];
|
||||
root.confirmSelection();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// List view
|
||||
NListView {
|
||||
id: listView
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginS
|
||||
model: filteredModel
|
||||
visible: !filePickerPanel.viewMode
|
||||
gradientColor: Color.mSurface
|
||||
|
||||
delegate: Rectangle {
|
||||
id: listItem
|
||||
width: listView.width
|
||||
height: 40
|
||||
color: {
|
||||
if (filePickerPanel.currentSelection.includes(model.filePath))
|
||||
return Color.mSecondary;
|
||||
if (mouseArea.containsMouse)
|
||||
return Color.mHover;
|
||||
return "transparent";
|
||||
}
|
||||
radius: Style.iRadiusS
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Style.marginM
|
||||
anchors.rightMargin: Style.marginM
|
||||
spacing: Style.marginM
|
||||
|
||||
NIcon {
|
||||
icon: model.fileIsDir ? "filepicker-folder" : root.getFileIcon(model.fileName)
|
||||
pointSize: Style.fontSizeL
|
||||
color: model.fileIsDir ? (filePickerPanel.currentSelection.includes(model.filePath) ? Color.mOnSecondary : Color.mPrimary) : Color.mOnSurfaceVariant
|
||||
}
|
||||
|
||||
NText {
|
||||
text: model.fileName
|
||||
color: filePickerPanel.currentSelection.includes(model.filePath) ? Color.mOnSecondary : Color.mOnSurface
|
||||
pointSize: Style.fontSizeM
|
||||
font.weight: filePickerPanel.currentSelection.includes(model.filePath) ? Style.fontWeightBold : Style.fontWeightRegular
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
NText {
|
||||
text: model.fileIsDir ? "" : root.formatFileSize(model.fileSize)
|
||||
color: filePickerPanel.currentSelection.includes(model.filePath) ? Color.mOnSecondary : Color.mOnSurfaceVariant
|
||||
pointSize: Style.fontSizeS
|
||||
visible: !model.fileIsDir
|
||||
Layout.preferredWidth: implicitWidth
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
|
||||
onClicked: mouse => {
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
if (model.fileIsDir) {
|
||||
// In folder mode, single click selects the folder
|
||||
if (root.selectionMode === "folders") {
|
||||
filePickerPanel.itemClicked(mouse.modifiers, model.filePath);
|
||||
}
|
||||
// In file mode, single click on folder does nothing (must double-click to enter)
|
||||
} else {
|
||||
// Single click on file selects it (only in file mode)
|
||||
if (root.selectionMode === "files") {
|
||||
filePickerPanel.itemClicked(mouse.modifiers, model.filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDoubleClicked: mouse => {
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
if (model.fileIsDir) {
|
||||
// Double-click on folder always navigates into it
|
||||
folderModel.folder = "file://" + model.filePath;
|
||||
root.currentPath = model.filePath;
|
||||
} else {
|
||||
// Double-click on file selects and confirms (only in file mode)
|
||||
if (root.selectionMode === "files") {
|
||||
filePickerPanel.currentSelection = [model.filePath];
|
||||
root.confirmSelection();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginM
|
||||
|
||||
NText {
|
||||
text: {
|
||||
if (filePickerPanel.searchText.length > 0) {
|
||||
return "Searching for: \"" + filePickerPanel.searchText + "\" (" + filteredModel.count + " matches)";
|
||||
} else if (filePickerPanel.currentSelection.length > 0) {
|
||||
const selectedName = filePickerPanel.currentSelection[0].split('/').pop();
|
||||
return I18n.tr("widgets.file-picker.selected") + " " + selectedName;
|
||||
} else {
|
||||
return filteredModel.count + " " + (filteredModel.count === 1 ? I18n.tr("widgets.file-picker.item") : I18n.tr("widgets.file-picker.items"));
|
||||
}
|
||||
}
|
||||
color: filePickerPanel.searchText.length > 0 ? Color.mPrimary : Color.mOnSurfaceVariant
|
||||
pointSize: Style.fontSizeS
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NButton {
|
||||
text: I18n.tr("common.cancel")
|
||||
outlined: true
|
||||
onClicked: {
|
||||
root.cancelled();
|
||||
root.close();
|
||||
}
|
||||
}
|
||||
|
||||
NButton {
|
||||
text: root.selectionMode === "folders" ? I18n.tr("widgets.file-picker.select-folder") : I18n.tr("widgets.file-picker.select-file")
|
||||
icon: "filepicker-check"
|
||||
enabled: filePickerPanel.currentSelection.length > 0
|
||||
onClicked: root.confirmSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onShouldResetSelectionChanged() {
|
||||
if (root.shouldResetSelection) {
|
||||
filePickerPanel.currentSelection = [];
|
||||
root.shouldResetSelection = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (!root.currentPath)
|
||||
root.currentPath = root.initialPath;
|
||||
folderModel.folder = "file://" + root.currentPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
|
||||
Item {
|
||||
id: root
|
||||
clip: true
|
||||
|
||||
// Primary line
|
||||
property var values: []
|
||||
property color color: Color.mPrimary
|
||||
|
||||
// Optional secondary line
|
||||
property var values2: []
|
||||
property color color2: Color.mError
|
||||
|
||||
// Range settings for primary line
|
||||
property real minValue: 0
|
||||
property real maxValue: 100
|
||||
|
||||
// Range settings for secondary line (defaults to primary range)
|
||||
property real minValue2: minValue
|
||||
property real maxValue2: maxValue
|
||||
|
||||
// Style settings
|
||||
property real strokeWidth: 1
|
||||
property bool fill: true
|
||||
property real fillOpacity: 0.15
|
||||
property real antialiasing: 0.5
|
||||
|
||||
// Smooth scrolling interval (how often data updates)
|
||||
property int updateInterval: 1000
|
||||
|
||||
// Animate scale changes (for network graphs with dynamic max)
|
||||
property bool animateScale: false
|
||||
|
||||
// Vertical padding (percentage of range) to keep values from touching edges
|
||||
readonly property real curvePadding: 0.12
|
||||
|
||||
readonly property bool hasData: values.length >= 4
|
||||
readonly property bool hasData2: values2.length >= 4
|
||||
|
||||
// Scale animation state
|
||||
property real _targetMax1: maxValue
|
||||
property real _targetMax2: maxValue2
|
||||
property real _animMax1: maxValue
|
||||
property real _animMax2: maxValue2
|
||||
|
||||
onMaxValueChanged: {
|
||||
_targetMax1 = maxValue;
|
||||
if (animateScale && _ready1) {
|
||||
_scaleTimer.start();
|
||||
} else {
|
||||
_animMax1 = maxValue;
|
||||
}
|
||||
}
|
||||
|
||||
onMaxValue2Changed: {
|
||||
_targetMax2 = maxValue2;
|
||||
if (animateScale && _ready2) {
|
||||
_scaleTimer.start();
|
||||
} else {
|
||||
_animMax2 = maxValue2;
|
||||
}
|
||||
}
|
||||
|
||||
// Effective max values (animated or direct)
|
||||
readonly property real _effectiveMax1: animateScale ? _animMax1 : maxValue
|
||||
readonly property real _effectiveMax2: animateScale ? _animMax2 : maxValue2
|
||||
|
||||
// Scroll state (driven by NumberAnimation)
|
||||
property real _t1: 1.0
|
||||
property bool _ready1: false
|
||||
property real _pred1: 0
|
||||
|
||||
property real _t2: 1.0
|
||||
property bool _ready2: false
|
||||
property real _pred2: 0
|
||||
|
||||
// Frame-accurate scroll animations tied to Qt's render loop
|
||||
NumberAnimation {
|
||||
id: _scrollAnim1
|
||||
target: root
|
||||
property: "_t1"
|
||||
from: 0
|
||||
to: 1
|
||||
duration: root.updateInterval
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
id: _scrollAnim2
|
||||
target: root
|
||||
property: "_t2"
|
||||
from: 0
|
||||
to: 1
|
||||
duration: root.updateInterval
|
||||
}
|
||||
|
||||
onValuesChanged: {
|
||||
if (values.length < 4)
|
||||
return;
|
||||
|
||||
const last = values[values.length - 1];
|
||||
const prev = values[values.length - 2];
|
||||
_pred1 = Math.max(minValue, last + (last - prev) * 0.5);
|
||||
|
||||
if (!_ready1)
|
||||
_ready1 = true;
|
||||
_scrollAnim1.restart();
|
||||
}
|
||||
|
||||
onValues2Changed: {
|
||||
if (values2.length < 4)
|
||||
return;
|
||||
|
||||
const last = values2[values2.length - 1];
|
||||
const prev = values2[values2.length - 2];
|
||||
_pred2 = Math.max(minValue2, last + (last - prev) * 0.5);
|
||||
|
||||
if (!_ready2)
|
||||
_ready2 = true;
|
||||
_scrollAnim2.restart();
|
||||
}
|
||||
|
||||
// Scale animation timer (only needed for animateScale mode)
|
||||
Timer {
|
||||
id: _scaleTimer
|
||||
interval: 16
|
||||
repeat: true
|
||||
|
||||
onTriggered: {
|
||||
const scaleLerp = 0.15;
|
||||
const threshold = 0.5;
|
||||
let stillAnimating = false;
|
||||
|
||||
if (Math.abs(root._animMax1 - root._targetMax1) > threshold) {
|
||||
root._animMax1 += (root._targetMax1 - root._animMax1) * scaleLerp;
|
||||
stillAnimating = true;
|
||||
} else if (root._animMax1 !== root._targetMax1) {
|
||||
root._animMax1 = root._targetMax1;
|
||||
}
|
||||
|
||||
if (Math.abs(root._animMax2 - root._targetMax2) > threshold) {
|
||||
root._animMax2 += (root._targetMax2 - root._animMax2) * scaleLerp;
|
||||
stillAnimating = true;
|
||||
} else if (root._animMax2 !== root._targetMax2) {
|
||||
root._animMax2 = root._targetMax2;
|
||||
}
|
||||
|
||||
if (!stillAnimating)
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize a value to [0, 1] with padding applied
|
||||
function _normalize(val, minVal, maxVal) {
|
||||
let range = maxVal - minVal;
|
||||
if (range <= 0)
|
||||
return 0.5;
|
||||
let padding = range * curvePadding;
|
||||
let paddedMin = minVal - padding;
|
||||
let paddedRange = (maxVal + padding) - paddedMin;
|
||||
return Math.max(0, Math.min(1, (val - paddedMin) / paddedRange));
|
||||
}
|
||||
|
||||
// Data texture built from Rectangles instead of Canvas.
|
||||
// Each Rectangle is one data point, color-coded with normalized values.
|
||||
// R channel = primary, G channel = secondary.
|
||||
Item {
|
||||
id: _dataRow
|
||||
width: Math.max(root.values.length + 1, root.values2.length + 1, 4)
|
||||
height: 1
|
||||
|
||||
Repeater {
|
||||
model: _dataRow.width
|
||||
|
||||
Rectangle {
|
||||
required property int index
|
||||
x: index
|
||||
width: 1
|
||||
height: 1
|
||||
color: {
|
||||
let r = 0, g = 0;
|
||||
let n1 = root.values.length;
|
||||
let n2 = root.values2.length;
|
||||
let eMax1 = root._effectiveMax1;
|
||||
let eMax2 = root._effectiveMax2;
|
||||
|
||||
if (index < n1)
|
||||
r = root._normalize(root.values[index], root.minValue, eMax1);
|
||||
else if (n1 > 0)
|
||||
r = root._normalize(root._pred1, root.minValue, eMax1);
|
||||
|
||||
if (index < n2)
|
||||
g = root._normalize(root.values2[index], root.minValue2, eMax2);
|
||||
else if (n2 > 0)
|
||||
g = root._normalize(root._pred2, root.minValue2, eMax2);
|
||||
|
||||
return Qt.rgba(r, g, 0, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ShaderEffectSource {
|
||||
id: _dataTex
|
||||
sourceItem: _dataRow
|
||||
textureSize: Qt.size(_dataRow.width, 1)
|
||||
live: true
|
||||
smooth: false
|
||||
hideSource: true
|
||||
}
|
||||
|
||||
ShaderEffect {
|
||||
anchors.fill: parent
|
||||
visible: (root.hasData || root.hasData2) && width > 0 && height > 0
|
||||
|
||||
property variant dataSource: _dataTex
|
||||
property color lineColor1: root.color
|
||||
property color lineColor2: root.color2
|
||||
property real count1: root.values.length
|
||||
property real count2: root.values2.length
|
||||
property real scroll1: root._t1
|
||||
property real scroll2: root._t2
|
||||
property real lineWidth: root.strokeWidth
|
||||
property real graphFillOpacity: root.fill ? root.fillOpacity : 0.0
|
||||
property real texWidth: _dataRow.width
|
||||
property real resY: height
|
||||
property real aaSize: root.antialiasing
|
||||
|
||||
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/graph.frag.qsb")
|
||||
blending: true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Templates as T
|
||||
import qs.Commons
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// Signal for key press events when keyNavigationEnabled is true
|
||||
signal keyPressed(var event)
|
||||
|
||||
property color handleColor: Qt.alpha(Color.mHover, 0.8)
|
||||
property color handleHoverColor: handleColor
|
||||
property color handlePressedColor: handleColor
|
||||
property color trackColor: "transparent"
|
||||
property real handleWidth: 6
|
||||
property real handleRadius: Style.iRadiusM
|
||||
property int verticalPolicy: ScrollBar.AsNeeded
|
||||
property int horizontalPolicy: ScrollBar.AlwaysOff
|
||||
readonly property bool verticalScrollBarActive: {
|
||||
if (gridView.ScrollBar.vertical.policy === ScrollBar.AlwaysOff)
|
||||
return false;
|
||||
return gridView.contentHeight > gridView.height;
|
||||
}
|
||||
readonly property bool contentOverflows: gridView.contentHeight > gridView.height
|
||||
|
||||
// Gradient properties
|
||||
property bool showGradientMasks: true
|
||||
property color gradientColor: Color.mSurfaceVariant
|
||||
property int gradientHeight: 16
|
||||
property bool reserveScrollbarSpace: true
|
||||
|
||||
// Keep scrollbars visible whenever overflow exists (without forcing visibility when not scrollable)
|
||||
property bool showScrollbarWhenScrollable: Settings.data.ui.scrollbarAlwaysVisible
|
||||
|
||||
// Available width for content (excludes scrollbar space when reserveScrollbarSpace is true)
|
||||
// Note: Always reserves space when enabled to avoid binding loops with cellWidth calculations
|
||||
readonly property real availableWidth: width - (reserveScrollbarSpace ? handleWidth + Style.marginXS : 0)
|
||||
|
||||
// Expose activeFocus from internal gridView
|
||||
readonly property bool hasActiveFocus: gridView.activeFocus
|
||||
|
||||
// Forward GridView properties
|
||||
property alias model: gridView.model
|
||||
property alias delegate: gridView.delegate
|
||||
property alias cellWidth: gridView.cellWidth
|
||||
property alias cellHeight: gridView.cellHeight
|
||||
property alias leftMargin: gridView.leftMargin
|
||||
property alias rightMargin: gridView.rightMargin
|
||||
property alias topMargin: gridView.topMargin
|
||||
property alias bottomMargin: gridView.bottomMargin
|
||||
property alias currentIndex: gridView.currentIndex
|
||||
property alias count: gridView.count
|
||||
property alias contentHeight: gridView.contentHeight
|
||||
property alias contentWidth: gridView.contentWidth
|
||||
property alias contentY: gridView.contentY
|
||||
property alias contentX: gridView.contentX
|
||||
property alias currentItem: gridView.currentItem
|
||||
property alias highlightItem: gridView.highlightItem
|
||||
property alias highlightFollowsCurrentItem: gridView.highlightFollowsCurrentItem
|
||||
property alias preferredHighlightBegin: gridView.preferredHighlightBegin
|
||||
property alias preferredHighlightEnd: gridView.preferredHighlightEnd
|
||||
property alias highlightRangeMode: gridView.highlightRangeMode
|
||||
property alias snapMode: gridView.snapMode
|
||||
property alias keyNavigationEnabled: gridView.keyNavigationEnabled
|
||||
property alias keyNavigationWraps: gridView.keyNavigationWraps
|
||||
property alias cacheBuffer: gridView.cacheBuffer
|
||||
property alias displayMarginBeginning: gridView.displayMarginBeginning
|
||||
property alias displayMarginEnd: gridView.displayMarginEnd
|
||||
property alias layoutDirection: gridView.layoutDirection
|
||||
property alias effectiveLayoutDirection: gridView.effectiveLayoutDirection
|
||||
property alias flow: gridView.flow
|
||||
property alias boundsBehavior: gridView.boundsBehavior
|
||||
property alias flickableDirection: gridView.flickableDirection
|
||||
property alias interactive: gridView.interactive
|
||||
property alias moving: gridView.moving
|
||||
property alias flicking: gridView.flicking
|
||||
property alias dragging: gridView.dragging
|
||||
property alias horizontalVelocity: gridView.horizontalVelocity
|
||||
property alias verticalVelocity: gridView.verticalVelocity
|
||||
property alias reuseItems: gridView.reuseItems
|
||||
|
||||
// Animate items when the model is reordered (e.g. ListModel.move())
|
||||
property bool animateMovement: false
|
||||
|
||||
// Scroll speed multiplier for mouse wheel (1.0 = default, higher = faster)
|
||||
property real wheelScrollMultiplier: 2.0
|
||||
property int smoothWheelAnimationDuration: Style.animationNormal
|
||||
property real _wheelTargetY: 0
|
||||
|
||||
function clampScrollY(value) {
|
||||
return Math.max(0, Math.min(value, gridView.contentHeight - gridView.height));
|
||||
}
|
||||
|
||||
function applyWheelScroll(delta) {
|
||||
if (!root.contentOverflows)
|
||||
return;
|
||||
|
||||
const step = delta * root.wheelScrollMultiplier;
|
||||
|
||||
if (!Settings.data.general.smoothScrollEnabled || Settings.data.general.animationDisabled) {
|
||||
gridView.contentY = root.clampScrollY(gridView.contentY - step);
|
||||
root._wheelTargetY = gridView.contentY;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wheelScrollAnimation.running)
|
||||
root._wheelTargetY = gridView.contentY;
|
||||
|
||||
root._wheelTargetY = root.clampScrollY(root._wheelTargetY - step);
|
||||
wheelScrollAnimation.to = root._wheelTargetY;
|
||||
wheelScrollAnimation.restart();
|
||||
}
|
||||
|
||||
function animateToContentY(targetY) {
|
||||
const clampedY = root.clampScrollY(targetY);
|
||||
|
||||
if (!Settings.data.general.smoothScrollEnabled || Settings.data.general.animationDisabled || gridView.dragging || gridView.flicking) {
|
||||
gridView.contentY = clampedY;
|
||||
root._wheelTargetY = clampedY;
|
||||
return;
|
||||
}
|
||||
|
||||
root._wheelTargetY = clampedY;
|
||||
wheelScrollAnimation.to = clampedY;
|
||||
wheelScrollAnimation.restart();
|
||||
}
|
||||
|
||||
// Track selection index for gradient visibility (set externally)
|
||||
property int trackedSelectionIndex: -1
|
||||
|
||||
// Check if selection is on first visible row
|
||||
readonly property bool selectionOnFirstVisibleRow: {
|
||||
if (trackedSelectionIndex < 0 || cellHeight <= 0 || cellWidth <= 0)
|
||||
return false;
|
||||
|
||||
// Calculate columns per row
|
||||
var cols = Math.floor(gridView.width / cellWidth);
|
||||
if (cols <= 0)
|
||||
cols = 1;
|
||||
|
||||
// Calculate the row of the selection
|
||||
var selectionRow = Math.round(trackedSelectionIndex / cols);
|
||||
|
||||
// Calculate the first visible row
|
||||
var firstVisibleRow = Math.round(gridView.contentY / cellHeight);
|
||||
|
||||
return selectionRow === firstVisibleRow;
|
||||
}
|
||||
|
||||
// Check if selection is on last visible row
|
||||
readonly property bool selectionOnLastVisibleRow: {
|
||||
if (trackedSelectionIndex < 0 || cellHeight <= 0 || cellWidth <= 0)
|
||||
return false;
|
||||
|
||||
// Calculate columns per row
|
||||
var cols = Math.floor(gridView.width / cellWidth);
|
||||
if (cols <= 0)
|
||||
cols = 1;
|
||||
|
||||
// Calculate the row of the selection
|
||||
var selectionRow = Math.round(trackedSelectionIndex / cols);
|
||||
|
||||
// Calculate the last visible row (might be partially visible)
|
||||
var lastVisibleRow = Math.round((gridView.contentY + gridView.height - 1) / cellHeight);
|
||||
|
||||
return selectionRow === lastVisibleRow;
|
||||
}
|
||||
|
||||
// Forward GridView methods
|
||||
function positionViewAtIndex(index, mode) {
|
||||
const shouldAnimate = mode === GridView.Contain;
|
||||
if (!shouldAnimate) {
|
||||
gridView.positionViewAtIndex(index, mode);
|
||||
root._wheelTargetY = gridView.contentY;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousY = gridView.contentY;
|
||||
gridView.positionViewAtIndex(index, mode);
|
||||
const targetY = root.clampScrollY(gridView.contentY);
|
||||
|
||||
if (Math.abs(targetY - previousY) < 0.5) {
|
||||
root._wheelTargetY = targetY;
|
||||
return;
|
||||
}
|
||||
|
||||
gridView.contentY = previousY;
|
||||
root._wheelTargetY = previousY;
|
||||
root.animateToContentY(targetY);
|
||||
}
|
||||
|
||||
function positionViewAtBeginning() {
|
||||
gridView.positionViewAtBeginning();
|
||||
}
|
||||
|
||||
function positionViewAtEnd() {
|
||||
gridView.positionViewAtEnd();
|
||||
}
|
||||
|
||||
function forceLayout() {
|
||||
gridView.forceLayout();
|
||||
}
|
||||
|
||||
function forceActiveFocus() {
|
||||
gridView.forceActiveFocus();
|
||||
}
|
||||
|
||||
function cancelFlick() {
|
||||
gridView.cancelFlick();
|
||||
}
|
||||
|
||||
function flick(xVelocity, yVelocity) {
|
||||
gridView.flick(xVelocity, yVelocity);
|
||||
}
|
||||
|
||||
function incrementCurrentIndex() {
|
||||
gridView.incrementCurrentIndex();
|
||||
}
|
||||
|
||||
function decrementCurrentIndex() {
|
||||
gridView.decrementCurrentIndex();
|
||||
}
|
||||
|
||||
function indexAt(x, y) {
|
||||
return gridView.indexAt(x, y);
|
||||
}
|
||||
|
||||
function itemAt(x, y) {
|
||||
return gridView.itemAt(x, y);
|
||||
}
|
||||
|
||||
function itemAtIndex(index) {
|
||||
return gridView.itemAtIndex(index);
|
||||
}
|
||||
|
||||
function moveCurrentIndexUp() {
|
||||
gridView.moveCurrentIndexUp();
|
||||
}
|
||||
|
||||
function moveCurrentIndexDown() {
|
||||
gridView.moveCurrentIndexDown();
|
||||
}
|
||||
|
||||
function moveCurrentIndexLeft() {
|
||||
gridView.moveCurrentIndexLeft();
|
||||
}
|
||||
|
||||
function moveCurrentIndexRight() {
|
||||
gridView.moveCurrentIndexRight();
|
||||
}
|
||||
|
||||
// Set reasonable implicit sizes for Layout usage
|
||||
implicitWidth: 200
|
||||
implicitHeight: 200
|
||||
|
||||
Component.onCompleted: {
|
||||
_wheelTargetY = gridView.contentY;
|
||||
createGradients();
|
||||
}
|
||||
|
||||
// Dynamically create gradient overlays
|
||||
function createGradients() {
|
||||
if (!showGradientMasks)
|
||||
return;
|
||||
|
||||
Qt.createQmlObject(`
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
Rectangle {
|
||||
x: 0
|
||||
y: 0
|
||||
width: root.availableWidth
|
||||
height: root.gradientHeight
|
||||
z: 1
|
||||
visible: root.showGradientMasks && root.contentOverflows
|
||||
opacity: (gridView.contentY <= 1 || root.selectionOnFirstVisibleRow) ? 0 : 1
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad }
|
||||
}
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: root.gradientColor }
|
||||
GradientStop { position: 1.0; color: "transparent" }
|
||||
}
|
||||
}
|
||||
`, root, "topGradient");
|
||||
|
||||
Qt.createQmlObject(`
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
Rectangle {
|
||||
x: 0
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: -1
|
||||
width: root.availableWidth
|
||||
height: root.gradientHeight + 1
|
||||
z: 1
|
||||
visible: root.showGradientMasks && root.contentOverflows
|
||||
opacity: ((gridView.contentY + gridView.height >= gridView.contentHeight - 1) || root.selectionOnLastVisibleRow) ? 0 : 1
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad }
|
||||
}
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: "transparent" }
|
||||
GradientStop { position: 1.0; color: root.gradientColor }
|
||||
}
|
||||
}
|
||||
`, root, "bottomGradient");
|
||||
}
|
||||
|
||||
GridView {
|
||||
id: gridView
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: root.reserveScrollbarSpace ? root.handleWidth + Style.marginXS : 0
|
||||
|
||||
move: root.animateMovement ? moveTransitionImpl : null
|
||||
displaced: root.animateMovement ? displacedTransitionImpl : null
|
||||
|
||||
Transition {
|
||||
id: moveTransitionImpl
|
||||
NumberAnimation {
|
||||
properties: "x,y"
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
Transition {
|
||||
id: displacedTransitionImpl
|
||||
NumberAnimation {
|
||||
properties: "x,y"
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
|
||||
// Enable clipping to keep content within bounds
|
||||
clip: true
|
||||
|
||||
// Enable flickable for smooth scrolling
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
NumberAnimation {
|
||||
id: wheelScrollAnimation
|
||||
target: gridView
|
||||
property: "contentY"
|
||||
duration: root.smoothWheelAnimationDuration
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
|
||||
onDraggingChanged: {
|
||||
if (dragging) {
|
||||
wheelScrollAnimation.stop();
|
||||
root._wheelTargetY = contentY;
|
||||
}
|
||||
}
|
||||
|
||||
onFlickingChanged: {
|
||||
if (flicking) {
|
||||
wheelScrollAnimation.stop();
|
||||
root._wheelTargetY = contentY;
|
||||
}
|
||||
}
|
||||
|
||||
onContentHeightChanged: root._wheelTargetY = root.clampScrollY(root._wheelTargetY)
|
||||
onHeightChanged: root._wheelTargetY = root.clampScrollY(root._wheelTargetY)
|
||||
|
||||
// Focus handling depends on keyNavigationEnabled
|
||||
focus: keyNavigationEnabled
|
||||
activeFocusOnTab: keyNavigationEnabled
|
||||
|
||||
// Emit keyPressed signal for custom key handling
|
||||
Keys.onPressed: event => {
|
||||
if (keyNavigationEnabled) {
|
||||
root.keyPressed(event);
|
||||
}
|
||||
}
|
||||
|
||||
WheelHandler {
|
||||
enabled: root.wheelScrollMultiplier !== 1.0
|
||||
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||
onWheel: event => {
|
||||
const delta = event.pixelDelta.y !== 0 ? event.pixelDelta.y : event.angleDelta.y / 2;
|
||||
root.applyWheelScroll(delta);
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
ScrollBar.vertical: ScrollBar {
|
||||
parent: root
|
||||
x: root.mirrored ? 0 : root.width - width
|
||||
y: 0
|
||||
height: root.height
|
||||
policy: root.verticalPolicy
|
||||
|
||||
contentItem: Rectangle {
|
||||
implicitWidth: root.handleWidth
|
||||
implicitHeight: 100
|
||||
radius: root.handleRadius
|
||||
color: parent.pressed ? root.handlePressedColor : parent.hovered ? root.handleHoverColor : root.handleColor
|
||||
opacity: parent.policy === ScrollBar.AlwaysOn ? 1.0 : root.verticalScrollBarActive ? ((root.showScrollbarWhenScrollable || parent.active) ? 1.0 : 0.0) : 0.0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
implicitWidth: root.handleWidth
|
||||
implicitHeight: 100
|
||||
color: root.trackColor
|
||||
opacity: parent.policy === ScrollBar.AlwaysOn ? 0.3 : root.verticalScrollBarActive ? ((root.showScrollbarWhenScrollable || parent.active) ? 0.3 : 0.0) : 0.0
|
||||
radius: root.handleRadius / 2
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
|
||||
ColumnLayout {
|
||||
id: root
|
||||
|
||||
property string label: ""
|
||||
property string description: ""
|
||||
property bool enableDescriptionRichText: false
|
||||
|
||||
opacity: enabled ? 1.0 : 0.6
|
||||
spacing: Style.marginXXS
|
||||
Layout.fillWidth: true
|
||||
Layout.bottomMargin: Style.marginM
|
||||
|
||||
NText {
|
||||
text: root.label
|
||||
pointSize: Style.fontSizeXL
|
||||
font.weight: Style.fontWeightSemiBold
|
||||
color: Color.mPrimary
|
||||
visible: root.label !== ""
|
||||
}
|
||||
|
||||
NText {
|
||||
text: root.description
|
||||
pointSize: Style.fontSizeM
|
||||
color: Color.mOnSurfaceVariant
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
visible: root.description !== ""
|
||||
richTextEnabled: root.enableDescriptionRichText
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
Text {
|
||||
id: root
|
||||
|
||||
property string icon: Icons.defaultIcon
|
||||
property real pointSize: Style.fontSizeL
|
||||
property bool applyUiScale: true
|
||||
|
||||
visible: (icon !== undefined) && (icon !== "")
|
||||
text: {
|
||||
if ((icon === undefined) || (icon === "")) {
|
||||
return "";
|
||||
}
|
||||
if (Icons.get(icon) === undefined) {
|
||||
Logger.w("Icon", `"${icon}"`, "doesn't exist in the icons font");
|
||||
Logger.callStack();
|
||||
return Icons.get(Icons.defaultIcon);
|
||||
}
|
||||
return Icons.get(icon);
|
||||
}
|
||||
font.family: Icons.fontFamily
|
||||
font.pointSize: Math.max(1, applyUiScale ? root.pointSize * Style.uiScaleRatio : root.pointSize)
|
||||
color: Color.mOnSurface
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property real baseSize: Style.baseWidgetSize
|
||||
property bool applyUiScale: true
|
||||
|
||||
property string icon
|
||||
property var tooltipText
|
||||
property string tooltipDirection: "auto"
|
||||
property bool allowClickWhenDisabled: false
|
||||
property bool handleWheel: false
|
||||
property bool hovering: false
|
||||
|
||||
property color colorBg: Color.smartAlpha(Color.mSurfaceVariant)
|
||||
property color colorFg: Color.mPrimary
|
||||
property color colorBgHover: Color.mHover
|
||||
property color colorFgHover: Color.mOnHover
|
||||
property color colorBorder: Color.mOutline
|
||||
property color colorBorderHover: Color.mOutline
|
||||
property real customRadius: -1 // -1 means use default (iRadiusL), otherwise use this value
|
||||
|
||||
// Expose border properties for backwards compatibility (aliases to visualButton)
|
||||
property alias border: visualButton.border
|
||||
property alias radius: visualButton.radius
|
||||
property alias color: visualButton.color
|
||||
|
||||
signal entered
|
||||
signal exited
|
||||
signal clicked
|
||||
signal rightClicked
|
||||
signal middleClicked
|
||||
signal wheel(int angleDelta)
|
||||
|
||||
// Calculate button size based on settings
|
||||
readonly property real buttonSize: applyUiScale ? Style.toOdd(baseSize * Style.uiScaleRatio) : Style.toOdd(baseSize)
|
||||
|
||||
// Size: use implicit width/height which layout can override
|
||||
// BarWidgetLoader sets explicit width/height to extend click area
|
||||
implicitWidth: buttonSize
|
||||
implicitHeight: buttonSize
|
||||
|
||||
opacity: enabled ? 1.0 : 0.6
|
||||
|
||||
// Visual button - stays at buttonSize, centered in parent
|
||||
Rectangle {
|
||||
id: visualButton
|
||||
width: root.buttonSize
|
||||
height: root.buttonSize
|
||||
anchors.centerIn: parent
|
||||
|
||||
color: root.enabled && root.hovering ? colorBgHover : colorBg
|
||||
radius: Math.min((customRadius >= 0 ? customRadius : Style.iRadiusL), width / 2)
|
||||
border.color: root.enabled && root.hovering ? colorBorderHover : colorBorder
|
||||
border.width: Style.borderS
|
||||
|
||||
Behavior on color {
|
||||
enabled: !Color.isTransitioning
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
|
||||
NIcon {
|
||||
icon: root.icon
|
||||
pointSize: Style.toOdd(visualButton.width * 0.48)
|
||||
applyUiScale: root.applyUiScale
|
||||
color: root.enabled && root.hovering ? colorFgHover : colorFg
|
||||
// Pixel-perfect centering
|
||||
x: Style.pixelAlignCenter(visualButton.width, width)
|
||||
y: Style.pixelAlignCenter(visualButton.height, contentHeight)
|
||||
|
||||
Behavior on color {
|
||||
enabled: !Color.isTransitioning
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MouseArea fills root (extends beyond visual button for bar click area)
|
||||
MouseArea {
|
||||
// Always enabled to allow hover/tooltip even when the button is disabled
|
||||
enabled: true
|
||||
anchors.fill: parent
|
||||
cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
hoverEnabled: true
|
||||
onEntered: {
|
||||
hovering = root.enabled ? true : false;
|
||||
if (hovering && tooltipText && (!Array.isArray(tooltipText) || tooltipText.length > 0)) {
|
||||
TooltipService.show(root, tooltipText, tooltipDirection);
|
||||
}
|
||||
root.entered();
|
||||
}
|
||||
onExited: {
|
||||
hovering = false;
|
||||
if (tooltipText && (!Array.isArray(tooltipText) || tooltipText.length > 0)) {
|
||||
TooltipService.hide(root);
|
||||
}
|
||||
root.exited();
|
||||
}
|
||||
onClicked: mouse => {
|
||||
if (tooltipText && (!Array.isArray(tooltipText) || tooltipText.length > 0)) {
|
||||
TooltipService.hide(root);
|
||||
}
|
||||
if (!root.enabled && !allowClickWhenDisabled) {
|
||||
return;
|
||||
}
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
root.clicked();
|
||||
} else if (mouse.button === Qt.RightButton) {
|
||||
root.rightClicked();
|
||||
} else if (mouse.button === Qt.MiddleButton) {
|
||||
root.middleClicked();
|
||||
}
|
||||
}
|
||||
onWheel: wheel => {
|
||||
if (root.handleWheel) {
|
||||
root.wheel(wheel.angleDelta.y);
|
||||
}
|
||||
wheel.accepted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Effects
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
// Public properties
|
||||
property real baseSize: Style.baseWidgetSize
|
||||
property bool applyUiScale: true
|
||||
property string icon
|
||||
property var tooltipText
|
||||
property string tooltipDirection: "auto"
|
||||
property bool allowClickWhenDisabled: false
|
||||
property bool hot: false
|
||||
|
||||
// Internal properties
|
||||
property bool hovering: false
|
||||
property bool pressed: false
|
||||
|
||||
// Color properties
|
||||
property color colorBg: Color.smartAlpha(Color.mSurfaceVariant)
|
||||
property color colorFg: Color.mPrimary
|
||||
property color colorBgHover: Color.mHover
|
||||
property color colorFgHover: Color.mOnHover
|
||||
property color colorBorder: Color.mOutline
|
||||
property color colorBorderHover: Color.mOutline
|
||||
|
||||
// Hot state colors
|
||||
property color colorBgHot: Color.mPrimary
|
||||
property color colorFgHot: Color.mOnPrimary
|
||||
|
||||
// Signals
|
||||
signal entered
|
||||
signal exited
|
||||
signal clicked
|
||||
signal rightClicked
|
||||
signal middleClicked
|
||||
|
||||
// Dimensions
|
||||
implicitWidth: applyUiScale ? Math.round(baseSize * Style.uiScaleRatio) : Math.round(baseSize)
|
||||
implicitHeight: applyUiScale ? Math.round(baseSize * Style.uiScaleRatio) : Math.round(baseSize)
|
||||
|
||||
// Appearance
|
||||
opacity: enabled ? 1.0 : 0.6
|
||||
color: {
|
||||
if (root.enabled && root.hovering || pressed) {
|
||||
return colorBgHover;
|
||||
}
|
||||
|
||||
if (hot) {
|
||||
return colorBgHot;
|
||||
}
|
||||
return colorBg;
|
||||
}
|
||||
radius: Math.min(Style.iRadiusL, width / 2)
|
||||
border.color: root.enabled && root.hovering ? colorBorderHover : colorBorder
|
||||
border.width: Style.borderS
|
||||
|
||||
Behavior on color {
|
||||
enabled: !Color.isTransitioning
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
|
||||
// Icon
|
||||
NIcon {
|
||||
icon: root.icon
|
||||
pointSize: Math.max(1, Math.round(root.width * 0.48))
|
||||
applyUiScale: root.applyUiScale
|
||||
color: {
|
||||
if (root.enabled && root.hovering || pressed) {
|
||||
return colorFgHover;
|
||||
}
|
||||
if (hot) {
|
||||
return colorFgHot;
|
||||
}
|
||||
return colorFg;
|
||||
}
|
||||
// Center horizontally
|
||||
x: (root.width - width) / 2
|
||||
// Center vertically accounting for font metrics
|
||||
y: (root.height - height) / 2 + (height - contentHeight) / 2
|
||||
|
||||
Behavior on color {
|
||||
enabled: !Color.isTransitioning
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
// Always enabled to allow hover/tooltip even when the button is disabled
|
||||
enabled: true
|
||||
anchors.fill: parent
|
||||
cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
hoverEnabled: true
|
||||
|
||||
onEntered: {
|
||||
hovering = root.enabled ? true : false;
|
||||
if (hovering && tooltipText && (!Array.isArray(tooltipText) || tooltipText.length > 0)) {
|
||||
TooltipService.show(parent, tooltipText, tooltipDirection);
|
||||
}
|
||||
root.entered();
|
||||
}
|
||||
|
||||
onExited: {
|
||||
hovering = false;
|
||||
if (tooltipText && (!Array.isArray(tooltipText) || tooltipText.length > 0)) {
|
||||
TooltipService.hide();
|
||||
}
|
||||
root.exited();
|
||||
}
|
||||
|
||||
onPressed: function (mouse) {
|
||||
if (root.enabled) {
|
||||
root.pressed = true;
|
||||
}
|
||||
if (tooltipText && (!Array.isArray(tooltipText) || tooltipText.length > 0)) {
|
||||
TooltipService.hide();
|
||||
}
|
||||
}
|
||||
|
||||
onReleased: function (mouse) {
|
||||
root.scale = 1.0;
|
||||
root.pressed = false;
|
||||
|
||||
if (!root.enabled && !allowClickWhenDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only trigger actions if released while hovering
|
||||
if (root.hovering) {
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
root.clicked();
|
||||
} else if (mouse.button === Qt.RightButton) {
|
||||
root.rightClicked();
|
||||
} else if (mouse.button === Qt.MiddleButton) {
|
||||
root.middleClicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onCanceled: {
|
||||
root.hovering = false;
|
||||
root.pressed = false;
|
||||
root.scale = 1.0;
|
||||
if (tooltipText && (!Array.isArray(tooltipText) || tooltipText.length > 0)) {
|
||||
TooltipService.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Window
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
Popup {
|
||||
id: root
|
||||
modal: true
|
||||
|
||||
property string selectedIcon: ""
|
||||
property string initialIcon: ""
|
||||
|
||||
signal iconSelected(string iconName)
|
||||
|
||||
width: Math.round(900 * Style.uiScaleRatio)
|
||||
height: Math.round(700 * Style.uiScaleRatio)
|
||||
anchors.centerIn: Overlay.overlay
|
||||
padding: Style.marginXL
|
||||
|
||||
property string query: ""
|
||||
property var allIcons: Object.keys(Icons.icons)
|
||||
property var filteredIcons: {
|
||||
if (query === "")
|
||||
return allIcons;
|
||||
var q = query.toLowerCase();
|
||||
return allIcons.filter(name => name.toLowerCase().includes(q));
|
||||
}
|
||||
readonly property int columns: 6
|
||||
readonly property int cellW: Math.floor(grid.width / columns)
|
||||
readonly property int cellH: Math.round(cellW * 0.7 + 36)
|
||||
|
||||
onOpened: {
|
||||
selectedIcon = initialIcon;
|
||||
query = initialIcon;
|
||||
searchInput.forceActiveFocus();
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: Color.mSurface
|
||||
radius: Style.iRadiusL
|
||||
border.color: Color.mPrimary
|
||||
border.width: Style.borderM
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: Style.marginM
|
||||
|
||||
// Title row
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
NText {
|
||||
text: I18n.tr("widgets.icon-picker.title")
|
||||
pointSize: Style.fontSizeL
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Color.mPrimary
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
NIconButton {
|
||||
icon: "close"
|
||||
tooltipText: I18n.tr("common.close")
|
||||
onClicked: root.close()
|
||||
}
|
||||
}
|
||||
|
||||
NDivider {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
NTextInput {
|
||||
id: searchInput
|
||||
Layout.fillWidth: true
|
||||
label: I18n.tr("common.search")
|
||||
placeholderText: I18n.tr("placeholders.search-icons")
|
||||
text: root.query
|
||||
onTextChanged: root.query = text.trim().toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
// Icon grid
|
||||
NGridView {
|
||||
id: grid
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.margins: Style.marginM
|
||||
cellWidth: root.cellW
|
||||
cellHeight: root.cellH
|
||||
model: root.filteredIcons
|
||||
reserveScrollbarSpace: false
|
||||
gradientColor: Color.mSurface
|
||||
|
||||
delegate: Rectangle {
|
||||
width: grid.cellWidth
|
||||
height: grid.cellHeight
|
||||
radius: Style.iRadiusS
|
||||
|
||||
color: (root.selectedIcon === modelData) ? Qt.alpha(Color.mPrimary, 0.15) : "transparent"
|
||||
border.color: (root.selectedIcon === modelData) ? Color.mPrimary : "transparent"
|
||||
border.width: (root.selectedIcon === modelData) ? Style.borderS : 0
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.selectedIcon = modelData
|
||||
onDoubleClicked: {
|
||||
root.selectedIcon = modelData;
|
||||
root.iconSelected(root.selectedIcon);
|
||||
root.close();
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginS
|
||||
spacing: Style.marginS
|
||||
Item {
|
||||
Layout.fillHeight: true
|
||||
Layout.preferredHeight: 4
|
||||
}
|
||||
NIcon {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
icon: modelData
|
||||
pointSize: 42
|
||||
}
|
||||
NText {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: Style.marginXS
|
||||
elide: Text.ElideRight
|
||||
wrapMode: Text.NoWrap
|
||||
maximumLineCount: 1
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
color: Color.mOnSurfaceVariant
|
||||
pointSize: Style.fontSizeXS
|
||||
text: modelData
|
||||
}
|
||||
Item {
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginM
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
NButton {
|
||||
text: I18n.tr("common.cancel")
|
||||
outlined: true
|
||||
onClicked: root.close()
|
||||
}
|
||||
NButton {
|
||||
text: I18n.tr("common.apply")
|
||||
icon: "check"
|
||||
enabled: root.selectedIcon !== ""
|
||||
onClicked: {
|
||||
root.iconSelected(root.selectedIcon);
|
||||
root.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import qs.Commons
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property real radius: 0
|
||||
property string imagePath: ""
|
||||
property string fallbackIcon: ""
|
||||
property real fallbackIconSize: Style.fontSizeXXL
|
||||
property real borderWidth: 0
|
||||
property color borderColor: "transparent"
|
||||
property int imageFillMode: Image.PreserveAspectCrop
|
||||
|
||||
readonly property bool _isAnimated: imagePath.toLowerCase().endsWith(".gif")
|
||||
readonly property Item imageSource: imageSourceLoader.item
|
||||
readonly property bool showFallback: fallbackIcon !== "" && (imagePath === "" || (imageSource && imageSource.status === Image.Error))
|
||||
readonly property int status: imageSource ? imageSource.status : Image.Null
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: root.radius
|
||||
color: "transparent"
|
||||
border.width: root.borderWidth
|
||||
border.color: root.borderColor
|
||||
|
||||
Loader {
|
||||
id: imageSourceLoader
|
||||
anchors.fill: parent
|
||||
anchors.margins: root.borderWidth
|
||||
active: root.imagePath !== ""
|
||||
sourceComponent: root._isAnimated ? animatedComponent : staticComponent
|
||||
}
|
||||
|
||||
Component {
|
||||
id: staticComponent
|
||||
Image {
|
||||
visible: false
|
||||
source: root.imagePath
|
||||
mipmap: true
|
||||
smooth: true
|
||||
asynchronous: true
|
||||
antialiasing: true
|
||||
fillMode: root.imageFillMode
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: animatedComponent
|
||||
AnimatedImage {
|
||||
visible: false
|
||||
source: root.imagePath
|
||||
mipmap: true
|
||||
smooth: true
|
||||
asynchronous: true
|
||||
antialiasing: true
|
||||
fillMode: root.imageFillMode
|
||||
playing: true
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback texture provider to avoid null source warnings
|
||||
ShaderEffectSource {
|
||||
id: _safeFallback
|
||||
sourceItem: Rectangle {
|
||||
width: 1
|
||||
height: 1
|
||||
color: "transparent"
|
||||
}
|
||||
visible: false
|
||||
live: false
|
||||
}
|
||||
|
||||
ShaderEffect {
|
||||
anchors.fill: parent
|
||||
anchors.margins: root.borderWidth
|
||||
visible: !root.showFallback && root.imageSource !== null && root.status === Image.Ready
|
||||
property var source: root.imageSource ?? _safeFallback
|
||||
property real itemWidth: width
|
||||
property real itemHeight: height
|
||||
property real sourceWidth: root.imageSource?.sourceSize.width ?? 0
|
||||
property real sourceHeight: root.imageSource?.sourceSize.height ?? 0
|
||||
property real cornerRadius: Math.max(0, root.radius - root.borderWidth)
|
||||
property real imageOpacity: 1.0
|
||||
property int fillMode: root.imageFillMode
|
||||
|
||||
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/rounded_image.frag.qsb")
|
||||
supportsAtlasTextures: false
|
||||
blending: true
|
||||
}
|
||||
|
||||
NIcon {
|
||||
anchors.fill: parent
|
||||
anchors.margins: root.borderWidth
|
||||
visible: root.showFallback
|
||||
icon: root.fallbackIcon
|
||||
pointSize: root.fallbackIconSize
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
// Input and button row
|
||||
RowLayout {
|
||||
id: root
|
||||
|
||||
// Public properties
|
||||
property string label: ""
|
||||
property string description: ""
|
||||
property string placeholderText: ""
|
||||
property string text: ""
|
||||
property string actionButtonText: I18n.tr("common.test")
|
||||
property string actionButtonIcon: "media-play"
|
||||
property bool actionButtonEnabled: text !== ""
|
||||
|
||||
// Signals
|
||||
signal editingFinished
|
||||
signal actionClicked
|
||||
|
||||
// Internal properties
|
||||
spacing: Style.marginM
|
||||
|
||||
NTextInput {
|
||||
id: textInput
|
||||
label: root.label
|
||||
description: root.description
|
||||
placeholderText: root.placeholderText
|
||||
text: root.text
|
||||
onTextChanged: root.text = text
|
||||
onEditingFinished: root.editingFinished()
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NButton {
|
||||
Layout.fillWidth: false
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
|
||||
text: root.actionButtonText
|
||||
icon: root.actionButtonIcon
|
||||
backgroundColor: Color.mSecondary
|
||||
textColor: Color.mOnSecondary
|
||||
hoverColor: Color.mHover
|
||||
enabled: root.actionButtonEnabled
|
||||
|
||||
onClicked: {
|
||||
root.actionClicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property string label: ""
|
||||
property string description: ""
|
||||
property var currentKeybinds: []
|
||||
property string defaultKeybind: ""
|
||||
property bool allowEmpty: false
|
||||
property color labelColor: Color.mOnSurface
|
||||
property color descriptionColor: Color.mOnSurfaceVariant
|
||||
property string settingsPath: ""
|
||||
|
||||
property int maxKeybinds: 2
|
||||
property bool requireModifierForNormalKeys: true
|
||||
signal keybindsChanged(var newKeybinds)
|
||||
|
||||
implicitHeight: contentLayout.implicitHeight
|
||||
|
||||
// -1 = not recording, >= 0 = re-recording at index, -2 = adding new
|
||||
property int recordingIndex: -1
|
||||
property bool hasConflict: false
|
||||
|
||||
onRecordingIndexChanged: {
|
||||
PanelService.isKeybindRecording = recordingIndex !== -1;
|
||||
if (recordingIndex !== -1) {
|
||||
hasConflict = false;
|
||||
}
|
||||
}
|
||||
|
||||
readonly property real _pillHeight: Style.baseWidgetSize * 1.1 * Style.uiScaleRatio
|
||||
|
||||
function _applyKeybind(keyStr) {
|
||||
if (!keyStr)
|
||||
return;
|
||||
|
||||
// 1. Internal duplicate check (same action)
|
||||
for (let i = 0; i < root.currentKeybinds.length; i++) {
|
||||
if (i !== root.recordingIndex && String(root.currentKeybinds[i]).toLowerCase() === keyStr.toLowerCase()) {
|
||||
hasConflict = true;
|
||||
ToastService.showWarning(I18n.tr("panels.general.keybinds-conflict-title"), I18n.tr("panels.general.keybinds-conflict-description", {
|
||||
"action": root.label || "This action"
|
||||
}));
|
||||
conflictTimer.restart();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. External conflict check (other actions)
|
||||
const conflict = Keybinds.getKeybindConflict(keyStr, root.settingsPath, Settings.data);
|
||||
if (conflict) {
|
||||
hasConflict = true;
|
||||
ToastService.showWarning(I18n.tr("panels.general.keybinds-conflict-title"), I18n.tr("panels.general.keybinds-conflict-description", {
|
||||
"action": conflict
|
||||
}));
|
||||
conflictTimer.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
var newKeybinds = Array.from(root.currentKeybinds);
|
||||
if (recordingIndex >= 0) {
|
||||
newKeybinds[recordingIndex] = keyStr;
|
||||
}
|
||||
// Ensure array is dense and limited to maxKeybinds
|
||||
newKeybinds = newKeybinds.filter(k => k !== undefined && k !== "").slice(0, root.maxKeybinds);
|
||||
recordingIndex = -1;
|
||||
root.keybindsChanged(newKeybinds);
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: conflictTimer
|
||||
interval: 2000
|
||||
onTriggered: {
|
||||
hasConflict = false;
|
||||
recordingIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: contentLayout
|
||||
width: parent.width
|
||||
spacing: Style.marginL
|
||||
|
||||
// Label and Description (optional)
|
||||
NLabel {
|
||||
id: labelContainer
|
||||
label: root.label
|
||||
description: root.description
|
||||
labelColor: root.labelColor
|
||||
descriptionColor: root.descriptionColor
|
||||
visible: label !== "" || description !== ""
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: slotsRow
|
||||
spacing: Style.marginS
|
||||
Layout.alignment: Qt.AlignVCenter | (labelContainer.visible ? Qt.AlignRight : Qt.AlignLeft)
|
||||
|
||||
Repeater {
|
||||
model: root.maxKeybinds
|
||||
delegate: MouseArea {
|
||||
id: slotArea
|
||||
width: Math.round(180 * Style.uiScaleRatio)
|
||||
height: root._pillHeight
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
|
||||
readonly property bool isOccupied: index < root.currentKeybinds.length
|
||||
readonly property bool isRecordingThis: root.recordingIndex === index
|
||||
readonly property string keybindText: isRecordingThis ? I18n.tr("placeholders.keybind-recording") : (isOccupied ? root.currentKeybinds[index] : I18n.tr("placeholders.add-new-keybind"))
|
||||
|
||||
onClicked: {
|
||||
if (isRecordingThis) {
|
||||
root.recordingIndex = -1;
|
||||
} else {
|
||||
root.recordingIndex = index;
|
||||
keybindInput.forceActiveFocus();
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: slotBg
|
||||
anchors.fill: parent
|
||||
radius: Style.iRadiusS
|
||||
color: root.hasConflict && slotArea.isRecordingThis ? Color.mError : (slotArea.isRecordingThis ? Color.mSecondary : (slotArea.containsMouse ? Qt.alpha(Color.mSecondary, 0.15) : Color.mSurface))
|
||||
border.color: root.hasConflict && slotArea.isRecordingThis ? Color.mError : (slotArea.isRecordingThis ? Color.mPrimary : (slotArea.containsMouse ? Color.mSecondary : Color.mOutline))
|
||||
border.width: Style.borderS
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
Behavior on border.color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Style.marginM
|
||||
anchors.rightMargin: Style.marginS
|
||||
spacing: Style.marginXS
|
||||
|
||||
NIcon {
|
||||
icon: root.hasConflict && slotArea.isRecordingThis ? "alert-circle" : (slotArea.isRecordingThis ? "circle-dot" : "keyboard")
|
||||
color: slotArea.isRecordingThis ? Color.mOnSecondary : (slotArea.isOccupied ? Color.mOnSurfaceVariant : Qt.alpha(Color.mOnSurfaceVariant, 0.4))
|
||||
opacity: 0.8
|
||||
visible: !slotArea.isRecordingThis || root.hasConflict
|
||||
}
|
||||
|
||||
NText {
|
||||
Layout.fillWidth: true
|
||||
text: slotArea.keybindText
|
||||
color: slotArea.isRecordingThis ? Color.mOnSecondary : (slotArea.isOccupied ? Color.mOnSurface : Color.mOnSurfaceVariant)
|
||||
font.family: slotArea.isOccupied && !slotArea.isRecordingThis ? Settings.data.ui.fontFixed : Settings.data.ui.fontDefault
|
||||
font.pointSize: slotArea.isOccupied ? Style.fontSizeM : Style.fontSizeS
|
||||
font.weight: slotArea.isOccupied ? Style.fontWeightBold : Style.fontWeightRegular
|
||||
elide: Text.ElideRight
|
||||
opacity: slotArea.isOccupied || slotArea.isRecordingThis ? 1.0 : 0.6
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: Math.round(root._pillHeight * 0.7)
|
||||
Layout.fillHeight: true
|
||||
visible: slotArea.isOccupied && root.recordingIndex === -1
|
||||
|
||||
NIconButton {
|
||||
anchors.centerIn: parent
|
||||
visible: root.recordingIndex === -1 && (root.currentKeybinds.length > 1 || root.allowEmpty)
|
||||
icon: "x"
|
||||
colorBg: "transparent"
|
||||
colorBgHover: Qt.alpha(Color.mError, 0.1)
|
||||
colorFg: Color.mOnSurfaceVariant
|
||||
colorFgHover: Color.mError
|
||||
border.width: 0
|
||||
baseSize: Style.baseWidgetSize * 0.7
|
||||
onClicked: {
|
||||
var newKeybinds = Array.from(root.currentKeybinds);
|
||||
newKeybinds.splice(index, 1);
|
||||
root.keybindsChanged(newKeybinds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden Item to capture keys
|
||||
Item {
|
||||
id: keybindInput
|
||||
width: 0
|
||||
height: 0
|
||||
focus: true
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (root.recordingIndex === -1 || root.hasConflict)
|
||||
return;
|
||||
|
||||
// Handle Escape specifically to ensure it doesn't close the panel
|
||||
if (event.key === Qt.Key_Escape) {
|
||||
event.accepted = true;
|
||||
root._applyKeybind("Esc");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore modifier keys by themselves
|
||||
if (event.key === Qt.Key_Control || event.key === Qt.Key_Shift || event.key === Qt.Key_Alt || event.key === Qt.Key_Meta) {
|
||||
event.accepted = true; // Consume modifiers too while listening
|
||||
return;
|
||||
}
|
||||
|
||||
let keyStr = "";
|
||||
if (event.modifiers & Qt.ControlModifier)
|
||||
keyStr += "Ctrl+";
|
||||
if (event.modifiers & Qt.AltModifier)
|
||||
keyStr += "Alt+";
|
||||
if (event.modifiers & Qt.ShiftModifier)
|
||||
keyStr += "Shift+";
|
||||
|
||||
let keyName = "";
|
||||
let rawText = event.text;
|
||||
|
||||
if (event.key >= Qt.Key_A && event.key <= Qt.Key_Z || event.key >= Qt.Key_0 && event.key <= Qt.Key_9) {
|
||||
keyName = String.fromCharCode(event.key);
|
||||
} else if (event.key >= Qt.Key_F1 && event.key <= Qt.Key_F12) {
|
||||
keyName = "F" + (event.key - Qt.Key_F1 + 1);
|
||||
} else if (rawText && rawText.length > 0 && rawText.charCodeAt(0) > 31 && rawText.charCodeAt(0) !== 127) {
|
||||
keyName = rawText.toUpperCase();
|
||||
|
||||
// Handle shifted digits
|
||||
if (event.modifiers & Qt.ShiftModifier) {
|
||||
const shiftMap = {
|
||||
"!": "1",
|
||||
"\"": "2",
|
||||
"§": "3",
|
||||
"$": "4",
|
||||
"%": "5",
|
||||
"&": "6",
|
||||
"/": "7",
|
||||
"(": "8",
|
||||
")": "9",
|
||||
"=": "0",
|
||||
"@": "2",
|
||||
"#": "3",
|
||||
"^": "6",
|
||||
"*": "8"
|
||||
};
|
||||
if (shiftMap[keyName])
|
||||
keyName = shiftMap[keyName];
|
||||
}
|
||||
} else {
|
||||
switch (event.key) {
|
||||
case Qt.Key_Return:
|
||||
keyName = "Return";
|
||||
break;
|
||||
case Qt.Key_Enter:
|
||||
keyName = "Enter";
|
||||
break;
|
||||
case Qt.Key_Tab:
|
||||
keyName = "Tab";
|
||||
break;
|
||||
case Qt.Key_Backspace:
|
||||
keyName = "Backspace";
|
||||
break;
|
||||
case Qt.Key_Delete:
|
||||
keyName = "Del";
|
||||
break;
|
||||
case Qt.Key_Insert:
|
||||
keyName = "Ins";
|
||||
break;
|
||||
case Qt.Key_Home:
|
||||
keyName = "Home";
|
||||
break;
|
||||
case Qt.Key_End:
|
||||
keyName = "End";
|
||||
break;
|
||||
case Qt.Key_PageUp:
|
||||
keyName = "PgUp";
|
||||
break;
|
||||
case Qt.Key_PageDown:
|
||||
keyName = "PgDn";
|
||||
break;
|
||||
case Qt.Key_Left:
|
||||
keyName = "Left";
|
||||
break;
|
||||
case Qt.Key_Right:
|
||||
keyName = "Right";
|
||||
break;
|
||||
case Qt.Key_Up:
|
||||
keyName = "Up";
|
||||
break;
|
||||
case Qt.Key_Down:
|
||||
keyName = "Down";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (keyName) {
|
||||
// Enforce modifier requirement (Ctrl or Alt) for "normal" keys unless explicitly disabled
|
||||
// Allow Arrows, Nav, Function, and System keys without modifiers
|
||||
const isSpecialKey = (event.key >= Qt.Key_F1 && event.key <= Qt.Key_F35) || (event.key >= Qt.Key_Left && event.key <= Qt.Key_Down) || (event.key === Qt.Key_Home || event.key === Qt.Key_End || event.key === Qt.Key_PageUp || event.key === Qt.Key_PageDown) || (event.key === Qt.Key_Insert || event.key === Qt.Key_Delete || event.key
|
||||
=== Qt.Key_Backspace) || (event.key === Qt.Key_Tab || event.key
|
||||
=== Qt.Key_Return || event.key === Qt.Key_Enter
|
||||
|| event.key === Qt.Key_Escape || event.key
|
||||
=== Qt.Key_Space);
|
||||
|
||||
const hasModifier = (event.modifiers & Qt.ControlModifier) || (event.modifiers & Qt.AltModifier);
|
||||
|
||||
if (root.requireModifierForNormalKeys && !hasModifier && !isSpecialKey) {
|
||||
hasConflict = true;
|
||||
ToastService.showWarning(I18n.tr("panels.general.keybinds-modifier-title"), I18n.tr("panels.general.keybinds-modifier-description"));
|
||||
conflictTimer.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
root._applyKeybind(keyStr + keyName);
|
||||
}
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
ColumnLayout {
|
||||
id: root
|
||||
|
||||
property string label: ""
|
||||
property string description: ""
|
||||
property string icon: ""
|
||||
property color labelColor: Color.mOnSurface
|
||||
property color descriptionColor: Color.mOnSurfaceVariant
|
||||
property color iconColor: Color.mOnSurface
|
||||
property bool showIndicator: false
|
||||
property string indicatorTooltip: ""
|
||||
property real labelSize: Style.fontSizeL
|
||||
|
||||
opacity: enabled ? 1.0 : 0.6
|
||||
spacing: Style.marginXXS
|
||||
visible: root.label != "" || root.description != ""
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
RowLayout {
|
||||
spacing: Style.marginXS
|
||||
Layout.fillWidth: true
|
||||
visible: root.label !== ""
|
||||
|
||||
NIcon {
|
||||
visible: root.icon !== ""
|
||||
icon: root.icon
|
||||
pointSize: Style.fontSizeXXL
|
||||
color: root.iconColor
|
||||
Layout.rightMargin: Style.marginS
|
||||
}
|
||||
|
||||
NText {
|
||||
id: labelText
|
||||
Layout.fillWidth: true
|
||||
text: root.label
|
||||
pointSize: root.labelSize
|
||||
font.weight: Style.fontWeightSemiBold
|
||||
color: labelColor
|
||||
wrapMode: Text.WordWrap
|
||||
|
||||
// Settings indicator dot positioned right after the text content
|
||||
Loader {
|
||||
active: root.showIndicator
|
||||
x: labelText.contentWidth + Style.marginXS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
sourceComponent: NSettingsIndicator {
|
||||
show: true
|
||||
tooltipText: root.indicatorTooltip || ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
visible: root.description !== ""
|
||||
Layout.fillWidth: true
|
||||
text: root.description
|
||||
pointSize: Style.fontSizeS
|
||||
color: root.descriptionColor
|
||||
wrapMode: Text.WordWrap
|
||||
textFormat: Text.StyledText
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
// Mandatory properties for gauges
|
||||
required property int orientation // Qt.Vertical || Qt.Horizontal
|
||||
required property real ratio // 0..1
|
||||
|
||||
radius: orientation === Qt.Vertical ? width / 2 : height / 2
|
||||
color: Color.mOutline
|
||||
property color fillColor: Color.mPrimary
|
||||
|
||||
// Fill that grows from bottom if vertical and left if horizontal.
|
||||
// Snap to zero if the computed pixel length is sub-pixel (< 1px).
|
||||
Rectangle {
|
||||
readonly property real clampedRatio: Math.min(1, Math.max(0, root.ratio))
|
||||
readonly property real rawFill: (orientation === Qt.Vertical ? root.height : root.width) * clampedRatio
|
||||
width: orientation === Qt.Vertical ? root.width : (rawFill < 1 ? 0 : rawFill)
|
||||
height: orientation === Qt.Vertical ? (rawFill < 1 ? 0 : rawFill) : root.height
|
||||
radius: root.radius
|
||||
color: root.fillColor
|
||||
anchors.bottom: orientation === Qt.Vertical ? parent.bottom : undefined
|
||||
anchors.left: parent.left
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Templates as T
|
||||
import qs.Commons
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property color handleColor: Qt.alpha(Color.mHover, 0.8)
|
||||
property color handleHoverColor: handleColor
|
||||
property color handlePressedColor: handleColor
|
||||
property color trackColor: "transparent"
|
||||
property real handleWidth: Math.round(6 * Style.uiScaleRatio)
|
||||
property real handleRadius: Style.iRadiusM
|
||||
property int verticalPolicy: ScrollBar.AsNeeded
|
||||
property int horizontalPolicy: ScrollBar.AlwaysOff
|
||||
readonly property bool verticalScrollBarActive: {
|
||||
if (listView.ScrollBar.vertical.policy === ScrollBar.AlwaysOff)
|
||||
return false;
|
||||
return listView.contentHeight > listView.height;
|
||||
}
|
||||
readonly property bool contentOverflows: listView.contentHeight > listView.height
|
||||
|
||||
property bool showGradientMasks: true
|
||||
property color gradientColor: Color.mSurfaceVariant
|
||||
property int gradientHeight: 16
|
||||
property bool reserveScrollbarSpace: true
|
||||
|
||||
// Keep scrollbars visible whenever overflow exists (without forcing visibility when not scrollable)
|
||||
property bool showScrollbarWhenScrollable: Settings.data.ui.scrollbarAlwaysVisible
|
||||
|
||||
// Available width for content (excludes scrollbar space when reserveScrollbarSpace is true)
|
||||
readonly property real availableWidth: width - (reserveScrollbarSpace ? handleWidth + Style.marginXS : 0)
|
||||
|
||||
// Forward ListView properties
|
||||
property alias model: listView.model
|
||||
property alias delegate: listView.delegate
|
||||
property alias spacing: listView.spacing
|
||||
property alias orientation: listView.orientation
|
||||
property alias currentIndex: listView.currentIndex
|
||||
property alias count: listView.count
|
||||
property alias contentHeight: listView.contentHeight
|
||||
property alias contentWidth: listView.contentWidth
|
||||
property alias contentY: listView.contentY
|
||||
property alias contentX: listView.contentX
|
||||
property alias currentItem: listView.currentItem
|
||||
property alias highlightItem: listView.highlightItem
|
||||
property alias headerItem: listView.headerItem
|
||||
property alias footerItem: listView.footerItem
|
||||
property alias section: listView.section
|
||||
property alias highlightFollowsCurrentItem: listView.highlightFollowsCurrentItem
|
||||
property alias highlightMoveDuration: listView.highlightMoveDuration
|
||||
property alias highlightMoveVelocity: listView.highlightMoveVelocity
|
||||
property alias preferredHighlightBegin: listView.preferredHighlightBegin
|
||||
property alias preferredHighlightEnd: listView.preferredHighlightEnd
|
||||
property alias highlightRangeMode: listView.highlightRangeMode
|
||||
property alias snapMode: listView.snapMode
|
||||
property alias keyNavigationWraps: listView.keyNavigationWraps
|
||||
property alias cacheBuffer: listView.cacheBuffer
|
||||
property alias displayMarginBeginning: listView.displayMarginBeginning
|
||||
property alias displayMarginEnd: listView.displayMarginEnd
|
||||
property alias layoutDirection: listView.layoutDirection
|
||||
property alias effectiveLayoutDirection: listView.effectiveLayoutDirection
|
||||
property alias verticalLayoutDirection: listView.verticalLayoutDirection
|
||||
property alias boundsBehavior: listView.boundsBehavior
|
||||
property alias flickableDirection: listView.flickableDirection
|
||||
property alias interactive: listView.interactive
|
||||
property alias moving: listView.moving
|
||||
property alias flicking: listView.flicking
|
||||
property alias dragging: listView.dragging
|
||||
property alias horizontalVelocity: listView.horizontalVelocity
|
||||
property alias verticalVelocity: listView.verticalVelocity
|
||||
|
||||
// Scroll speed multiplier for mouse wheel (1.0 = default, higher = faster)
|
||||
property real wheelScrollMultiplier: 2.0
|
||||
property int smoothWheelAnimationDuration: Style.animationNormal
|
||||
property real _wheelTargetY: 0
|
||||
|
||||
function clampScrollY(value) {
|
||||
return Math.max(0, Math.min(value, listView.contentHeight - listView.height));
|
||||
}
|
||||
|
||||
function applyWheelScroll(delta) {
|
||||
if (!root.contentOverflows)
|
||||
return;
|
||||
|
||||
const step = delta * root.wheelScrollMultiplier;
|
||||
|
||||
if (!Settings.data.general.smoothScrollEnabled || Settings.data.general.animationDisabled) {
|
||||
listView.contentY = root.clampScrollY(listView.contentY - step);
|
||||
root._wheelTargetY = listView.contentY;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wheelScrollAnimation.running)
|
||||
root._wheelTargetY = listView.contentY;
|
||||
|
||||
root._wheelTargetY = root.clampScrollY(root._wheelTargetY - step);
|
||||
wheelScrollAnimation.to = root._wheelTargetY;
|
||||
wheelScrollAnimation.restart();
|
||||
}
|
||||
|
||||
function animateToContentY(targetY) {
|
||||
const clampedY = root.clampScrollY(targetY);
|
||||
|
||||
if (!Settings.data.general.smoothScrollEnabled || Settings.data.general.animationDisabled || listView.dragging || listView.flicking) {
|
||||
listView.contentY = clampedY;
|
||||
root._wheelTargetY = clampedY;
|
||||
return;
|
||||
}
|
||||
|
||||
root._wheelTargetY = clampedY;
|
||||
wheelScrollAnimation.to = clampedY;
|
||||
wheelScrollAnimation.restart();
|
||||
}
|
||||
|
||||
// Forward ListView methods
|
||||
function positionViewAtIndex(index, mode) {
|
||||
const shouldAnimate = mode === ListView.Contain;
|
||||
if (!shouldAnimate) {
|
||||
listView.positionViewAtIndex(index, mode);
|
||||
root._wheelTargetY = listView.contentY;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousY = listView.contentY;
|
||||
listView.positionViewAtIndex(index, mode);
|
||||
const targetY = root.clampScrollY(listView.contentY);
|
||||
|
||||
if (Math.abs(targetY - previousY) < 0.5) {
|
||||
root._wheelTargetY = targetY;
|
||||
return;
|
||||
}
|
||||
|
||||
listView.contentY = previousY;
|
||||
root._wheelTargetY = previousY;
|
||||
root.animateToContentY(targetY);
|
||||
}
|
||||
|
||||
function positionViewAtBeginning() {
|
||||
listView.positionViewAtBeginning();
|
||||
}
|
||||
|
||||
function positionViewAtEnd() {
|
||||
listView.positionViewAtEnd();
|
||||
}
|
||||
|
||||
function forceLayout() {
|
||||
listView.forceLayout();
|
||||
}
|
||||
|
||||
function cancelFlick() {
|
||||
listView.cancelFlick();
|
||||
}
|
||||
|
||||
function flick(xVelocity, yVelocity) {
|
||||
listView.flick(xVelocity, yVelocity);
|
||||
}
|
||||
|
||||
function incrementCurrentIndex() {
|
||||
listView.incrementCurrentIndex();
|
||||
}
|
||||
|
||||
function decrementCurrentIndex() {
|
||||
listView.decrementCurrentIndex();
|
||||
}
|
||||
|
||||
function indexAt(x, y) {
|
||||
return listView.indexAt(x, y);
|
||||
}
|
||||
|
||||
function itemAt(x, y) {
|
||||
return listView.itemAt(x, y);
|
||||
}
|
||||
|
||||
function itemAtIndex(index) {
|
||||
return listView.itemAtIndex(index);
|
||||
}
|
||||
|
||||
// Set reasonable implicit sizes for Layout usage
|
||||
implicitWidth: 200
|
||||
implicitHeight: 200
|
||||
|
||||
Component.onCompleted: {
|
||||
_wheelTargetY = listView.contentY;
|
||||
createGradients();
|
||||
}
|
||||
|
||||
// Dynamically create gradient overlays
|
||||
function createGradients() {
|
||||
if (!showGradientMasks)
|
||||
return;
|
||||
|
||||
Qt.createQmlObject(`
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
Rectangle {
|
||||
x: 0
|
||||
y: 0
|
||||
width: root.availableWidth
|
||||
height: root.gradientHeight
|
||||
z: 1
|
||||
visible: root.showGradientMasks && root.contentOverflows
|
||||
opacity: {
|
||||
if (listView.contentY <= 1) return 0;
|
||||
if (listView.currentItem && listView.currentItem.y - listView.contentY < root.gradientHeight) return 0;
|
||||
return 1;
|
||||
}
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad }
|
||||
}
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: root.gradientColor }
|
||||
GradientStop { position: 1.0; color: "transparent" }
|
||||
}
|
||||
}
|
||||
`, root, "topGradient");
|
||||
|
||||
Qt.createQmlObject(`
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
Rectangle {
|
||||
x: 0
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: -1
|
||||
width: root.availableWidth
|
||||
height: root.gradientHeight + 1
|
||||
z: 1
|
||||
visible: root.showGradientMasks && root.contentOverflows
|
||||
opacity: {
|
||||
if (listView.contentY + listView.height >= listView.contentHeight - 1) return 0;
|
||||
if (listView.currentItem && listView.currentItem.y + listView.currentItem.height > listView.contentY + listView.height - root.gradientHeight) return 0;
|
||||
return 1;
|
||||
}
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad }
|
||||
}
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: "transparent" }
|
||||
GradientStop { position: 1.0; color: root.gradientColor }
|
||||
}
|
||||
}
|
||||
`, root, "bottomGradient");
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: listView
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: root.reserveScrollbarSpace ? root.handleWidth + Style.marginXS : 0
|
||||
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
NumberAnimation {
|
||||
id: wheelScrollAnimation
|
||||
target: listView
|
||||
property: "contentY"
|
||||
duration: root.smoothWheelAnimationDuration
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
|
||||
onDraggingChanged: {
|
||||
if (dragging) {
|
||||
wheelScrollAnimation.stop();
|
||||
root._wheelTargetY = contentY;
|
||||
}
|
||||
}
|
||||
|
||||
onFlickingChanged: {
|
||||
if (flicking) {
|
||||
wheelScrollAnimation.stop();
|
||||
root._wheelTargetY = contentY;
|
||||
}
|
||||
}
|
||||
|
||||
onContentHeightChanged: root._wheelTargetY = root.clampScrollY(root._wheelTargetY)
|
||||
onHeightChanged: root._wheelTargetY = root.clampScrollY(root._wheelTargetY)
|
||||
|
||||
WheelHandler {
|
||||
enabled: !root.contentOverflows
|
||||
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||
onWheel: event => {
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
WheelHandler {
|
||||
enabled: root.wheelScrollMultiplier !== 1.0 && root.contentOverflows
|
||||
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||
onWheel: event => {
|
||||
const delta = event.pixelDelta.y !== 0 ? event.pixelDelta.y : event.angleDelta.y / 2;
|
||||
root.applyWheelScroll(delta);
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
ScrollBar.vertical: ScrollBar {
|
||||
parent: root
|
||||
x: root.mirrored ? 0 : root.width - width
|
||||
y: 0
|
||||
height: root.height
|
||||
policy: root.verticalPolicy
|
||||
visible: policy === ScrollBar.AlwaysOn || root.verticalScrollBarActive
|
||||
|
||||
contentItem: Rectangle {
|
||||
implicitWidth: root.handleWidth
|
||||
implicitHeight: 100
|
||||
radius: root.handleRadius
|
||||
color: parent.pressed ? root.handlePressedColor : parent.hovered ? root.handleHoverColor : root.handleColor
|
||||
opacity: parent.policy === ScrollBar.AlwaysOn ? 1.0 : root.verticalScrollBarActive ? ((root.showScrollbarWhenScrollable || parent.active) ? 1.0 : 0.0) : 0.0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
implicitWidth: root.handleWidth
|
||||
implicitHeight: 100
|
||||
color: root.trackColor
|
||||
opacity: parent.policy === ScrollBar.AlwaysOn ? 0.3 : root.verticalScrollBarActive ? ((root.showScrollbarWhenScrollable || parent.active) ? 0.3 : 0.0) : 0.0
|
||||
radius: root.handleRadius / 2
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Services.Noctalia
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Popup {
|
||||
id: root
|
||||
modal: true
|
||||
dim: false
|
||||
anchors.centerIn: parent
|
||||
property var screen: null
|
||||
readonly property real maxHeight: (screen ? screen.height : (parent ? parent.height : 800)) * 0.8
|
||||
|
||||
property real _minWidth: 600 * Style.uiScaleRatio
|
||||
width: _minWidth
|
||||
height: Math.min(settingsContent.implicitHeight + padding * 2, maxHeight)
|
||||
padding: Style.marginXL
|
||||
|
||||
property var currentPlugin: null
|
||||
property var currentPluginApi: null
|
||||
property bool showToastOnSave: false
|
||||
|
||||
background: Rectangle {
|
||||
color: Color.mSurface
|
||||
radius: Style.radiusL
|
||||
border.color: Color.mPrimary
|
||||
border.width: Style.borderM
|
||||
}
|
||||
|
||||
contentItem: FocusScope {
|
||||
focus: true
|
||||
|
||||
ColumnLayout {
|
||||
id: settingsContent
|
||||
anchors.fill: parent
|
||||
spacing: Style.marginM
|
||||
|
||||
// Header
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
NText {
|
||||
text: I18n.tr("panels.plugins.plugin-settings-title", {
|
||||
"plugin": root.currentPlugin?.name || ""
|
||||
})
|
||||
pointSize: Style.fontSizeL
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Color.mPrimary
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "close"
|
||||
tooltipText: I18n.tr("common.close")
|
||||
onClicked: root.close()
|
||||
}
|
||||
}
|
||||
|
||||
// Separator
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 1
|
||||
color: Color.mOutline
|
||||
}
|
||||
|
||||
// Settings loader - pluginApi is passed via setSource() in openPluginSettings()
|
||||
NScrollView {
|
||||
id: settingsScrollView
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.minimumHeight: 100
|
||||
horizontalPolicy: ScrollBar.AlwaysOff
|
||||
gradientColor: Color.mSurface
|
||||
|
||||
Loader {
|
||||
id: settingsLoader
|
||||
width: settingsScrollView.availableWidth
|
||||
}
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: Style.marginM
|
||||
spacing: Style.marginM
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NButton {
|
||||
text: I18n.tr("common.close")
|
||||
outlined: true
|
||||
onClicked: root.close()
|
||||
}
|
||||
|
||||
NButton {
|
||||
text: I18n.tr("common.apply")
|
||||
icon: "check"
|
||||
onClicked: {
|
||||
if (settingsLoader.item && settingsLoader.item.saveSettings) {
|
||||
settingsLoader.item.saveSettings();
|
||||
if (root.showToastOnSave) {
|
||||
ToastService.showNotice(I18n.tr("panels.plugins.title"), I18n.tr("panels.plugins.settings-saved"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClosed: {
|
||||
// Clear both source and sourceComponent to ensure full cleanup
|
||||
settingsLoader.sourceComponent = null;
|
||||
settingsLoader.source = "";
|
||||
currentPlugin = null;
|
||||
currentPluginApi = null;
|
||||
}
|
||||
|
||||
function openPluginSettings(pluginManifest, settingsEntryPoint) {
|
||||
currentPlugin = pluginManifest;
|
||||
|
||||
// Use composite key if available (for custom plugins), otherwise use manifest ID (for official plugins)
|
||||
var pluginId = pluginManifest.compositeKey || pluginManifest.id;
|
||||
|
||||
currentPluginApi = PluginService.getPluginAPI(pluginId);
|
||||
if (!currentPluginApi) {
|
||||
Logger.e("NPluginSettingsPopup", "Cannot open settings: plugin not loaded:", pluginId);
|
||||
if (showToastOnSave) {
|
||||
ToastService.showError(I18n.tr("panels.plugins.title"), I18n.tr("panels.plugins.settings-error-not-loaded"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get plugin directory
|
||||
var pluginDir = PluginRegistry.getPluginDir(pluginId);
|
||||
var settingsEntry = settingsEntryPoint ? pluginManifest.entryPoints[settingsEntryPoint] : pluginManifest.entryPoints.settings;
|
||||
var settingsPath = pluginDir + "/" + settingsEntry;
|
||||
|
||||
settingsLoader.setSource("file://" + settingsPath, {
|
||||
"pluginApi": currentPluginApi
|
||||
});
|
||||
|
||||
var preferred = (settingsLoader.item && settingsLoader.item.preferredWidth !== undefined) ? settingsLoader.item.preferredWidth + padding * 2 : 0;
|
||||
width = Math.max(preferred, _minWidth);
|
||||
open();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
|
||||
// Simple context menu PopupWindow (similar to TrayMenu)
|
||||
// Designed to be rendered inside a PopupMenuWindow for click-outside-to-close
|
||||
// Automatically positions itself to respect screen boundaries
|
||||
PopupWindow {
|
||||
id: root
|
||||
|
||||
property alias model: repeater.model
|
||||
property real itemHeight: 28 // Match TrayMenu
|
||||
property real itemPadding: Style.marginM
|
||||
property int verticalPolicy: ScrollBar.AsNeeded
|
||||
property int horizontalPolicy: ScrollBar.AsNeeded
|
||||
|
||||
property var anchorItem: null
|
||||
property ShellScreen screen: null
|
||||
property real minWidth: 120
|
||||
property real calculatedWidth: 180
|
||||
|
||||
// Explicit offset for centering on target item (computed from targetItem in openAtItem)
|
||||
property real targetOffsetX: 0
|
||||
property real targetOffsetY: 0
|
||||
property real targetWidth: 0
|
||||
property real targetHeight: 0
|
||||
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screen?.name)
|
||||
readonly property real barHeight: Style.getBarHeightForScreen(screen?.name)
|
||||
|
||||
signal triggered(string action, var item)
|
||||
|
||||
implicitWidth: calculatedWidth
|
||||
implicitHeight: Math.min(600, flickable.contentHeight + Style.margin2S)
|
||||
visible: false
|
||||
color: "transparent"
|
||||
|
||||
NText {
|
||||
id: textMeasure
|
||||
visible: false
|
||||
pointSize: Style.fontSizeS
|
||||
wrapMode: Text.NoWrap
|
||||
elide: Text.ElideNone
|
||||
width: undefined
|
||||
}
|
||||
|
||||
NIcon {
|
||||
id: iconMeasure
|
||||
visible: false
|
||||
icon: "bell"
|
||||
pointSize: Style.fontSizeS
|
||||
applyUiScale: false
|
||||
}
|
||||
|
||||
onModelChanged: {
|
||||
Qt.callLater(calculateWidth);
|
||||
}
|
||||
|
||||
function calculateWidth() {
|
||||
let maxWidth = 0;
|
||||
if (model && model.length) {
|
||||
for (let i = 0; i < model.length; i++) {
|
||||
const item = model[i];
|
||||
if (item && item.visible !== false) {
|
||||
const label = item.label || item.text || "";
|
||||
textMeasure.text = label;
|
||||
textMeasure.forceLayout();
|
||||
|
||||
let itemWidth = textMeasure.contentWidth + 8;
|
||||
|
||||
if (item.icon !== undefined) {
|
||||
itemWidth += iconMeasure.width + Style.marginS;
|
||||
}
|
||||
|
||||
itemWidth += Style.margin2M;
|
||||
|
||||
if (itemWidth > maxWidth) {
|
||||
maxWidth = itemWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
calculatedWidth = Math.max(maxWidth + Style.margin2S, minWidth);
|
||||
}
|
||||
|
||||
anchor.item: anchorItem
|
||||
|
||||
anchor.rect.x: {
|
||||
if (anchorItem && screen) {
|
||||
const anchorGlobalPos = anchorItem.mapToItem(null, 0, 0);
|
||||
|
||||
// Use stored targetOffsetX and targetWidth for positioning
|
||||
const effectiveWidth = targetWidth > 0 ? targetWidth : anchorItem.width;
|
||||
const targetGlobalX = anchorGlobalPos.x + targetOffsetX;
|
||||
|
||||
// For right bar: position menu to the left of target
|
||||
if (root.barPosition === "right") {
|
||||
let baseX = targetOffsetX - implicitWidth - Style.marginM;
|
||||
return baseX;
|
||||
}
|
||||
|
||||
// For left bar: position menu to the right of target
|
||||
if (root.barPosition === "left") {
|
||||
let baseX = targetOffsetX + effectiveWidth + Style.marginM;
|
||||
return baseX;
|
||||
}
|
||||
|
||||
// For top/bottom bar: center horizontally on target
|
||||
const targetCenterScreenX = targetGlobalX + (effectiveWidth / 2);
|
||||
const menuScreenX = targetCenterScreenX - (implicitWidth / 2);
|
||||
let baseX = menuScreenX - anchorGlobalPos.x;
|
||||
|
||||
const menuRight = menuScreenX + implicitWidth;
|
||||
|
||||
// Adjust if menu would clip on the right
|
||||
if (menuRight > screen.width - Style.marginM) {
|
||||
const overflow = menuRight - (screen.width - Style.marginM);
|
||||
return baseX - overflow;
|
||||
}
|
||||
// Adjust if menu would clip on the left
|
||||
if (menuScreenX < Style.marginM) {
|
||||
return baseX + (Style.marginM - menuScreenX);
|
||||
}
|
||||
return baseX;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
anchor.rect.y: {
|
||||
if (anchorItem && screen) {
|
||||
// Check if using absolute positioning (small anchor point item)
|
||||
const isAbsolutePosition = anchorItem.width <= 1 && anchorItem.height <= 1;
|
||||
|
||||
if (isAbsolutePosition) {
|
||||
// For absolute positioning, show menu directly at anchor Y
|
||||
// Only adjust if menu would clip at bottom
|
||||
const anchorGlobalPos = anchorItem.mapToItem(null, 0, 0);
|
||||
const menuBottom = anchorGlobalPos.y + implicitHeight;
|
||||
|
||||
if (menuBottom > screen.height - Style.marginM) {
|
||||
// Position above the click point instead
|
||||
return -implicitHeight;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const anchorGlobalPos = anchorItem.mapToItem(null, 0, 0);
|
||||
|
||||
// Use target offset/height for vertical bars, anchor dimensions otherwise
|
||||
const effectiveHeight = targetHeight > 0 ? targetHeight : anchorItem.height;
|
||||
const effectiveOffsetY = targetOffsetY;
|
||||
|
||||
// Calculate base Y position based on bar orientation
|
||||
let baseY;
|
||||
if (root.barPosition === "bottom") {
|
||||
// For bottom bar: position menu above the bar
|
||||
baseY = -(implicitHeight + Style.marginS);
|
||||
} else if (root.barPosition === "top") {
|
||||
// For top bar: position menu below bar at consistent height
|
||||
// Compensate for anchor's Y position to ensure menu top is always at (barHeight + margin)
|
||||
baseY = barHeight + Style.marginS - anchorGlobalPos.y;
|
||||
} else {
|
||||
// For left/right bar: vertically center on target item
|
||||
const targetCenterY = effectiveOffsetY + (effectiveHeight / 2);
|
||||
baseY = targetCenterY - (implicitHeight / 2);
|
||||
}
|
||||
|
||||
const menuScreenY = anchorGlobalPos.y + baseY;
|
||||
const menuBottom = menuScreenY + implicitHeight;
|
||||
|
||||
// Define clipping boundaries based on bar position
|
||||
const topLimit = Style.marginM;
|
||||
const bottomLimit = root.barPosition === "bottom" ? screen.height - barHeight - Style.marginS : screen.height - Style.marginM;
|
||||
|
||||
// Adjust if menu would clip at top (skip for bottom bar - don't push menu down over bar)
|
||||
if (menuScreenY < topLimit && root.barPosition !== "bottom") {
|
||||
const adjustment = topLimit - menuScreenY;
|
||||
return baseY + adjustment;
|
||||
}
|
||||
|
||||
// Adjust if menu would clip at bottom (or overlap bar for bottom bar)
|
||||
if (menuBottom > bottomLimit) {
|
||||
const overflow = menuBottom - bottomLimit;
|
||||
return baseY - overflow;
|
||||
}
|
||||
|
||||
return baseY;
|
||||
}
|
||||
|
||||
// Fallback if no screen
|
||||
if (root.barPosition === "bottom") {
|
||||
return -implicitHeight - Style.marginS;
|
||||
}
|
||||
return barHeight;
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
Qt.callLater(calculateWidth);
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
Keys.onEscapePressed: root.close()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: menuBackground
|
||||
anchors.fill: parent
|
||||
color: Color.mSurface
|
||||
border.color: Color.mOutline
|
||||
border.width: Style.borderS
|
||||
radius: Style.radiusM
|
||||
opacity: root.visible ? 1.0 : 0.0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: flickable
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginS
|
||||
contentHeight: columnLayout.implicitHeight
|
||||
interactive: true
|
||||
opacity: root.visible ? 1.0 : 0.0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: columnLayout
|
||||
width: flickable.width
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
id: repeater
|
||||
|
||||
delegate: Rectangle {
|
||||
id: menuItem
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
Layout.preferredWidth: parent.width
|
||||
Layout.preferredHeight: modelData.visible !== false ? root.itemHeight : 0
|
||||
visible: modelData.visible !== false
|
||||
color: "transparent"
|
||||
|
||||
Rectangle {
|
||||
id: innerRect
|
||||
anchors.fill: parent
|
||||
color: mouseArea.containsMouse ? Color.mHover : "transparent"
|
||||
radius: Style.radiusS
|
||||
opacity: modelData.enabled !== false ? 1.0 : 0.5
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Style.marginM
|
||||
anchors.rightMargin: Style.marginM
|
||||
spacing: Style.marginS
|
||||
|
||||
NIcon {
|
||||
visible: modelData.icon !== undefined
|
||||
icon: modelData.icon || ""
|
||||
pointSize: Style.fontSizeS
|
||||
applyUiScale: false
|
||||
color: mouseArea.containsMouse ? Color.mOnHover : Color.mOnSurface
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
text: modelData.label || modelData.text || ""
|
||||
pointSize: Style.fontSizeS
|
||||
color: mouseArea.containsMouse ? Color.mOnHover : Color.mOnSurface
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
Layout.fillWidth: true
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: (modelData.enabled !== false) && root.visible
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
|
||||
onClicked: {
|
||||
if (menuItem.modelData.enabled !== false) {
|
||||
root.triggered(menuItem.modelData.action || menuItem.modelData.key || menuItem.index.toString(), menuItem.modelData);
|
||||
// Don't call root.close() here - let the parent PopupMenuWindow handle closing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to open context menu anchored to an item
|
||||
// Position is calculated automatically based on bar position and screen boundaries
|
||||
// Optional centerOnItem: if provided, menu will be horizontally centered on this item instead of anchorItem
|
||||
function openAtItem(item, itemScreen, centerOnItem) {
|
||||
if (!item) {
|
||||
Logger.w("NPopupContextMenu", "anchorItem is undefined, won't show menu.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set anchor and screen first
|
||||
anchorItem = item;
|
||||
screen = itemScreen || null;
|
||||
|
||||
// Compute target offset and dimensions from centerOnItem
|
||||
if (centerOnItem && centerOnItem !== item) {
|
||||
const relPos = centerOnItem.mapToItem(item, 0, 0);
|
||||
targetOffsetX = relPos.x;
|
||||
targetOffsetY = relPos.y;
|
||||
targetWidth = centerOnItem.width;
|
||||
targetHeight = centerOnItem.height;
|
||||
} else {
|
||||
targetOffsetX = 0;
|
||||
targetOffsetY = 0;
|
||||
targetWidth = 0;
|
||||
targetHeight = 0;
|
||||
}
|
||||
|
||||
// Calculate menu width after anchor is set
|
||||
calculateWidth();
|
||||
|
||||
visible = true;
|
||||
|
||||
// Force anchor recalculation after showing
|
||||
Qt.callLater(() => {
|
||||
anchor.updateAnchor();
|
||||
});
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible = false;
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
RadioButton {
|
||||
id: root
|
||||
|
||||
property real pointSize: Style.fontSizeM
|
||||
|
||||
implicitWidth: outerCircle.implicitWidth + Style.marginS + contentItem.implicitWidth
|
||||
|
||||
indicator: Rectangle {
|
||||
id: outerCircle
|
||||
|
||||
implicitWidth: Style.baseWidgetSize * 0.625 * pointSize / Style.fontSizeM
|
||||
implicitHeight: Style.baseWidgetSize * 0.625 * pointSize / Style.fontSizeM
|
||||
radius: Math.min(Style.iRadiusL, width / 2)
|
||||
color: "transparent"
|
||||
border.color: root.checked ? Color.mPrimary : Color.mOnSurface
|
||||
border.width: Style.borderM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: parent.width * 0.3
|
||||
|
||||
radius: Math.min(Style.iRadiusL, width / 2)
|
||||
color: Qt.alpha(Color.mPrimary, root.checked ? 1 : 0)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: NText {
|
||||
text: root.text
|
||||
pointSize: root.pointSize
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: outerCircle.right
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: Style.marginS
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// Public API
|
||||
property var model: []
|
||||
property var disabledIds: []
|
||||
property color activeColor: Color.mPrimary
|
||||
property color activeOnColor: Color.mOnPrimary
|
||||
property color dragHandleColor: Color.mOutline
|
||||
property int baseSize: Style.baseWidgetSize * 0.7
|
||||
property int spacing: Style.marginM
|
||||
|
||||
signal itemToggled(int index, bool enabled)
|
||||
signal itemsReordered(int fromIndex, int toIndex)
|
||||
signal dragPotentialStarted
|
||||
signal dragPotentialEnded
|
||||
|
||||
readonly property real itemHeight: root.baseSize
|
||||
readonly property real contentHeight: root.model.length > 0 ? root.model.length * itemHeight + (root.model.length - 1) * root.spacing : 0
|
||||
implicitHeight: contentHeight
|
||||
|
||||
function toggleItem(index) {
|
||||
if (index < 0 || index >= root.model.length)
|
||||
return;
|
||||
var item = root.model[index];
|
||||
if (item.required)
|
||||
return;
|
||||
|
||||
// Create a new array to trigger binding update
|
||||
var newModel = root.model.slice();
|
||||
newModel[index] = Object.assign({}, item, {
|
||||
"enabled": !item.enabled
|
||||
});
|
||||
root.model = newModel;
|
||||
|
||||
root.itemToggled(index, newModel[index].enabled);
|
||||
}
|
||||
|
||||
function moveItem(fromIndex, toIndex) {
|
||||
if (fromIndex === toIndex)
|
||||
return;
|
||||
if (fromIndex < 0 || fromIndex >= root.model.length)
|
||||
return;
|
||||
if (toIndex < 0 || toIndex >= root.model.length)
|
||||
return;
|
||||
|
||||
// Create a new array with item moved
|
||||
var newModel = root.model.slice();
|
||||
var item = newModel.splice(fromIndex, 1)[0];
|
||||
newModel.splice(toIndex, 0, item);
|
||||
root.model = newModel;
|
||||
|
||||
root.itemsReordered(fromIndex, toIndex);
|
||||
}
|
||||
|
||||
Item {
|
||||
id: itemsContainer
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
|
||||
Repeater {
|
||||
id: repeater
|
||||
model: root.model
|
||||
|
||||
delegate: Item {
|
||||
id: delegateItem
|
||||
|
||||
width: itemsContainer.width
|
||||
height: checkboxRow.height
|
||||
|
||||
required property int index
|
||||
required property var modelData
|
||||
|
||||
property string text: modelData.text || ""
|
||||
property bool itemEnabled: modelData.enabled || false
|
||||
property bool required: modelData.required || false
|
||||
readonly property bool isDisabled: (root.disabledIds || []).indexOf(modelData.id) !== -1
|
||||
readonly property bool canDrag: !delegateItem.isDisabled
|
||||
property bool dragging: false
|
||||
property int dragStartY: 0
|
||||
property int dragStartIndex: -1
|
||||
property int dragTargetIndex: -1
|
||||
property int itemSpacing: root.spacing
|
||||
|
||||
RowLayout {
|
||||
id: checkboxRow
|
||||
|
||||
width: parent.width
|
||||
spacing: Style.marginS
|
||||
opacity: isDisabled ? 0.5 : 1.0
|
||||
|
||||
// Drag handle
|
||||
Rectangle {
|
||||
id: dragHandle
|
||||
|
||||
Layout.preferredWidth: root.baseSize
|
||||
Layout.preferredHeight: root.baseSize
|
||||
radius: Style.iRadiusXS
|
||||
color: dragHandleMouseArea.containsMouse ? Color.mSurfaceVariant : "transparent"
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
spacing: Style.marginS
|
||||
|
||||
Repeater {
|
||||
model: 3
|
||||
Rectangle {
|
||||
Layout.preferredWidth: root.baseSize * 0.4
|
||||
Layout.preferredHeight: 2
|
||||
radius: 1
|
||||
color: root.dragHandleColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: dragHandleMouseArea
|
||||
|
||||
anchors.fill: parent
|
||||
cursorShape: delegateItem.canDrag ? Qt.SizeVerCursor : Qt.ArrowCursor
|
||||
hoverEnabled: true
|
||||
preventStealing: false
|
||||
enabled: delegateItem.canDrag
|
||||
z: 1000
|
||||
|
||||
onPressed: mouse => {
|
||||
if (!delegateItem.canDrag) {
|
||||
return;
|
||||
}
|
||||
delegateItem.dragStartIndex = delegateItem.index;
|
||||
delegateItem.dragTargetIndex = delegateItem.index;
|
||||
delegateItem.dragStartY = delegateItem.y;
|
||||
delegateItem.dragging = true;
|
||||
delegateItem.z = 999;
|
||||
|
||||
// Signal that interaction started (prevents panel close)
|
||||
preventStealing = true;
|
||||
root.dragPotentialStarted();
|
||||
}
|
||||
|
||||
onPositionChanged: mouse => {
|
||||
if (delegateItem.dragging) {
|
||||
var dy = mouse.y - dragHandle.height / 2;
|
||||
var newY = delegateItem.y + dy;
|
||||
|
||||
// Constrain within bounds
|
||||
newY = Math.max(0, Math.min(newY, root.contentHeight - delegateItem.height));
|
||||
delegateItem.y = newY;
|
||||
|
||||
// Calculate target index (but don't apply yet)
|
||||
var targetIndex = Math.floor((newY + delegateItem.height / 2) / (delegateItem.height + delegateItem.itemSpacing));
|
||||
targetIndex = Math.max(0, Math.min(targetIndex, repeater.count - 1));
|
||||
|
||||
delegateItem.dragTargetIndex = targetIndex;
|
||||
}
|
||||
}
|
||||
|
||||
onReleased: {
|
||||
// Always signal end of interaction
|
||||
preventStealing = false;
|
||||
root.dragPotentialEnded();
|
||||
|
||||
// Apply the model change now that drag is complete
|
||||
if (delegateItem.dragStartIndex !== -1 && delegateItem.dragTargetIndex !== -1 && delegateItem.dragStartIndex !== delegateItem.dragTargetIndex) {
|
||||
root.moveItem(delegateItem.dragStartIndex, delegateItem.dragTargetIndex);
|
||||
}
|
||||
|
||||
delegateItem.dragging = false;
|
||||
delegateItem.dragStartIndex = -1;
|
||||
delegateItem.dragTargetIndex = -1;
|
||||
delegateItem.z = 0;
|
||||
}
|
||||
|
||||
onCanceled: {
|
||||
// Handle cancel (e.g., ESC key pressed during drag)
|
||||
preventStealing = false;
|
||||
root.dragPotentialEnded();
|
||||
|
||||
delegateItem.dragging = false;
|
||||
delegateItem.dragStartIndex = -1;
|
||||
delegateItem.dragTargetIndex = -1;
|
||||
delegateItem.z = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Checkbox
|
||||
Rectangle {
|
||||
id: box
|
||||
|
||||
Layout.preferredWidth: root.baseSize
|
||||
Layout.preferredHeight: root.baseSize
|
||||
radius: Style.iRadiusXS
|
||||
color: delegateItem.itemEnabled ? root.activeColor : Color.mSurface
|
||||
border.color: delegateItem.required ? root.activeColor : Color.mOutline
|
||||
border.width: Style.borderS
|
||||
opacity: delegateItem.required ? 0.7 : 1.0
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
NIcon {
|
||||
visible: delegateItem.itemEnabled
|
||||
anchors.centerIn: parent
|
||||
anchors.horizontalCenterOffset: -1
|
||||
icon: "check"
|
||||
color: root.activeOnColor
|
||||
pointSize: Math.max(Style.fontSizeXS, root.baseSize * 0.5)
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: (!delegateItem.required && !delegateItem.isDisabled) ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
enabled: !delegateItem.required && !delegateItem.isDisabled
|
||||
|
||||
onClicked: {
|
||||
if (!delegateItem.required && !delegateItem.isDisabled) {
|
||||
root.toggleItem(delegateItem.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Label
|
||||
NText {
|
||||
Layout.fillWidth: true
|
||||
text: delegateItem.text
|
||||
color: Color.mOnSurface
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// Required indicator
|
||||
NText {
|
||||
visible: delegateItem.required
|
||||
text: I18n.tr("common.required")
|
||||
color: Color.mOnSurfaceVariant
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
// Position binding for non-dragging state
|
||||
y: {
|
||||
if (delegateItem.dragging) {
|
||||
return delegateItem.y;
|
||||
}
|
||||
|
||||
// Check if any item is being dragged
|
||||
var draggedIndex = -1;
|
||||
var targetIndex = -1;
|
||||
for (var i = 0; i < repeater.count; i++) {
|
||||
var item = repeater.itemAt(i);
|
||||
if (item && item.dragging) {
|
||||
draggedIndex = item.dragStartIndex;
|
||||
targetIndex = item.dragTargetIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If an item is being dragged, adjust positions
|
||||
if (draggedIndex !== -1 && targetIndex !== -1 && draggedIndex !== targetIndex) {
|
||||
var currentIndex = delegateItem.index;
|
||||
|
||||
if (draggedIndex < targetIndex) {
|
||||
// Dragging down: shift items up between draggedIndex and targetIndex
|
||||
if (currentIndex > draggedIndex && currentIndex <= targetIndex) {
|
||||
return (currentIndex - 1) * (delegateItem.height + delegateItem.itemSpacing);
|
||||
}
|
||||
} else {
|
||||
// Dragging up: shift items down between targetIndex and draggedIndex
|
||||
if (currentIndex >= targetIndex && currentIndex < draggedIndex) {
|
||||
return (currentIndex + 1) * (delegateItem.height + delegateItem.itemSpacing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return delegateItem.index * (delegateItem.height + delegateItem.itemSpacing);
|
||||
}
|
||||
|
||||
Behavior on y {
|
||||
enabled: !delegateItem.dragging
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
|
||||
/*
|
||||
NScrollText {
|
||||
NText {
|
||||
pointSize: Style.fontSizeS
|
||||
// here any NText properties can be used
|
||||
}
|
||||
maxWidth: 200
|
||||
text: "Some long long long text"
|
||||
scrollMode: NScrollText.ScrollMode.Always
|
||||
}
|
||||
*/
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property string text
|
||||
default property Component delegate: NText {
|
||||
pointSize: Style.fontSizeS
|
||||
}
|
||||
|
||||
property real maxWidth: Infinity
|
||||
|
||||
enum ScrollMode {
|
||||
Never = 0,
|
||||
Always = 1,
|
||||
Hover = 2
|
||||
}
|
||||
|
||||
property int scrollMode: NScrollText.ScrollMode.Never
|
||||
property bool alwaysMaxWidth: false
|
||||
property bool forcedHover: false
|
||||
property int cursorShape: Qt.ArrowCursor
|
||||
|
||||
property real waitBeforeScrolling: 1000
|
||||
property real scrollCycleDuration: Math.max(4000, root.text.length * 120)
|
||||
property real resettingDuration: 300
|
||||
|
||||
// Fade controls (fadeExtent: 0.0–0.5, fraction of width that fades)
|
||||
property real fadeExtent: 0.1
|
||||
property real fadeCornerRadius: 0
|
||||
property bool fadeRoundLeftCorners: true
|
||||
|
||||
readonly property real contentWidth: {
|
||||
if (!titleText.item)
|
||||
return 0;
|
||||
const implicit = titleText.item.implicitWidth;
|
||||
return implicit > 0 ? implicit : titleText.item.width;
|
||||
}
|
||||
readonly property real measuredWidth: scrollContainer.width
|
||||
|
||||
implicitWidth: alwaysMaxWidth ? maxWidth : Math.min(maxWidth, contentWidth)
|
||||
implicitHeight: titleText.height
|
||||
|
||||
layer.enabled: contentWidth > maxWidth
|
||||
layer.effect: MultiEffect {
|
||||
maskEnabled: true
|
||||
maskThresholdMin: 0.5
|
||||
maskSpreadAtMin: 1.0
|
||||
maskSource: fadeMask
|
||||
}
|
||||
|
||||
enum ScrollState {
|
||||
None = 0,
|
||||
Scrolling = 1,
|
||||
Resetting = 2
|
||||
}
|
||||
|
||||
property int state: NScrollText.ScrollState.None
|
||||
|
||||
onTextChanged: {
|
||||
if (titleText.item)
|
||||
titleText.item.text = text;
|
||||
if (loopingText.item)
|
||||
loopingText.item.text = text;
|
||||
|
||||
// reset state
|
||||
resetState();
|
||||
}
|
||||
onMaxWidthChanged: resetState()
|
||||
onContentWidthChanged: root.updateState()
|
||||
onForcedHoverChanged: updateState()
|
||||
|
||||
function resetState() {
|
||||
root.state = NScrollText.ScrollState.None;
|
||||
scrollContainer.x = 0;
|
||||
scrollTimer.restart();
|
||||
root.updateState();
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: scrollTimer
|
||||
interval: root.waitBeforeScrolling
|
||||
onTriggered: {
|
||||
root.state = NScrollText.ScrollState.Scrolling;
|
||||
root.updateState();
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: hoverArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
onEntered: root.updateState()
|
||||
onExited: root.updateState()
|
||||
cursorShape: root.cursorShape
|
||||
}
|
||||
|
||||
function ensureReset() {
|
||||
if (state === NScrollText.ScrollState.Scrolling)
|
||||
state = NScrollText.ScrollState.Resetting;
|
||||
}
|
||||
|
||||
function updateState() {
|
||||
if (contentWidth <= root.maxWidth || scrollMode === NScrollText.ScrollMode.Never) {
|
||||
state = NScrollText.ScrollState.None;
|
||||
return;
|
||||
}
|
||||
if (scrollMode === NScrollText.ScrollMode.Always) {
|
||||
if (hoverArea.containsMouse) {
|
||||
ensureReset();
|
||||
} else {
|
||||
scrollTimer.restart();
|
||||
}
|
||||
} else if (scrollMode === NScrollText.ScrollMode.Hover) {
|
||||
if (hoverArea.containsMouse || forcedHover)
|
||||
state = NScrollText.ScrollState.Scrolling;
|
||||
else
|
||||
ensureReset();
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: scrollContainer
|
||||
height: parent.height
|
||||
x: 0
|
||||
spacing: 50
|
||||
|
||||
Loader {
|
||||
id: titleText
|
||||
sourceComponent: root.delegate
|
||||
Layout.fillHeight: true
|
||||
onLoaded: {
|
||||
this.item.text = root.text;
|
||||
// Bind height to container to enable vertical centering of overly high text
|
||||
this.item.height = Qt.binding(() => titleText.height);
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: loopingText
|
||||
sourceComponent: root.delegate
|
||||
Layout.fillHeight: true
|
||||
visible: root.state !== NScrollText.ScrollState.None
|
||||
onLoaded: {
|
||||
this.item.text = root.text;
|
||||
this.item.height = Qt.binding(() => loopingText.height);
|
||||
}
|
||||
}
|
||||
|
||||
NumberAnimation on x {
|
||||
running: root.state === NScrollText.ScrollState.Resetting
|
||||
to: 0
|
||||
duration: root.resettingDuration
|
||||
easing.type: Easing.OutQuad
|
||||
onFinished: {
|
||||
root.state = NScrollText.ScrollState.None;
|
||||
root.updateState();
|
||||
}
|
||||
}
|
||||
|
||||
NumberAnimation on x {
|
||||
running: root.state === NScrollText.ScrollState.Scrolling
|
||||
to: -(titleText.width + scrollContainer.spacing)
|
||||
duration: root.scrollCycleDuration
|
||||
loops: Animation.Infinite
|
||||
easing.type: Easing.Linear
|
||||
}
|
||||
}
|
||||
|
||||
// Transparency Fade Rectangle
|
||||
Rectangle {
|
||||
id: fadeMask
|
||||
width: root.width
|
||||
height: root.height
|
||||
topLeftRadius: fadeRoundLeftCorners ? fadeCornerRadius : 0
|
||||
bottomLeftRadius: fadeRoundLeftCorners ? fadeCornerRadius : 0
|
||||
topRightRadius: fadeCornerRadius
|
||||
bottomRightRadius: fadeCornerRadius
|
||||
gradient: Gradient {
|
||||
GradientStop {
|
||||
position: 0.0
|
||||
color: "transparent"
|
||||
}
|
||||
GradientStop {
|
||||
position: fadeExtent
|
||||
color: "white"
|
||||
}
|
||||
GradientStop {
|
||||
position: 1 - fadeExtent
|
||||
color: "white"
|
||||
}
|
||||
GradientStop {
|
||||
position: 1.0
|
||||
color: "transparent"
|
||||
}
|
||||
orientation: Gradient.Horizontal
|
||||
}
|
||||
layer.enabled: true
|
||||
layer.smooth: true
|
||||
opacity: 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Templates as T
|
||||
import qs.Commons
|
||||
|
||||
ScrollView {
|
||||
id: root
|
||||
|
||||
property color handleColor: Qt.alpha(Color.mHover, 0.8)
|
||||
property color handleHoverColor: handleColor
|
||||
property color handlePressedColor: handleColor
|
||||
property color trackColor: "transparent"
|
||||
property real handleWidth: Math.round(6 * Style.uiScaleRatio)
|
||||
property real handleRadius: Style.iRadiusM
|
||||
property int verticalPolicy: ScrollBar.AsNeeded
|
||||
property int horizontalPolicy: ScrollBar.AsNeeded
|
||||
property bool preventHorizontalScroll: horizontalPolicy === ScrollBar.AlwaysOff
|
||||
property int boundsBehavior: Flickable.StopAtBounds
|
||||
readonly property bool verticalScrollable: (contentItem.contentHeight > contentItem.height) || (verticalPolicy == ScrollBar.AlwaysOn)
|
||||
readonly property bool horizontalScrollable: (contentItem.contentWidth > contentItem.width) || (horizontalPolicy == ScrollBar.AlwaysOn)
|
||||
property bool showGradientMasks: true
|
||||
property color gradientColor: Color.mSurfaceVariant
|
||||
property int gradientHeight: 16
|
||||
property bool reserveScrollbarSpace: true
|
||||
property real userRightPadding: 0
|
||||
// Keep scrollbars visible whenever overflow exists (without forcing visibility when not scrollable)
|
||||
property bool showScrollbarWhenScrollable: Settings.data.ui.scrollbarAlwaysVisible
|
||||
|
||||
// Scroll speed multiplier for mouse wheel (1.0 = default, higher = faster)
|
||||
property real wheelScrollMultiplier: 2.0
|
||||
property int smoothWheelAnimationDuration: Style.animationNormal
|
||||
property real _wheelTargetY: 0
|
||||
|
||||
function clampScrollY(value) {
|
||||
if (!root._internalFlickable)
|
||||
return 0;
|
||||
const flickable = root._internalFlickable;
|
||||
return Math.max(0, Math.min(value, flickable.contentHeight - flickable.height));
|
||||
}
|
||||
|
||||
function applyWheelScroll(delta) {
|
||||
if (!root._internalFlickable)
|
||||
return;
|
||||
|
||||
const flickable = root._internalFlickable;
|
||||
const step = delta * root.wheelScrollMultiplier;
|
||||
|
||||
if (!Settings.data.general.smoothScrollEnabled || Settings.data.general.animationDisabled) {
|
||||
flickable.contentY = root.clampScrollY(flickable.contentY - step);
|
||||
root._wheelTargetY = flickable.contentY;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wheelScrollAnimation.running)
|
||||
root._wheelTargetY = flickable.contentY;
|
||||
|
||||
root._wheelTargetY = root.clampScrollY(root._wheelTargetY - step);
|
||||
wheelScrollAnimation.to = root._wheelTargetY;
|
||||
wheelScrollAnimation.restart();
|
||||
}
|
||||
|
||||
rightPadding: userRightPadding + (reserveScrollbarSpace && verticalScrollable ? handleWidth + Style.marginXS : 0)
|
||||
|
||||
implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, contentWidth + leftPadding + rightPadding)
|
||||
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, contentHeight + topPadding + bottomPadding)
|
||||
|
||||
// Configure the internal flickable when it becomes available
|
||||
Component.onCompleted: {
|
||||
configureFlickable();
|
||||
createGradients();
|
||||
}
|
||||
|
||||
// Dynamically create gradient overlays to avoid interfering with ScrollView content management
|
||||
function createGradients() {
|
||||
if (!showGradientMasks)
|
||||
return;
|
||||
|
||||
Qt.createQmlObject(`
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
Rectangle {
|
||||
x: root.leftPadding
|
||||
y: root.topPadding
|
||||
width: root.availableWidth
|
||||
height: root.gradientHeight
|
||||
z: 1
|
||||
visible: root.showGradientMasks && root.verticalScrollable
|
||||
opacity: root.contentItem.contentY <= 1 ? 0 : 1
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad }
|
||||
}
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: root.gradientColor }
|
||||
GradientStop { position: 1.0; color: "transparent" }
|
||||
}
|
||||
}
|
||||
`, root, "topGradient");
|
||||
|
||||
Qt.createQmlObject(`
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
Rectangle {
|
||||
x: root.leftPadding
|
||||
y: root.height - root.bottomPadding - height + 1
|
||||
width: root.availableWidth
|
||||
height: root.gradientHeight + 1
|
||||
z: 1
|
||||
visible: root.showGradientMasks && root.verticalScrollable
|
||||
opacity: (root.contentItem.contentY + root.contentItem.height >= root.contentItem.contentHeight - 1) ? 0 : 1
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad }
|
||||
}
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: "transparent" }
|
||||
GradientStop { position: 1.0; color: root.gradientColor }
|
||||
}
|
||||
}
|
||||
`, root, "bottomGradient");
|
||||
}
|
||||
|
||||
// Reference to the internal Flickable for wheel handling
|
||||
property Flickable _internalFlickable: null
|
||||
|
||||
NumberAnimation {
|
||||
id: wheelScrollAnimation
|
||||
target: root._internalFlickable
|
||||
property: "contentY"
|
||||
duration: root.smoothWheelAnimationDuration
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root._internalFlickable
|
||||
|
||||
function onDraggingChanged() {
|
||||
if (!root._internalFlickable || !root._internalFlickable.dragging)
|
||||
return;
|
||||
wheelScrollAnimation.stop();
|
||||
root._wheelTargetY = root._internalFlickable.contentY;
|
||||
}
|
||||
|
||||
function onFlickingChanged() {
|
||||
if (!root._internalFlickable || !root._internalFlickable.flicking)
|
||||
return;
|
||||
wheelScrollAnimation.stop();
|
||||
root._wheelTargetY = root._internalFlickable.contentY;
|
||||
}
|
||||
|
||||
function onContentHeightChanged() {
|
||||
root._wheelTargetY = root.clampScrollY(root._wheelTargetY);
|
||||
}
|
||||
|
||||
function onHeightChanged() {
|
||||
root._wheelTargetY = root.clampScrollY(root._wheelTargetY);
|
||||
}
|
||||
}
|
||||
|
||||
// Function to configure the underlying Flickable
|
||||
function configureFlickable() {
|
||||
// Find the internal Flickable (it's usually the first child)
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
var child = children[i];
|
||||
if (child.toString().indexOf("Flickable") !== -1) {
|
||||
// Configure the flickable to prevent horizontal scrolling
|
||||
child.boundsBehavior = root.boundsBehavior;
|
||||
root._internalFlickable = child;
|
||||
|
||||
if (root.preventHorizontalScroll) {
|
||||
child.flickableDirection = Flickable.VerticalFlick;
|
||||
child.contentWidth = Qt.binding(() => child.width);
|
||||
}
|
||||
|
||||
root._wheelTargetY = child.contentY;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WheelHandler {
|
||||
enabled: root.wheelScrollMultiplier !== 1.0 && root._internalFlickable !== null
|
||||
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||
onWheel: event => {
|
||||
const delta = event.pixelDelta.y !== 0 ? event.pixelDelta.y : event.angleDelta.y / 2;
|
||||
root.applyWheelScroll(delta);
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes in horizontalPolicy
|
||||
onHorizontalPolicyChanged: {
|
||||
preventHorizontalScroll = (horizontalPolicy === ScrollBar.AlwaysOff);
|
||||
configureFlickable();
|
||||
}
|
||||
|
||||
ScrollBar.vertical: ScrollBar {
|
||||
parent: root
|
||||
x: root.mirrored ? 0 : root.width - width
|
||||
y: root.topPadding
|
||||
height: root.availableHeight
|
||||
policy: root.verticalPolicy
|
||||
interactive: root.verticalScrollable
|
||||
|
||||
contentItem: Rectangle {
|
||||
implicitWidth: root.handleWidth
|
||||
implicitHeight: 100
|
||||
radius: root.handleRadius
|
||||
color: parent.pressed ? root.handlePressedColor : parent.hovered ? root.handleHoverColor : root.handleColor
|
||||
opacity: parent.policy === ScrollBar.AlwaysOn ? 1.0 : root.verticalScrollable ? ((root.showScrollbarWhenScrollable || parent.active) ? 1.0 : 0.0) : 0.0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
implicitWidth: root.handleWidth
|
||||
implicitHeight: 100
|
||||
color: root.trackColor
|
||||
opacity: parent.policy === ScrollBar.AlwaysOn ? 0.3 : root.verticalScrollable ? ((root.showScrollbarWhenScrollable || parent.active) ? 0.3 : 0.0) : 0.0
|
||||
radius: root.handleRadius / 2
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScrollBar.horizontal: ScrollBar {
|
||||
parent: root
|
||||
x: root.leftPadding
|
||||
y: root.height - height
|
||||
width: root.availableWidth
|
||||
policy: root.horizontalPolicy
|
||||
interactive: root.horizontalScrollable
|
||||
|
||||
contentItem: Rectangle {
|
||||
implicitWidth: 100
|
||||
implicitHeight: root.handleWidth
|
||||
radius: root.handleRadius
|
||||
color: parent.pressed ? root.handlePressedColor : parent.hovered ? root.handleHoverColor : root.handleColor
|
||||
opacity: parent.policy === ScrollBar.AlwaysOn ? 1.0 : root.horizontalScrollable ? ((root.showScrollbarWhenScrollable || parent.active) ? 1.0 : 0.0) : 0.0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
implicitWidth: 100
|
||||
implicitHeight: root.handleWidth
|
||||
color: root.trackColor
|
||||
opacity: parent.policy === ScrollBar.AlwaysOn ? 0.3 : root.horizontalScrollable ? ((root.showScrollbarWhenScrollable || parent.active) ? 0.3 : 0.0) : 0.0
|
||||
radius: root.handleRadius / 2
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
RowLayout {
|
||||
id: root
|
||||
|
||||
property real minimumWidth: 280
|
||||
property real popupHeight: 180
|
||||
|
||||
property bool selectOnNavigation: true
|
||||
property string label: ""
|
||||
property string description: ""
|
||||
property ListModel model: {}
|
||||
property string currentKey: ""
|
||||
property string placeholder: ""
|
||||
property string searchPlaceholder: I18n.tr("placeholders.search")
|
||||
property Component delegate: null
|
||||
property var defaultValue: undefined
|
||||
property string settingsPath: ""
|
||||
|
||||
readonly property real preferredHeight: Math.round(Style.baseWidgetSize * 1.1)
|
||||
|
||||
signal selected(string key)
|
||||
|
||||
spacing: Style.marginL
|
||||
Layout.fillWidth: true
|
||||
|
||||
readonly property bool isValueChanged: (defaultValue !== undefined) && (currentKey !== defaultValue)
|
||||
readonly property string indicatorTooltip: {
|
||||
if (defaultValue === undefined)
|
||||
return "";
|
||||
var displayValue = "";
|
||||
if (defaultValue === "") {
|
||||
// Try to find the display name for empty key in the model
|
||||
if (model && model.count > 0) {
|
||||
for (var i = 0; i < model.count; i++) {
|
||||
var item = model.get(i);
|
||||
if (item && item.key === "") {
|
||||
displayValue = item.name || I18n.tr("panels.indicator.system-default");
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If not found in model, show "System Default" instead of "(empty)"
|
||||
if (displayValue === "") {
|
||||
displayValue = I18n.tr("panels.indicator.system-default");
|
||||
}
|
||||
} else {
|
||||
displayValue = I18n.tr("panels.indicator.system-default");
|
||||
}
|
||||
} else {
|
||||
// Try to find the display name for the default key in the model
|
||||
if (model && model.count > 0) {
|
||||
for (var i = 0; i < model.count; i++) {
|
||||
var item = model.get(i);
|
||||
if (item && item.key === defaultValue) {
|
||||
displayValue = item.name || String(defaultValue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (displayValue === "") {
|
||||
displayValue = String(defaultValue);
|
||||
}
|
||||
} else {
|
||||
displayValue = String(defaultValue);
|
||||
}
|
||||
}
|
||||
return I18n.tr("panels.indicator.default-value", {
|
||||
"value": displayValue
|
||||
});
|
||||
}
|
||||
|
||||
// Filtered model for search results
|
||||
property ListModel filteredModel: ListModel {}
|
||||
property string searchText: ""
|
||||
|
||||
function findIndexByKey(key) {
|
||||
if (!root.model)
|
||||
return -1;
|
||||
for (var i = 0; i < root.model.count; i++) {
|
||||
if (root.model.get(i).key === key) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// The active model used for the popup list (source model or filtered results)
|
||||
readonly property var activeModel: isFiltered ? filteredModel : root.model
|
||||
|
||||
function findIndexInActiveModel(key) {
|
||||
if (!activeModel || activeModel.count === undefined)
|
||||
return -1;
|
||||
for (var i = 0; i < activeModel.count; i++) {
|
||||
if (activeModel.get(i).key === key) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Whether we're using filtered results or the source model directly
|
||||
property bool isFiltered: false
|
||||
|
||||
function filterModel() {
|
||||
// Check if model exists and has items
|
||||
if (!root.model || root.model.count === undefined || root.model.count === 0) {
|
||||
filteredModel.clear();
|
||||
isFiltered = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var query = searchText.trim();
|
||||
if (query === "") {
|
||||
// No search text - use source model directly, don't copy
|
||||
filteredModel.clear();
|
||||
isFiltered = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// We have search text - need to filter
|
||||
isFiltered = true;
|
||||
filteredModel.clear();
|
||||
|
||||
// Convert ListModel to array for fuzzy search
|
||||
var items = [];
|
||||
for (var i = 0; i < root.model.count; i++) {
|
||||
items.push(root.model.get(i));
|
||||
}
|
||||
|
||||
// Use fuzzy search if available, fallback to simple search
|
||||
if (typeof FuzzySort !== 'undefined') {
|
||||
var fuzzyResults = FuzzySort.go(query, items, {
|
||||
"key": "name",
|
||||
"limit": 50
|
||||
});
|
||||
|
||||
// Add results in order of relevance
|
||||
for (var j = 0; j < fuzzyResults.length; j++) {
|
||||
filteredModel.append(fuzzyResults[j].obj);
|
||||
}
|
||||
} else {
|
||||
// Fallback to simple search
|
||||
var searchLower = query.toLowerCase();
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var item = items[i];
|
||||
if (item.name.toLowerCase().includes(searchLower)) {
|
||||
filteredModel.append(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onSearchTextChanged: {
|
||||
filterModel();
|
||||
listView.currentIndex = 0;
|
||||
}
|
||||
|
||||
NLabel {
|
||||
label: root.label
|
||||
description: root.description
|
||||
showIndicator: root.isValueChanged
|
||||
indicatorTooltip: root.indicatorTooltip
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
ComboBox {
|
||||
id: combo
|
||||
|
||||
opacity: enabled ? 1.0 : 0.6
|
||||
Layout.margins: Style.borderS
|
||||
Layout.minimumWidth: Math.round(root.minimumWidth * Style.uiScaleRatio)
|
||||
Layout.preferredHeight: Math.round(root.preferredHeight * Style.uiScaleRatio)
|
||||
implicitWidth: Layout.minimumWidth
|
||||
model: root.activeModel
|
||||
textRole: "name"
|
||||
currentIndex: findIndexInActiveModel(currentKey)
|
||||
onActivated: {
|
||||
if (combo.currentIndex >= 0 && root.activeModel && combo.currentIndex < root.activeModel.count) {
|
||||
root.selected(root.activeModel.get(combo.currentIndex).key);
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
implicitWidth: Math.round(Style.baseWidgetSize * 3.75 * Style.uiScaleRatio)
|
||||
implicitHeight: Math.round(root.preferredHeight * Style.uiScaleRatio)
|
||||
color: Color.mSurface
|
||||
border.color: combo.activeFocus ? Color.mSecondary : Color.mOutline
|
||||
border.width: Style.borderS
|
||||
radius: Style.iRadiusM
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: NText {
|
||||
leftPadding: Style.marginL
|
||||
rightPadding: combo.indicator.width + Style.marginL
|
||||
pointSize: Style.fontSizeM
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
|
||||
// Look up current selection directly in source model by key
|
||||
readonly property int sourceIndex: root.findIndexByKey(root.currentKey)
|
||||
readonly property bool hasSelection: root.model && sourceIndex >= 0 && sourceIndex < root.model.count
|
||||
|
||||
color: hasSelection ? Color.mOnSurface : Color.mOnSurfaceVariant
|
||||
text: hasSelection ? root.model.get(sourceIndex).name : root.placeholder
|
||||
}
|
||||
|
||||
indicator: NIcon {
|
||||
x: combo.width - width - Style.marginM
|
||||
y: combo.topPadding + (combo.availableHeight - height) / 2
|
||||
icon: "caret-down"
|
||||
pointSize: Style.fontSizeL
|
||||
}
|
||||
|
||||
popup: Popup {
|
||||
y: combo.height + Style.marginS
|
||||
width: combo.width
|
||||
height: Math.round((root.popupHeight + 60) * Style.uiScaleRatio)
|
||||
padding: Style.marginM
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
spacing: Style.marginS
|
||||
|
||||
// Search input
|
||||
NTextInput {
|
||||
id: searchInput
|
||||
inputIconName: "search"
|
||||
Layout.fillWidth: true
|
||||
placeholderText: root.searchPlaceholder
|
||||
text: root.searchText
|
||||
onTextChanged: root.searchText = text
|
||||
fontSize: Style.fontSizeS
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (Keybinds.checkKey(event, 'enter', Settings)) {
|
||||
selectHighlighted();
|
||||
combo.popup.close();
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Keybinds.checkKey(event, 'escape', Settings)) {
|
||||
combo.popup.close();
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Keybinds.checkKey(event, 'up', Settings)) {
|
||||
if (listView.currentIndex > 0) {
|
||||
listView.currentIndex--;
|
||||
if (root.selectOnNavigation)
|
||||
selectHighlighted();
|
||||
}
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Keybinds.checkKey(event, 'down', Settings)) {
|
||||
if (listView.currentIndex < listView.count - 1) {
|
||||
listView.currentIndex++;
|
||||
if (root.selectOnNavigation)
|
||||
selectHighlighted();
|
||||
}
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function selectHighlighted() {
|
||||
if (listView.currentIndex >= 0 && listView.model && listView.currentIndex < listView.model.count) {
|
||||
var selectedKey = listView.model.get(listView.currentIndex).key;
|
||||
root.selected(selectedKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NListView {
|
||||
id: listView
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
// Use activeModel (source model when not filtering, filtered results when searching)
|
||||
model: combo.popup.visible ? root.activeModel : null
|
||||
horizontalPolicy: ScrollBar.AlwaysOff
|
||||
verticalPolicy: ScrollBar.AsNeeded
|
||||
|
||||
onCurrentIndexChanged: {
|
||||
if (currentIndex >= 0)
|
||||
positionViewAtIndex(currentIndex, ListView.Contain);
|
||||
}
|
||||
|
||||
delegate: root.delegate ? root.delegate : defaultDelegate
|
||||
|
||||
Component {
|
||||
id: defaultDelegate
|
||||
ItemDelegate {
|
||||
id: delegateRoot
|
||||
width: listView.availableWidth
|
||||
leftPadding: Style.marginM
|
||||
rightPadding: Style.marginM
|
||||
topPadding: Style.marginS
|
||||
bottomPadding: Style.marginS
|
||||
hoverEnabled: true
|
||||
highlighted: ListView.view.currentIndex === index
|
||||
|
||||
onHoveredChanged: {
|
||||
if (hovered) {
|
||||
ListView.view.currentIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
var selectedKey = listView.model.get(index).key;
|
||||
root.selected(selectedKey);
|
||||
combo.popup.close();
|
||||
}
|
||||
|
||||
contentItem: RowLayout {
|
||||
width: delegateRoot.width - delegateRoot.leftPadding - delegateRoot.rightPadding
|
||||
spacing: Style.marginM
|
||||
|
||||
NText {
|
||||
text: name
|
||||
pointSize: Style.fontSizeM
|
||||
color: highlighted ? Color.mOnHover : Color.mOnSurface
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
spacing: Style.marginXXS
|
||||
Layout.alignment: Qt.AlignRight
|
||||
|
||||
// Generic badge renderer
|
||||
Repeater {
|
||||
model: {
|
||||
if (typeof badges === 'undefined' || badges === null)
|
||||
return 0;
|
||||
// Handle both arrays and ListModels
|
||||
if (typeof badges.length !== 'undefined')
|
||||
return badges.length;
|
||||
if (typeof badges.count !== 'undefined')
|
||||
return badges.count;
|
||||
return 0;
|
||||
}
|
||||
|
||||
delegate: NIcon {
|
||||
required property int index
|
||||
readonly property var badgeData: {
|
||||
if (typeof badges === 'undefined' || badges === null)
|
||||
return null;
|
||||
// Handle both arrays and ListModels
|
||||
if (typeof badges.length !== 'undefined')
|
||||
return badges[index];
|
||||
if (typeof badges.get !== 'undefined')
|
||||
return badges.get(index);
|
||||
return null;
|
||||
}
|
||||
|
||||
icon: badgeData && badgeData.icon ? badgeData.icon : ""
|
||||
pointSize: {
|
||||
if (!badgeData || !badgeData.size)
|
||||
return Style.fontSizeXS;
|
||||
if (badgeData.size === "xsmall")
|
||||
return Style.fontSizeXXS;
|
||||
else if (badgeData.size === "medium")
|
||||
return Style.fontSizeM;
|
||||
else
|
||||
return Style.fontSizeXS;
|
||||
}
|
||||
color: highlighted ? Color.mOnHover : (badgeData && badgeData.color ? badgeData.color : Color.mOnSurface)
|
||||
Layout.preferredWidth: Math.round(Style.baseWidgetSize * 0.6)
|
||||
Layout.preferredHeight: Math.round(Style.baseWidgetSize * 0.6)
|
||||
visible: badgeData && badgeData.icon !== undefined && badgeData.icon !== ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
background: Rectangle {
|
||||
anchors.fill: parent
|
||||
color: highlighted ? Color.mHover : "transparent"
|
||||
radius: Style.iRadiusS
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: Color.mSurfaceVariant
|
||||
border.color: Color.mOutline
|
||||
border.width: Style.borderS
|
||||
radius: Style.iRadiusM
|
||||
}
|
||||
}
|
||||
|
||||
// Update the currentIndex if the currentKey is changed externally
|
||||
Connections {
|
||||
target: root
|
||||
function onCurrentKeyChanged() {
|
||||
combo.currentIndex = root.findIndexInActiveModel(root.currentKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Focus search input when popup opens and ensure model is filtered
|
||||
Connections {
|
||||
target: combo.popup
|
||||
function onVisibleChanged() {
|
||||
if (combo.popup.visible) {
|
||||
// Ensure the model is filtered when popup opens
|
||||
filterModel();
|
||||
listView.currentIndex = Math.max(0, root.findIndexInActiveModel(root.currentKey));
|
||||
// Small delay to ensure the popup is fully rendered
|
||||
Qt.callLater(() => {
|
||||
if (searchInput && searchInput.inputItem) {
|
||||
searchInput.inputItem.forceActiveFocus();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
root.searchText = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,957 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property bool show: false
|
||||
property var tooltipText
|
||||
|
||||
implicitWidth: root.show ? 6 * Style.uiScaleRatio : 0
|
||||
implicitHeight: root.show ? 6 * Style.uiScaleRatio : 0
|
||||
width: root.show ? 6 * Style.uiScaleRatio : 0
|
||||
height: root.show ? 6 * Style.uiScaleRatio : 0
|
||||
radius: width / 2
|
||||
color: Color.mOnSurfaceVariant
|
||||
opacity: 0.6
|
||||
|
||||
visible: root.show
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
enabled: root.show && root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
|
||||
onEntered: {
|
||||
if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
|
||||
TooltipService.show(root, root.tooltipText);
|
||||
}
|
||||
}
|
||||
|
||||
onExited: {
|
||||
if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
|
||||
TooltipService.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property Component sourceComponent
|
||||
property bool animationsEnabled: true
|
||||
property int duration: Style.animationNormal
|
||||
property real transitionGap: Style.marginXL
|
||||
property real incomingStartOpacity: 0.0
|
||||
property real outgoingTargetOpacity: 0.25
|
||||
|
||||
readonly property var item: contentLoader.item
|
||||
readonly property bool running: _running
|
||||
|
||||
property bool _running: false
|
||||
property var _pendingApplyChange: null
|
||||
property real _contentOffset: 0
|
||||
property real _contentOpacity: 1
|
||||
property real _snapshotOffset: 0
|
||||
property real _snapshotOpacity: 0
|
||||
property real _snapshotTargetOffset: 0
|
||||
|
||||
clip: true
|
||||
|
||||
function resetVisuals() {
|
||||
_running = false;
|
||||
_pendingApplyChange = null;
|
||||
_contentOffset = 0;
|
||||
_contentOpacity = 1;
|
||||
_snapshotOffset = 0;
|
||||
_snapshotOpacity = 0;
|
||||
snapshot.visible = false;
|
||||
transition.stop();
|
||||
}
|
||||
|
||||
function swap(direction, applyChange) {
|
||||
if (!animationsEnabled || width <= 0 || height <= 0 || direction === 0) {
|
||||
if (applyChange)
|
||||
applyChange();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_running)
|
||||
resetVisuals();
|
||||
|
||||
const slideDistance = Math.max(1, width + transitionGap);
|
||||
const movingForward = direction > 0;
|
||||
|
||||
snapshot.visible = true;
|
||||
_snapshotOffset = 0;
|
||||
_snapshotOpacity = 1;
|
||||
_snapshotTargetOffset = movingForward ? -slideDistance : slideDistance;
|
||||
|
||||
_contentOffset = movingForward ? slideDistance : -slideDistance;
|
||||
_contentOpacity = incomingStartOpacity;
|
||||
|
||||
_pendingApplyChange = applyChange || null;
|
||||
_running = true;
|
||||
snapshot.scheduleUpdate();
|
||||
|
||||
Qt.callLater(() => {
|
||||
if (!_running) {
|
||||
return;
|
||||
}
|
||||
|
||||
const applyFn = _pendingApplyChange;
|
||||
_pendingApplyChange = null;
|
||||
const shouldAnimate = applyFn ? applyFn() !== false : true;
|
||||
if (!shouldAnimate) {
|
||||
resetVisuals();
|
||||
return;
|
||||
}
|
||||
transition.restart();
|
||||
});
|
||||
}
|
||||
|
||||
ShaderEffectSource {
|
||||
id: snapshot
|
||||
visible: false
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
y: 0
|
||||
sourceItem: contentLoader
|
||||
hideSource: false
|
||||
live: false
|
||||
smooth: true
|
||||
z: 2
|
||||
x: root._snapshotOffset
|
||||
opacity: root._snapshotOpacity
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: contentLoader
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
x: root._contentOffset
|
||||
opacity: root._contentOpacity
|
||||
sourceComponent: root.sourceComponent
|
||||
}
|
||||
|
||||
ParallelAnimation {
|
||||
id: transition
|
||||
NumberAnimation {
|
||||
target: root
|
||||
property: "_contentOffset"
|
||||
to: 0
|
||||
duration: root.duration
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
NumberAnimation {
|
||||
target: root
|
||||
property: "_contentOpacity"
|
||||
to: 1
|
||||
duration: root.duration
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
NumberAnimation {
|
||||
target: root
|
||||
property: "_snapshotOffset"
|
||||
to: root._snapshotTargetOffset
|
||||
duration: root.duration
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
NumberAnimation {
|
||||
target: root
|
||||
property: "_snapshotOpacity"
|
||||
to: root.outgoingTargetOpacity
|
||||
duration: root.duration
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
onFinished: root.resetVisuals()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Shapes
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
|
||||
Slider {
|
||||
id: root
|
||||
|
||||
readonly property bool sliderActive: activeFocus || pressed
|
||||
property color fillColor: Color.mPrimary
|
||||
property var cutoutColor: Color.mSurface
|
||||
property bool snapAlways: true
|
||||
property real heightRatio: 0.7
|
||||
property var tooltipText
|
||||
property string tooltipDirection: "auto"
|
||||
property bool hovering: false
|
||||
|
||||
readonly property color effectiveFillColor: enabled ? fillColor : Color.mOutline
|
||||
|
||||
readonly property real knobDiameter: Math.round((Style.baseWidgetSize * heightRatio * Style.uiScaleRatio) / 2) * 2
|
||||
readonly property real trackHeight: Math.round((knobDiameter * 0.4 * Style.uiScaleRatio) / 2) * 2
|
||||
readonly property real trackRadius: Math.min(Style.iRadiusL, trackHeight / 2)
|
||||
readonly property real cutoutExtra: Math.round((Style.baseWidgetSize * 0.1 * Style.uiScaleRatio) / 2) * 2
|
||||
|
||||
padding: cutoutExtra / 2
|
||||
|
||||
snapMode: snapAlways ? Slider.SnapAlways : Slider.SnapOnRelease
|
||||
implicitHeight: Math.max(trackHeight, knobDiameter)
|
||||
|
||||
background: Item {
|
||||
id: bgContainer
|
||||
x: root.leftPadding
|
||||
y: root.topPadding + Style.pixelAlignCenter(root.availableHeight, root.trackHeight)
|
||||
implicitWidth: Style.sliderWidth
|
||||
implicitHeight: root.trackHeight
|
||||
width: root.availableWidth
|
||||
height: root.trackHeight
|
||||
|
||||
readonly property real fillWidth: root.visualPosition * width
|
||||
|
||||
// Background track
|
||||
Shape {
|
||||
anchors.fill: parent
|
||||
visible: bgContainer.width > 0 && bgContainer.height > 0
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
asynchronous: true
|
||||
|
||||
ShapePath {
|
||||
id: bgPath
|
||||
strokeColor: Qt.alpha(Color.mOutline, 0.5)
|
||||
strokeWidth: Style.borderS
|
||||
fillColor: Qt.alpha(Color.mSurface, 0.5)
|
||||
|
||||
readonly property real w: bgContainer.width
|
||||
readonly property real h: bgContainer.height
|
||||
readonly property real r: root.trackRadius
|
||||
|
||||
startX: r
|
||||
startY: 0
|
||||
|
||||
PathLine {
|
||||
x: bgPath.w - bgPath.r
|
||||
y: 0
|
||||
}
|
||||
PathArc {
|
||||
x: bgPath.w
|
||||
y: bgPath.r
|
||||
radiusX: bgPath.r
|
||||
radiusY: bgPath.r
|
||||
}
|
||||
PathLine {
|
||||
x: bgPath.w
|
||||
y: bgPath.h - bgPath.r
|
||||
}
|
||||
PathArc {
|
||||
x: bgPath.w - bgPath.r
|
||||
y: bgPath.h
|
||||
radiusX: bgPath.r
|
||||
radiusY: bgPath.r
|
||||
}
|
||||
PathLine {
|
||||
x: bgPath.r
|
||||
y: bgPath.h
|
||||
}
|
||||
PathArc {
|
||||
x: 0
|
||||
y: bgPath.h - bgPath.r
|
||||
radiusX: bgPath.r
|
||||
radiusY: bgPath.r
|
||||
}
|
||||
PathLine {
|
||||
x: 0
|
||||
y: bgPath.r
|
||||
}
|
||||
PathArc {
|
||||
x: bgPath.r
|
||||
y: 0
|
||||
radiusX: bgPath.r
|
||||
radiusY: bgPath.r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LinearGradient {
|
||||
id: fillGradient
|
||||
x1: 0
|
||||
y1: 0
|
||||
x2: root.availableWidth
|
||||
y2: 0
|
||||
GradientStop {
|
||||
position: 0.0
|
||||
color: Qt.darker(effectiveFillColor, 1.2)
|
||||
}
|
||||
GradientStop {
|
||||
position: 1.0
|
||||
color: effectiveFillColor
|
||||
}
|
||||
}
|
||||
|
||||
// Active/filled track
|
||||
Shape {
|
||||
width: bgContainer.fillWidth
|
||||
height: bgContainer.height
|
||||
visible: bgContainer.fillWidth > 0 && bgContainer.height > 0
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
asynchronous: true
|
||||
clip: true
|
||||
|
||||
ShapePath {
|
||||
id: fillPath
|
||||
strokeColor: "transparent"
|
||||
fillGradient: fillGradient
|
||||
|
||||
readonly property real fullWidth: root.availableWidth
|
||||
readonly property real h: root.trackHeight
|
||||
readonly property real r: root.trackRadius
|
||||
|
||||
startX: r
|
||||
startY: 0
|
||||
|
||||
PathLine {
|
||||
x: fillPath.fullWidth - fillPath.r
|
||||
y: 0
|
||||
}
|
||||
PathArc {
|
||||
x: fillPath.fullWidth
|
||||
y: fillPath.r
|
||||
radiusX: fillPath.r
|
||||
radiusY: fillPath.r
|
||||
}
|
||||
PathLine {
|
||||
x: fillPath.fullWidth
|
||||
y: fillPath.h - fillPath.r
|
||||
}
|
||||
PathArc {
|
||||
x: fillPath.fullWidth - fillPath.r
|
||||
y: fillPath.h
|
||||
radiusX: fillPath.r
|
||||
radiusY: fillPath.r
|
||||
}
|
||||
PathLine {
|
||||
x: fillPath.r
|
||||
y: fillPath.h
|
||||
}
|
||||
PathArc {
|
||||
x: 0
|
||||
y: fillPath.h - fillPath.r
|
||||
radiusX: fillPath.r
|
||||
radiusY: fillPath.r
|
||||
}
|
||||
PathLine {
|
||||
x: 0
|
||||
y: fillPath.r
|
||||
}
|
||||
PathArc {
|
||||
x: fillPath.r
|
||||
y: 0
|
||||
radiusX: fillPath.r
|
||||
radiusY: fillPath.r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Circular cutout
|
||||
Rectangle {
|
||||
id: knobCutout
|
||||
implicitWidth: root.knobDiameter + root.cutoutExtra
|
||||
implicitHeight: root.knobDiameter + root.cutoutExtra
|
||||
radius: Math.min(Style.iRadiusL, width / 2)
|
||||
color: root.cutoutColor !== undefined ? root.cutoutColor : Color.mSurface
|
||||
x: root.visualPosition * (root.availableWidth - root.knobDiameter) - root.cutoutExtra / 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
handle: Item {
|
||||
implicitWidth: knobDiameter
|
||||
implicitHeight: knobDiameter
|
||||
x: root.leftPadding + root.visualPosition * (root.availableWidth - width)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Rectangle {
|
||||
id: knob
|
||||
implicitWidth: knobDiameter
|
||||
implicitHeight: knobDiameter
|
||||
radius: Math.min(Style.iRadiusL, width / 2)
|
||||
color: root.pressed ? Color.mHover : Color.mSurface
|
||||
border.color: effectiveFillColor
|
||||
border.width: Style.borderL
|
||||
anchors.centerIn: parent
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
enabled: true
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton // Don't accept any mouse buttons - only hover
|
||||
propagateComposedEvents: true
|
||||
|
||||
onEntered: {
|
||||
root.hovering = true;
|
||||
if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
|
||||
TooltipService.show(knob, root.tooltipText, root.tooltipDirection);
|
||||
}
|
||||
}
|
||||
|
||||
onExited: {
|
||||
root.hovering = false;
|
||||
if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
|
||||
TooltipService.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hide tooltip when slider is pressed (anywhere on the slider)
|
||||
Connections {
|
||||
target: root
|
||||
function onPressedChanged() {
|
||||
if (root.pressed && root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
|
||||
TooltipService.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
RowLayout {
|
||||
id: root
|
||||
|
||||
// Public properties
|
||||
property int value: 0
|
||||
property int from: 0
|
||||
property int to: 100
|
||||
property int stepSize: 1
|
||||
property string suffix: ""
|
||||
property string prefix: ""
|
||||
property string label: ""
|
||||
property string description: ""
|
||||
property bool hovering: false
|
||||
property int baseSize: Style.baseWidgetSize
|
||||
property var defaultValue: undefined
|
||||
property string settingsPath: ""
|
||||
|
||||
// Convenience properties for common naming
|
||||
property alias minimum: root.from
|
||||
property alias maximum: root.to
|
||||
|
||||
// Properties for repeating
|
||||
property int initialRepeatDelay: 400 // The "pause" after the first click (in ms)
|
||||
property int repeatInterval: 80 // How often to step up after fist pause (ms)
|
||||
property int rampFactor: 4 // How many ticks to wait before increasing the step multiplier
|
||||
property int maxStepMultiplier: 10 // The max step (e.g., 10 * stepSize)
|
||||
property int _holdTicks: 0 // Internal counter for hold duration
|
||||
property int _repeatDirection: 0 // -1 for decrease, 1 for increase
|
||||
|
||||
signal entered
|
||||
signal exited
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
readonly property bool isValueChanged: (defaultValue !== undefined) && (value !== defaultValue)
|
||||
readonly property string indicatorTooltip: defaultValue !== undefined ? I18n.tr("panels.indicator.default-value", {
|
||||
"value": String(defaultValue)
|
||||
}) : ""
|
||||
|
||||
Timer {
|
||||
id: repeatTimer
|
||||
repeat: true
|
||||
interval: root.initialRepeatDelay
|
||||
|
||||
onTriggered: {
|
||||
if (repeatTimer.interval === root.initialRepeatDelay) {
|
||||
repeatTimer.interval = root.repeatInterval;
|
||||
root._holdTicks = 0;
|
||||
}
|
||||
root._holdTicks++;
|
||||
var stepMultiplier = Math.min(root.maxStepMultiplier, 1 + Math.floor(root._holdTicks / root.rampFactor));
|
||||
changeValue(root._repeatDirection, root.stepSize * stepMultiplier);
|
||||
}
|
||||
}
|
||||
|
||||
function changeValue(direction, step) {
|
||||
var currentStep = step || root.stepSize;
|
||||
|
||||
if (direction === 1 && root.value < root.to) {
|
||||
root.value = Math.min(root.to, root.value + currentStep);
|
||||
} else if (direction === -1 && root.value > root.from) {
|
||||
root.value = Math.max(root.from, root.value - currentStep);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.value === root.to || root.value === root.from) {
|
||||
stopRepeat();
|
||||
}
|
||||
}
|
||||
|
||||
function stopRepeat() {
|
||||
root._repeatDirection = 0;
|
||||
repeatTimer.stop();
|
||||
repeatTimer.interval = root.initialRepeatDelay;
|
||||
}
|
||||
|
||||
NLabel {
|
||||
label: root.label
|
||||
description: root.description
|
||||
showIndicator: root.isValueChanged
|
||||
indicatorTooltip: root.indicatorTooltip
|
||||
}
|
||||
|
||||
// Main spinbox container
|
||||
Rectangle {
|
||||
id: spinBoxContainer
|
||||
Layout.margins: Style.borderS
|
||||
implicitWidth: 120
|
||||
implicitHeight: Math.round((root.baseSize - 4) / 2) * 2
|
||||
radius: Style.iRadiusS
|
||||
color: Color.mSurfaceVariant
|
||||
border.color: (root.hovering || decreaseArea.containsMouse || increaseArea.containsMouse) ? Color.mHover : Color.mOutline
|
||||
border.width: Style.borderS
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse area for hover and scroll
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.NoButton
|
||||
hoverEnabled: true
|
||||
onEntered: {
|
||||
if (!root.enabled)
|
||||
return;
|
||||
root.hovering = true;
|
||||
root.entered();
|
||||
}
|
||||
onExited: {
|
||||
root.hovering = false;
|
||||
root.exited();
|
||||
}
|
||||
onWheel: wheel => {
|
||||
if (wheel.angleDelta.y > 0 && root.value < root.to) {
|
||||
let newValue = Math.min(root.to, root.value + root.stepSize);
|
||||
root.value = newValue;
|
||||
} else if (wheel.angleDelta.y < 0 && root.value > root.from) {
|
||||
let newValue = Math.max(root.from, root.value - root.stepSize);
|
||||
root.value = newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decrease button (left)
|
||||
Item {
|
||||
id: decreaseButton
|
||||
height: parent.height
|
||||
width: height
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
opacity: (root.enabled && root.value > root.from) || decreaseArea.containsMouse ? 1.0 : 0.3
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: parent.height
|
||||
height: width
|
||||
radius: spinBoxContainer.radius
|
||||
color: Color.mHover
|
||||
opacity: decreaseArea.containsMouse ? 1.0 : 0.0
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NIcon {
|
||||
anchors.centerIn: parent
|
||||
icon: "chevron-left"
|
||||
pointSize: Style.fontSizeS
|
||||
color: decreaseArea.containsMouse ? Color.mOnHover : Color.mPrimary
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: decreaseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
enabled: root.enabled && root.value > root.from
|
||||
onPressed: {
|
||||
root._repeatDirection = -1;
|
||||
changeValue(root._repeatDirection, root.stepSize);
|
||||
repeatTimer.start();
|
||||
}
|
||||
onReleased: stopRepeat()
|
||||
onExited: stopRepeat()
|
||||
}
|
||||
}
|
||||
|
||||
// Increase button (right)
|
||||
Item {
|
||||
id: increaseButton
|
||||
height: parent.height
|
||||
width: height
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.right: parent.right
|
||||
opacity: (root.enabled && root.value < root.to) || increaseArea.containsMouse ? 1.0 : 0.3
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: parent.height
|
||||
height: width
|
||||
radius: spinBoxContainer.radius
|
||||
color: Color.mHover
|
||||
opacity: increaseArea.containsMouse ? 1.0 : 0.0
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NIcon {
|
||||
anchors.centerIn: parent
|
||||
icon: "chevron-right"
|
||||
pointSize: Style.fontSizeS
|
||||
color: increaseArea.containsMouse ? Color.mOnHover : Color.mPrimary
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: increaseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
enabled: root.enabled && root.value < root.to
|
||||
onPressed: {
|
||||
root._repeatDirection = 1;
|
||||
changeValue(root._repeatDirection, root.stepSize);
|
||||
repeatTimer.start();
|
||||
}
|
||||
onReleased: stopRepeat()
|
||||
onExited: stopRepeat()
|
||||
}
|
||||
}
|
||||
|
||||
// Center value display with separate prefix, value, and suffix
|
||||
Item {
|
||||
id: valueContainer
|
||||
anchors.left: decreaseButton.right
|
||||
anchors.right: increaseButton.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.margins: 4
|
||||
height: parent.height
|
||||
|
||||
RowLayout {
|
||||
anchors.centerIn: parent
|
||||
spacing: 0
|
||||
|
||||
// Prefix text (non-editable)
|
||||
NText {
|
||||
text: root.prefix
|
||||
family: Settings.data.ui.fontFixed
|
||||
pointSize: Style.fontSizeM
|
||||
font.weight: Style.fontWeightMedium
|
||||
color: Qt.alpha(Color.mOnSurface, root.enabled ? 1.0 : 0.6)
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
visible: root.prefix !== ""
|
||||
}
|
||||
|
||||
// Editable number input
|
||||
TextInput {
|
||||
id: valueInput
|
||||
text: valueInput.focus ? valueInput.text : root.value.toString()
|
||||
font.family: Settings.data.ui.fontFixed
|
||||
font.pointSize: Style.fontSizeM
|
||||
font.weight: Style.fontWeightMedium
|
||||
color: Qt.alpha(Color.mOnSurface, root.enabled ? 1.0 : 0.6)
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
selectByMouse: true
|
||||
enabled: root.enabled
|
||||
|
||||
// Only allow numeric input within range
|
||||
validator: IntValidator {
|
||||
bottom: root.from
|
||||
top: root.to
|
||||
}
|
||||
|
||||
onAccepted: {
|
||||
applyValue();
|
||||
focus = false;
|
||||
}
|
||||
|
||||
Keys.onEscapePressed: {
|
||||
text = root.value.toString();
|
||||
focus = false;
|
||||
}
|
||||
|
||||
onFocusChanged: {
|
||||
if (focus) {
|
||||
selectAll();
|
||||
} else {
|
||||
applyValue();
|
||||
}
|
||||
}
|
||||
|
||||
function applyValue() {
|
||||
let newValue = parseInt(text);
|
||||
if (!isNaN(newValue)) {
|
||||
// Don't manually set text here - let the binding handle it
|
||||
newValue = Math.max(root.from, Math.min(root.to, newValue));
|
||||
root.value = newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Suffix text (non-editable)
|
||||
NText {
|
||||
text: root.suffix
|
||||
family: Settings.data.ui.fontFixed
|
||||
pointSize: Style.fontSizeM
|
||||
font.weight: Style.fontWeightMedium
|
||||
color: Qt.alpha(Color.mOnSurface, root.enabled ? 1.0 : 0.6)
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
visible: root.suffix !== ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
objectName: "NTabBar"
|
||||
|
||||
// Public properties
|
||||
property int currentIndex: 0
|
||||
property real spacing: Style.marginXS
|
||||
property real margins: 0
|
||||
property real tabHeight: Style.baseWidgetSize
|
||||
property bool distributeEvenly: false
|
||||
default property alias content: tabRow.children
|
||||
|
||||
onDistributeEvenlyChanged: _applyDistribution()
|
||||
Component.onCompleted: _applyDistribution()
|
||||
|
||||
function _updateFirstLast() {
|
||||
// Defensive check for QML initialization timing
|
||||
if (!tabRow || !tabRow.children) {
|
||||
return;
|
||||
}
|
||||
var kids = tabRow.children;
|
||||
var len = kids.length;
|
||||
var firstVisible = -1;
|
||||
var lastVisible = -1;
|
||||
for (var i = 0; i < len; i++) {
|
||||
var child = kids[i];
|
||||
// Only consider items that have isFirst/isLast (actual tab buttons, not Repeaters)
|
||||
if (child.visible && "isFirst" in child) {
|
||||
if (firstVisible === -1)
|
||||
firstVisible = i;
|
||||
lastVisible = i;
|
||||
}
|
||||
}
|
||||
for (var i = 0; i < len; i++) {
|
||||
var child = kids[i];
|
||||
if ("isFirst" in child)
|
||||
child.isFirst = (i === firstVisible);
|
||||
if ("isLast" in child)
|
||||
child.isLast = (i === lastVisible);
|
||||
}
|
||||
}
|
||||
|
||||
function _applyDistribution() {
|
||||
if (!tabRow || !tabRow.children) {
|
||||
return;
|
||||
}
|
||||
if (!distributeEvenly) {
|
||||
for (var i = 0; i < tabRow.children.length; i++) {
|
||||
var child = tabRow.children[i];
|
||||
child.Layout.fillWidth = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < tabRow.children.length; i++) {
|
||||
var child = tabRow.children[i];
|
||||
child.Layout.fillWidth = true;
|
||||
child.Layout.preferredWidth = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Styling
|
||||
Layout.margins: Style.borderS
|
||||
implicitWidth: tabRow.implicitWidth + (margins * 2)
|
||||
implicitHeight: tabHeight + (margins * 2)
|
||||
color: Color.smartAlpha(Color.mSurfaceVariant)
|
||||
radius: Style.iRadiusM
|
||||
|
||||
RowLayout {
|
||||
id: tabRow
|
||||
anchors.fill: parent
|
||||
anchors.margins: margins
|
||||
spacing: root.spacing
|
||||
|
||||
onChildrenChanged: {
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
var child = children[i];
|
||||
child.visibleChanged.connect(root._updateFirstLast);
|
||||
}
|
||||
root._updateFirstLast();
|
||||
root._applyDistribution();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
// Public properties
|
||||
property string text: ""
|
||||
property string icon: ""
|
||||
property var tooltipText
|
||||
property bool checked: false
|
||||
property int tabIndex: 0
|
||||
property real pointSize: Style.fontSizeM
|
||||
property bool isFirst: false
|
||||
property bool isLast: false
|
||||
|
||||
// Internal state
|
||||
property bool isHovered: false
|
||||
|
||||
signal clicked
|
||||
|
||||
// Sizing
|
||||
Layout.fillHeight: true
|
||||
implicitWidth: contentLayout.implicitWidth + Style.margin2M
|
||||
|
||||
topLeftRadius: isFirst ? Style.iRadiusM : Style.iRadiusXXXS
|
||||
bottomLeftRadius: isFirst ? Style.iRadiusM : Style.iRadiusXXXS
|
||||
topRightRadius: isLast ? Style.iRadiusM : Style.iRadiusXXXS
|
||||
bottomRightRadius: isLast ? Style.iRadiusM : Style.iRadiusXXXS
|
||||
|
||||
color: root.isHovered ? Color.mHover : (root.checked ? Color.mPrimary : Color.smartAlpha(Color.mSurface))
|
||||
border.color: root.checked ? Color.mPrimary : Color.mOutline
|
||||
border.width: Style.borderS
|
||||
|
||||
Behavior on color {
|
||||
enabled: !Color.isTransitioning
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
RowLayout {
|
||||
id: contentLayout
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(implicitWidth, parent.width - Style.margin2S)
|
||||
spacing: (root.icon !== "" && root.text !== "") ? Style.marginXS : 0
|
||||
|
||||
NIcon {
|
||||
visible: root.icon !== ""
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
icon: root.icon
|
||||
pointSize: root.pointSize * 1.2
|
||||
color: root.isHovered ? Color.mOnHover : (root.checked ? Color.mOnPrimary : Color.mOnSurface)
|
||||
|
||||
Behavior on color {
|
||||
enabled: !Color.isTransitioning
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
id: tabText
|
||||
visible: root.text !== ""
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
text: root.text
|
||||
pointSize: root.pointSize
|
||||
font.weight: Style.fontWeightSemiBold
|
||||
color: root.isHovered ? Color.mOnHover : (root.checked ? Color.mOnPrimary : Color.mOnSurface)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
|
||||
Behavior on color {
|
||||
enabled: !Color.isTransitioning
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tooltip
|
||||
Timer {
|
||||
id: tooltipTimer
|
||||
interval: 500
|
||||
onTriggered: {
|
||||
if (root.isHovered && root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
|
||||
TooltipService.show(root, root.tooltipText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onEntered: {
|
||||
root.isHovered = true;
|
||||
if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
|
||||
tooltipTimer.start();
|
||||
}
|
||||
}
|
||||
onExited: {
|
||||
root.isHovered = false;
|
||||
tooltipTimer.stop();
|
||||
if (root.tooltipText && (!Array.isArray(root.tooltipText) || root.tooltipText.length > 0)) {
|
||||
TooltipService.hide();
|
||||
}
|
||||
}
|
||||
onClicked: {
|
||||
root.clicked();
|
||||
// Update parent NTabBar's currentIndex
|
||||
if (root.parent && root.parent.parent && root.parent.parent.currentIndex !== undefined) {
|
||||
root.parent.parent.currentIndex = root.tabIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
|
||||
Item {
|
||||
id: root
|
||||
objectName: "NTabView"
|
||||
|
||||
property int currentIndex: 0
|
||||
|
||||
// Private
|
||||
property int previousIndex: 0
|
||||
property bool initialized: false
|
||||
property bool animating: false
|
||||
property real animatingHeight: 0
|
||||
property real transitionGap: Style.marginXL
|
||||
property real transitionTime: Style.animationNormal
|
||||
property list<Item> contentItems: []
|
||||
|
||||
default property alias content: container.data
|
||||
|
||||
clip: true
|
||||
Layout.fillWidth: true
|
||||
|
||||
// During animation, use max height to prevent clipping. Otherwise use current item height.
|
||||
implicitHeight: animating ? animatingHeight : (contentItems[currentIndex] ? contentItems[currentIndex].implicitHeight : 0)
|
||||
|
||||
Item {
|
||||
id: container
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
// Set the visible tab to idx without triggering a slide animation.
|
||||
// Call this BEFORE the bound currentIndex changes so that
|
||||
// onCurrentIndexChanged sees previousIndex === currentIndex and skips.
|
||||
function setIndexWithoutAnimation(idx) {
|
||||
fromXAnim.stop();
|
||||
fromOpacityAnim.stop();
|
||||
toXAnim.stop();
|
||||
toOpacityAnim.stop();
|
||||
animating = false;
|
||||
previousIndex = idx;
|
||||
for (let i = 0; i < contentItems.length; i++) {
|
||||
if (i === idx) {
|
||||
contentItems[i].x = 0;
|
||||
contentItems[i].visible = true;
|
||||
contentItems[i].opacity = 1.0;
|
||||
} else {
|
||||
contentItems[i].x = root.width;
|
||||
contentItems[i].visible = false;
|
||||
contentItems[i].opacity = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
_initializeItems();
|
||||
}
|
||||
|
||||
function _initializeItems() {
|
||||
contentItems = [];
|
||||
for (let i = 0; i < container.children.length; i++) {
|
||||
const child = container.children[i];
|
||||
contentItems.push(child);
|
||||
child.width = Qt.binding(() => root.width);
|
||||
|
||||
if (i === currentIndex) {
|
||||
child.x = 0;
|
||||
child.visible = true;
|
||||
} else {
|
||||
child.x = root.width;
|
||||
child.visible = false;
|
||||
}
|
||||
}
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
onCurrentIndexChanged: {
|
||||
if (!initialized || contentItems.length === 0)
|
||||
return;
|
||||
if (previousIndex === currentIndex)
|
||||
return;
|
||||
|
||||
_animateTransition(previousIndex, currentIndex);
|
||||
previousIndex = currentIndex;
|
||||
}
|
||||
|
||||
function _animateTransition(fromIdx, toIdx) {
|
||||
// Stop any running animations
|
||||
fromXAnim.stop();
|
||||
fromOpacityAnim.stop();
|
||||
toXAnim.stop();
|
||||
toOpacityAnim.stop();
|
||||
|
||||
// Reset all items to clean state (except target)
|
||||
for (let i = 0; i < contentItems.length; i++) {
|
||||
if (i !== toIdx) {
|
||||
contentItems[i].visible = false;
|
||||
contentItems[i].opacity = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
const fromItem = contentItems[fromIdx];
|
||||
const toItem = contentItems[toIdx];
|
||||
const slideLeft = toIdx > fromIdx;
|
||||
|
||||
// Set height to max of both items during animation
|
||||
const fromHeight = fromItem ? fromItem.implicitHeight : 0;
|
||||
const toHeight = toItem ? toItem.implicitHeight : 0;
|
||||
animatingHeight = Math.max(fromHeight, toHeight);
|
||||
animating = true;
|
||||
|
||||
// Position outgoing item and make visible for animation
|
||||
if (fromItem) {
|
||||
fromItem.visible = true;
|
||||
fromItem.x = 0;
|
||||
fromItem.opacity = 1.0;
|
||||
}
|
||||
|
||||
// Position incoming item off-screen (with gap) and set initial opacity
|
||||
if (toItem) {
|
||||
toItem.visible = true;
|
||||
toItem.x = slideLeft ? root.width + transitionGap : -root.width - transitionGap;
|
||||
toItem.opacity = 0.0;
|
||||
}
|
||||
|
||||
// Animate both items together (with gap)
|
||||
if (fromItem) {
|
||||
fromXAnim.target = fromItem;
|
||||
fromXAnim.to = slideLeft ? -root.width - transitionGap : root.width + transitionGap;
|
||||
fromOpacityAnim.target = fromItem;
|
||||
fromXAnim.start();
|
||||
fromOpacityAnim.start();
|
||||
}
|
||||
|
||||
if (toItem) {
|
||||
toXAnim.target = toItem;
|
||||
toOpacityAnim.target = toItem;
|
||||
toXAnim.start();
|
||||
toOpacityAnim.start();
|
||||
}
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
id: fromXAnim
|
||||
property: "x"
|
||||
duration: root.transitionTime
|
||||
easing.type: Easing.OutCubic
|
||||
onFinished: {
|
||||
if (target && target !== contentItems[currentIndex]) {
|
||||
target.visible = false;
|
||||
target.opacity = 1.0;
|
||||
}
|
||||
animating = false;
|
||||
}
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
id: fromOpacityAnim
|
||||
property: "opacity"
|
||||
to: 0.25
|
||||
duration: root.transitionTime
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
id: toXAnim
|
||||
property: "x"
|
||||
to: 0
|
||||
duration: root.transitionTime
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
id: toOpacityAnim
|
||||
property: "opacity"
|
||||
to: 1.0
|
||||
duration: root.transitionTime
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
NCollapsible {
|
||||
id: root
|
||||
|
||||
// Public API
|
||||
property var tags: [] // Array of tag strings
|
||||
property string selectedTag: ""
|
||||
property alias label: root.label
|
||||
property alias description: root.description
|
||||
property alias expanded: root.expanded
|
||||
|
||||
// Formatting function for tag display (optional override)
|
||||
property var formatTag: function (tag) {
|
||||
if (tag === "")
|
||||
return I18n.tr("launcher.categories.all");
|
||||
// Default: capitalize first letter
|
||||
return tag.charAt(0).toUpperCase() + tag.slice(1);
|
||||
}
|
||||
|
||||
Layout.fillWidth: true
|
||||
contentSpacing: Style.marginXS
|
||||
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginXS
|
||||
flow: Flow.LeftToRight
|
||||
|
||||
Repeater {
|
||||
model: [""].concat(root.tags)
|
||||
|
||||
delegate: NButton {
|
||||
text: root.formatTag(modelData)
|
||||
backgroundColor: root.selectedTag === modelData ? Color.mPrimary : Color.mSurfaceVariant
|
||||
textColor: root.selectedTag === modelData ? Color.mOnPrimary : Color.mOnSurfaceVariant
|
||||
onClicked: root.selectedTag = modelData
|
||||
fontSize: Style.fontSizeS
|
||||
iconSize: Style.fontSizeS
|
||||
fontWeight: Style.fontWeightSemiBold
|
||||
buttonRadius: Style.iRadiusM
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
Text {
|
||||
id: root
|
||||
|
||||
property bool richTextEnabled: false
|
||||
property bool markdownTextEnabled: false
|
||||
property string family: Settings.data.ui.fontDefault
|
||||
property real pointSize: Style.fontSizeM
|
||||
property bool applyUiScale: true
|
||||
property real fontScale: {
|
||||
const fontScale = (root.family === Settings.data.ui.fontDefault ? Settings.data.ui.fontDefaultScale : Settings.data.ui.fontFixedScale);
|
||||
if (applyUiScale) {
|
||||
return fontScale * Style.uiScaleRatio;
|
||||
}
|
||||
return fontScale;
|
||||
}
|
||||
property var features: ({})
|
||||
|
||||
opacity: enabled ? 1.0 : 0.6
|
||||
font.family: root.family
|
||||
font.weight: Style.fontWeightMedium
|
||||
font.pointSize: Math.max(1, root.pointSize * fontScale)
|
||||
font.features: root.features
|
||||
color: Color.mOnSurface
|
||||
elide: Text.ElideRight
|
||||
wrapMode: Text.NoWrap
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
|
||||
textFormat: {
|
||||
if (root.richTextEnabled) {
|
||||
return Text.RichText;
|
||||
} else if (root.markdownTextEnabled) {
|
||||
return Text.MarkdownText;
|
||||
}
|
||||
return Text.PlainText;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
ColumnLayout {
|
||||
id: root
|
||||
|
||||
property string label: ""
|
||||
property string description: ""
|
||||
property string inputIconName: ""
|
||||
property bool readOnly: false
|
||||
property color labelColor: Color.mOnSurface
|
||||
property color descriptionColor: Color.mOnSurfaceVariant
|
||||
property string fontFamily: Settings.data.ui.fontDefault
|
||||
property real fontSize: Style.fontSizeS
|
||||
property int fontWeight: Style.fontWeightRegular
|
||||
property var defaultValue: undefined
|
||||
property string settingsPath: ""
|
||||
property real radius: Style.iRadiusM
|
||||
property real minimumInputWidth: 80 * Style.uiScaleRatio
|
||||
property bool showClearButton: true
|
||||
|
||||
property alias text: input.text
|
||||
property alias placeholderText: input.placeholderText
|
||||
property alias inputMethodHints: input.inputMethodHints
|
||||
property alias horizontalAlignment: input.horizontalAlignment
|
||||
property alias inputItem: input
|
||||
|
||||
signal editingFinished
|
||||
signal accepted
|
||||
|
||||
opacity: enabled ? 1.0 : 0.3
|
||||
spacing: Style.marginS
|
||||
|
||||
readonly property bool isValueChanged: (defaultValue !== undefined) && (text !== defaultValue)
|
||||
readonly property string indicatorTooltip: defaultValue !== undefined ? I18n.tr("panels.indicator.default-value", {
|
||||
"value": defaultValue === "" ? "(empty)" : String(defaultValue)
|
||||
}) : ""
|
||||
|
||||
NLabel {
|
||||
label: root.label
|
||||
description: root.description
|
||||
labelColor: root.labelColor
|
||||
descriptionColor: root.descriptionColor
|
||||
visible: root.label !== "" || root.description !== ""
|
||||
Layout.fillWidth: true
|
||||
showIndicator: root.isValueChanged
|
||||
indicatorTooltip: root.indicatorTooltip
|
||||
}
|
||||
|
||||
// An active control that blocks input, to avoid events leakage and dragging stuff in the background.
|
||||
Control {
|
||||
id: frameControl
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumWidth: root.minimumInputWidth
|
||||
Layout.margins: Style.borderS
|
||||
implicitHeight: Style.baseWidgetSize * 1.1 * Style.uiScaleRatio
|
||||
|
||||
// This is important - makes the control accept focus
|
||||
focusPolicy: Qt.StrongFocus
|
||||
hoverEnabled: true
|
||||
|
||||
background: Rectangle {
|
||||
id: frame
|
||||
|
||||
radius: root.radius
|
||||
color: Color.mSurface
|
||||
border.color: input.activeFocus ? Color.mSecondary : Color.mOutline
|
||||
border.width: Style.borderS
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Item {
|
||||
// Invisible background that captures ALL mouse events
|
||||
MouseArea {
|
||||
id: backgroundCapture
|
||||
anchors.fill: parent
|
||||
z: 0
|
||||
acceptedButtons: Qt.AllButtons
|
||||
hoverEnabled: true
|
||||
preventStealing: true
|
||||
propagateComposedEvents: false
|
||||
|
||||
onPressed: mouse => {
|
||||
mouse.accepted = true;
|
||||
// Focus the input and position cursor
|
||||
input.forceActiveFocus();
|
||||
var inputPos = mapToItem(inputContainer, mouse.x, mouse.y);
|
||||
if (inputPos.x >= 0 && inputPos.x <= inputContainer.width) {
|
||||
var textPos = inputPos.x - Style.marginM;
|
||||
if (textPos >= 0 && textPos <= input.width) {
|
||||
input.cursorPosition = input.positionAt(textPos, input.height / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onReleased: mouse => {
|
||||
mouse.accepted = true;
|
||||
}
|
||||
onDoubleClicked: mouse => {
|
||||
mouse.accepted = true;
|
||||
input.selectAll();
|
||||
}
|
||||
onPositionChanged: mouse => {
|
||||
mouse.accepted = true;
|
||||
}
|
||||
onWheel: wheel => {
|
||||
wheel.accepted = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Container for the actual text field
|
||||
Item {
|
||||
id: inputContainer
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Style.marginM
|
||||
anchors.rightMargin: 0
|
||||
clip: true
|
||||
z: 1
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
NIcon {
|
||||
id: inputIcon
|
||||
icon: root.inputIconName
|
||||
|
||||
visible: root.inputIconName !== ""
|
||||
enabled: false
|
||||
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
Layout.rightMargin: visible ? Style.marginS : 0
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: input
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
|
||||
echoMode: TextInput.Normal
|
||||
readOnly: root.readOnly
|
||||
placeholderTextColor: Qt.alpha(Color.mOnSurfaceVariant, 0.6)
|
||||
color: enabled ? Color.mOnSurface : Qt.alpha(Color.mOnSurface, 0.4)
|
||||
|
||||
selectByMouse: true
|
||||
|
||||
topPadding: 0
|
||||
bottomPadding: 0
|
||||
leftPadding: 0
|
||||
rightPadding: 0
|
||||
|
||||
background: null
|
||||
|
||||
font.family: root.fontFamily
|
||||
font.pointSize: root.fontSize * Style.uiScaleRatio
|
||||
font.weight: root.fontWeight
|
||||
|
||||
onEditingFinished: root.editingFinished()
|
||||
onAccepted: root.accepted()
|
||||
|
||||
// Override mouse handling to prevent propagation
|
||||
MouseArea {
|
||||
id: textFieldMouse
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.AllButtons
|
||||
preventStealing: true
|
||||
propagateComposedEvents: false
|
||||
cursorShape: Qt.IBeamCursor
|
||||
|
||||
property int selectionStart: 0
|
||||
|
||||
onPressed: mouse => {
|
||||
mouse.accepted = true;
|
||||
input.forceActiveFocus();
|
||||
var pos = input.positionAt(mouse.x, mouse.y);
|
||||
input.cursorPosition = pos;
|
||||
selectionStart = pos;
|
||||
}
|
||||
|
||||
onPositionChanged: mouse => {
|
||||
if (mouse.buttons & Qt.LeftButton) {
|
||||
mouse.accepted = true;
|
||||
var pos = input.positionAt(mouse.x, mouse.y);
|
||||
input.select(selectionStart, pos);
|
||||
}
|
||||
}
|
||||
|
||||
onDoubleClicked: mouse => {
|
||||
mouse.accepted = true;
|
||||
input.selectAll();
|
||||
}
|
||||
|
||||
onReleased: mouse => {
|
||||
mouse.accepted = true;
|
||||
}
|
||||
onWheel: wheel => {
|
||||
wheel.accepted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
NIconButton {
|
||||
id: clearButton
|
||||
icon: "x"
|
||||
tooltipText: (input.text.length > 0 && !root.readOnly && root.enabled) ? I18n.tr("common.clear") : ""
|
||||
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
border.width: 0
|
||||
|
||||
colorBg: "transparent"
|
||||
colorBgHover: "transparent"
|
||||
colorFg: Color.mOnSurface
|
||||
colorFgHover: Color.mError
|
||||
|
||||
visible: root.showClearButton && input.text.length > 0 && !root.readOnly
|
||||
enabled: input.text.length > 0 && !root.readOnly && root.enabled
|
||||
|
||||
onClicked: {
|
||||
input.clear();
|
||||
input.forceActiveFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
ColumnLayout {
|
||||
id: root
|
||||
Layout.fillWidth: true
|
||||
|
||||
property alias text: input.text
|
||||
property alias placeholderText: input.placeholderText
|
||||
property string label: ""
|
||||
property string description: ""
|
||||
property string inputIconName: ""
|
||||
property alias buttonIcon: button.icon
|
||||
property alias buttonTooltip: button.tooltipText
|
||||
property alias buttonEnabled: button.enabled
|
||||
property real maximumWidth: 0
|
||||
|
||||
signal buttonClicked
|
||||
signal inputTextChanged(string text)
|
||||
signal inputEditingFinished
|
||||
|
||||
spacing: Style.marginS
|
||||
|
||||
// Label and description
|
||||
NLabel {
|
||||
label: root.label
|
||||
description: root.description
|
||||
visible: root.label !== "" || root.description !== ""
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
// Input field with button
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginM
|
||||
|
||||
NTextInput {
|
||||
id: input
|
||||
inputIconName: root.inputIconName
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
enabled: root.enabled
|
||||
onTextChanged: root.inputTextChanged(text)
|
||||
onEditingFinished: root.inputEditingFinished()
|
||||
}
|
||||
|
||||
// Button
|
||||
NIconButton {
|
||||
id: button
|
||||
baseSize: Style.baseWidgetSize
|
||||
onClicked: root.buttonClicked()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
RowLayout {
|
||||
id: root
|
||||
|
||||
property string label: ""
|
||||
property string description: ""
|
||||
property string icon: ""
|
||||
property bool checked: false
|
||||
property bool hovering: false
|
||||
property int baseSize: Math.round(Style.baseWidgetSize * 0.8 * Style.uiScaleRatio)
|
||||
property var defaultValue: undefined
|
||||
property string settingsPath: ""
|
||||
|
||||
signal toggled(bool checked)
|
||||
signal entered
|
||||
signal exited
|
||||
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginM
|
||||
|
||||
readonly property bool isValueChanged: (defaultValue !== undefined) && (checked !== defaultValue)
|
||||
readonly property string indicatorTooltip: defaultValue !== undefined ? I18n.tr("panels.indicator.default-value", {
|
||||
"value": typeof defaultValue === "boolean" ? (defaultValue ? "true" : "false") : String(defaultValue)
|
||||
}) : ""
|
||||
|
||||
NLabel {
|
||||
Layout.fillWidth: true
|
||||
label: root.label
|
||||
description: root.description
|
||||
icon: root.icon
|
||||
iconColor: root.checked ? Color.mPrimary : Color.mOnSurface
|
||||
visible: root.label !== "" || root.description !== ""
|
||||
showIndicator: root.isValueChanged
|
||||
indicatorTooltip: root.indicatorTooltip
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: switcher
|
||||
|
||||
opacity: enabled ? 1.0 : 0.6
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
Layout.margins: Style.borderS
|
||||
implicitWidth: Math.round(root.baseSize * .85) * 2
|
||||
implicitHeight: Math.round(root.baseSize * .5) * 2
|
||||
radius: Math.min(Style.iRadiusL, height / 2)
|
||||
color: root.checked ? Color.mPrimary : Color.mSurface
|
||||
border.color: Color.mOutline
|
||||
border.width: Style.borderS
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
implicitWidth: Math.round(root.baseSize * 0.4) * 2
|
||||
implicitHeight: Math.round(root.baseSize * 0.4) * 2
|
||||
radius: Math.min(Style.iRadiusL, height / 2)
|
||||
color: root.checked ? Color.mOnPrimary : Color.mPrimary
|
||||
border.color: root.checked ? Color.mSurface : Color.mSurface
|
||||
border.width: Style.borderM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.verticalCenterOffset: 0
|
||||
x: root.checked ? switcher.width - width - 3 : 3
|
||||
|
||||
Behavior on x {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
enabled: root.enabled
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
onEntered: {
|
||||
if (!enabled)
|
||||
return;
|
||||
hovering = true;
|
||||
root.entered();
|
||||
}
|
||||
onExited: {
|
||||
if (!enabled)
|
||||
return;
|
||||
hovering = false;
|
||||
root.exited();
|
||||
}
|
||||
onClicked: {
|
||||
if (!enabled)
|
||||
return;
|
||||
root.toggled(!root.checked);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
RowLayout {
|
||||
id: root
|
||||
|
||||
property real from: 0
|
||||
property real to: 1
|
||||
property real value: 0
|
||||
property real stepSize: 0.01
|
||||
property var cutoutColor: Color.mSurface
|
||||
property bool snapAlways: true
|
||||
property real heightRatio: 0.7
|
||||
property string text: ""
|
||||
property real textSize: Style.fontSizeM
|
||||
property real customHeight: -1
|
||||
property real customHeightRatio: -1
|
||||
property string label: ""
|
||||
property string description: ""
|
||||
property var defaultValue: undefined
|
||||
property bool showReset: false
|
||||
|
||||
spacing: Style.marginL
|
||||
Layout.fillWidth: true
|
||||
|
||||
// Signals
|
||||
signal moved(real value)
|
||||
signal pressedChanged(bool pressed, real value)
|
||||
|
||||
readonly property bool sliderActive: slider.activeFocus || slider.pressed
|
||||
readonly property bool isValueChanged: defaultValue !== undefined && (value !== defaultValue)
|
||||
readonly property string indicatorTooltip: {
|
||||
if (defaultValue === undefined)
|
||||
return "";
|
||||
var defaultVal = defaultValue;
|
||||
if (typeof defaultVal === "number") {
|
||||
// If it's a decimal between 0 and 1, format as percentage
|
||||
if (defaultVal > 0 && defaultVal <= 1 && from >= 0 && from < 1) {
|
||||
return I18n.tr("panels.indicator.default-value", {
|
||||
"value": Math.floor(defaultVal * 100) + "%"
|
||||
});
|
||||
}
|
||||
return I18n.tr("panels.indicator.default-value", {
|
||||
"value": String(defaultVal)
|
||||
});
|
||||
}
|
||||
return I18n.tr("panels.indicator.default-value", {
|
||||
"value": String(defaultVal)
|
||||
});
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
spacing: Style.marginS
|
||||
Layout.fillWidth: true
|
||||
|
||||
NLabel {
|
||||
label: root.label
|
||||
description: root.description
|
||||
visible: root.label !== "" || root.description !== ""
|
||||
showIndicator: root.isValueChanged
|
||||
indicatorTooltip: root.indicatorTooltip
|
||||
opacity: root.enabled ? 1.0 : 0.6
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
spacing: Style.marginL
|
||||
Layout.fillWidth: true
|
||||
|
||||
NSlider {
|
||||
id: slider
|
||||
Layout.fillWidth: true
|
||||
from: root.from
|
||||
to: root.to
|
||||
value: root.value
|
||||
stepSize: root.stepSize
|
||||
cutoutColor: root.cutoutColor
|
||||
snapAlways: root.snapAlways
|
||||
heightRatio: root.customHeightRatio > 0 ? root.customHeightRatio : root.heightRatio
|
||||
onMoved: root.moved(value)
|
||||
onPressedChanged: root.pressedChanged(pressed, value)
|
||||
}
|
||||
|
||||
NText {
|
||||
visible: root.text !== ""
|
||||
text: root.text
|
||||
pointSize: root.textSize
|
||||
family: Settings.data.ui.fontFixed
|
||||
opacity: root.enabled ? 1.0 : 0.6
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
Layout.preferredWidth: 45 * Style.uiScaleRatio
|
||||
horizontalAlignment: Text.AlignRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: buttonItem
|
||||
visible: root.showReset && root.defaultValue !== undefined
|
||||
Layout.preferredWidth: 30 * Style.uiScaleRatio
|
||||
Layout.preferredHeight: 30 * Style.uiScaleRatio
|
||||
|
||||
NIconButton {
|
||||
icon: "restore"
|
||||
enabled: root.enabled
|
||||
baseSize: Style.baseWidgetSize * 0.8
|
||||
tooltipText: I18n.tr("common.reset")
|
||||
onClicked: root.moved(root.defaultValue)
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user