This commit is contained in:
2026-05-28 21:45:21 +02:00
parent 653439ac24
commit a356a3d369
610 changed files with 205679 additions and 0 deletions
@@ -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.00.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
}
}
}