From a4e086192d27d277039042e2b2f00a31316c5a2a Mon Sep 17 00:00:00 2001 From: Zacharias-Brohn Date: Sun, 15 Mar 2026 22:41:10 +0100 Subject: [PATCH] drawing sliders --- Components/BaseStyledSlider.qml | 165 ++++++++++++++++++++++++++++++++ Components/ColorArcPicker.qml | 146 +++++++++++++++++----------- Components/FilledSlider.qml | 144 ++++------------------------ Components/GradientSlider.qml | 47 +++++++++ Modules/Drawing/Content.qml | 107 +++++++++++++++++---- 5 files changed, 411 insertions(+), 198 deletions(-) create mode 100644 Components/BaseStyledSlider.qml create mode 100644 Components/GradientSlider.qml diff --git a/Components/BaseStyledSlider.qml b/Components/BaseStyledSlider.qml new file mode 100644 index 0000000..48c8f33 --- /dev/null +++ b/Components/BaseStyledSlider.qml @@ -0,0 +1,165 @@ +import QtQuick +import QtQuick.Templates +import qs.Config + +Slider { + id: root + + property color color: DynamicColors.palette.m3secondary + required property string icon + property bool initialized: false + readonly property bool isHorizontal: orientation === Qt.Horizontal + readonly property bool isVertical: orientation === Qt.Vertical + property real multiplier: 100 + property real oldValue + + // Wrapper components can inject their own track visuals here. + property Component trackContent + + // Keep current behavior for existing usages. + orientation: Qt.Vertical + + background: CustomRect { + id: groove + + color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainer, 2) + height: root.availableHeight + radius: Appearance.rounding.full + width: root.availableWidth + x: root.leftPadding + y: root.topPadding + + Loader { + id: trackLoader + + anchors.fill: parent + sourceComponent: root.trackContent + + onLoaded: { + if (!item) + return; + + item.rootSlider = root; + item.groove = groove; + item.handleItem = handle; + } + } + } + handle: Item { + id: handle + + property alias moving: icon.moving + + implicitHeight: Math.min(root.width, root.height) + implicitWidth: Math.min(root.width, root.height) + x: root.isHorizontal ? root.leftPadding + root.visualPosition * (root.availableWidth - width) : root.leftPadding + (root.availableWidth - width) / 2 + y: root.isVertical ? root.topPadding + root.visualPosition * (root.availableHeight - height) : root.topPadding + (root.availableHeight - height) / 2 + + Elevation { + anchors.fill: parent + level: handleInteraction.containsMouse ? 2 : 1 + radius: rect.radius + } + + CustomRect { + id: rect + + anchors.fill: parent + color: DynamicColors.palette.m3inverseSurface + radius: Appearance.rounding.full + + MouseArea { + id: handleInteraction + + acceptedButtons: Qt.NoButton + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + } + + MaterialIcon { + id: icon + + property bool moving + + function update(): void { + animate = !moving; + binding.when = moving; + font.pointSize = moving ? Appearance.font.size.small : Appearance.font.size.larger; + font.family = moving ? Appearance.font.family.sans : Appearance.font.family.material; + } + + anchors.centerIn: parent + color: DynamicColors.palette.m3inverseOnSurface + text: root.icon + + onMovingChanged: anim.restart() + + Binding { + id: binding + + property: "text" + target: icon + value: Math.round(root.value * root.multiplier) + when: false + } + + SequentialAnimation { + id: anim + + Anim { + duration: Appearance.anim.durations.normal / 2 + easing.bezierCurve: Appearance.anim.curves.standardAccel + property: "scale" + target: icon + to: 0 + } + + ScriptAction { + script: icon.update() + } + + Anim { + duration: Appearance.anim.durations.normal / 2 + easing.bezierCurve: Appearance.anim.curves.standardDecel + property: "scale" + target: icon + to: 1 + } + } + } + } + } + Behavior on value { + Anim { + duration: Appearance.anim.durations.large + } + } + + onPressedChanged: handle.moving = pressed + onValueChanged: { + if (!initialized) { + initialized = true; + oldValue = value; + return; + } + + if (Math.abs(value - oldValue) < 0.01) + return; + + oldValue = value; + handle.moving = true; + stateChangeDelay.restart(); + } + + Timer { + id: stateChangeDelay + + interval: 500 + + onTriggered: { + if (!root.pressed) + handle.moving = false; + } + } +} diff --git a/Components/ColorArcPicker.qml b/Components/ColorArcPicker.qml index 3e3a182..c909511 100644 --- a/Components/ColorArcPicker.qml +++ b/Components/ColorArcPicker.qml @@ -1,6 +1,7 @@ pragma ComponentBehavior: Bound import QtQuick +import qs.Config Item { id: root @@ -8,11 +9,18 @@ Item { readonly property real arcStartAngle: 0.75 * Math.PI readonly property real arcSweep: 1.5 * Math.PI property real currentHue: 0 + property bool dragActive: false required property var drawing - property real handleSize: 30 + readonly property real handleAngle: hueToAngle(currentHue) + readonly property real handleCenterX: width / 2 + radius * Math.cos(handleAngle) + readonly property real handleCenterY: height / 2 + radius * Math.sin(handleAngle) + property real handleSize: 32 property real lastChromaticHue: 0 - property real ringThickness: 12 - readonly property int segmentCount: 180 + readonly property real radius: (Math.min(width, height) - handleSize) / 2 + readonly property int segmentCount: 240 + readonly property color thumbColor: DynamicColors.palette.m3inverseSurface + readonly property color thumbContentColor: DynamicColors.palette.m3inverseOnSurface + readonly property color trackColor: DynamicColors.layer(DynamicColors.palette.m3surfaceContainer, 2) function hueToAngle(hue) { return arcStartAngle + arcSweep * hue; @@ -26,18 +34,25 @@ Item { return a; } + function pointIsOnTrack(x, y) { + const cx = width / 2; + const cy = height / 2; + const dx = x - cx; + const dy = y - cy; + const distance = Math.sqrt(dx * dx + dy * dy); + + return distance >= radius - handleSize / 2 && distance <= radius + handleSize / 2; + } + function syncFromPenColor() { if (!drawing) return; const c = drawing.penColor; - // QML color exposes HSL channels directly. - // If the current color is chromatic, move the handle to that hue. - // If it is achromatic (black/white/gray), keep the last useful hue. - if (c.hslSaturation > 0) { - currentHue = c.hslHue; - lastChromaticHue = c.hslHue; + if (c.hsvSaturation > 0) { + currentHue = c.hsvHue; + lastChromaticHue = c.hsvHue; } else { currentHue = lastChromaticHue; } @@ -45,16 +60,15 @@ Item { canvas.requestPaint(); } - function updateHueFromPoint(x, y) { - const cx = canvas.width / 2; - const cy = canvas.height / 2; + function updateHueFromPoint(x, y, force = false) { + const cx = width / 2; + const cy = height / 2; const dx = x - cx; const dy = y - cy; const distance = Math.sqrt(dx * dx + dy * dy); - const radius = (Math.min(canvas.width, canvas.height) - handleSize - 8) / 2; - if (distance < radius - 24 || distance > radius + 24) + if (!force && (distance < radius - handleSize / 2 || distance > radius + handleSize / 2)) return; const angle = normalizeAngle(Math.atan2(dy, dx)); @@ -71,7 +85,7 @@ Item { currentHue = relative / arcSweep; lastChromaticHue = currentHue; - drawing.penColor = Qt.hsla(currentHue, 1.0, 0.5, 1.0); + drawing.penColor = Qt.hsva(currentHue, drawing.penColor.hsvSaturation, drawing.penColor.hsvValue, drawing.penColor.a); } implicitHeight: 180 @@ -80,6 +94,9 @@ Item { Component.onCompleted: syncFromPenColor() onCurrentHueChanged: canvas.requestPaint() onDrawingChanged: syncFromPenColor() + onHandleSizeChanged: canvas.requestPaint() + onHeightChanged: canvas.requestPaint() + onWidthChanged: canvas.requestPaint() Connections { function onPenColorChanged() { @@ -97,7 +114,6 @@ Item { renderTarget: Canvas.Image Component.onCompleted: requestPaint() - onHeightChanged: requestPaint() onPaint: { const ctx = getContext("2d"); ctx.reset(); @@ -105,15 +121,10 @@ Item { const cx = width / 2; const cy = height / 2; - const radius = (Math.min(width, height) - root.handleSize - 8) / 2; - - ctx.beginPath(); - ctx.arc(cx, cy, radius, root.arcStartAngle, root.arcStartAngle + root.arcSweep); - ctx.lineWidth = root.ringThickness + 4; - ctx.lineCap = "round"; - ctx.strokeStyle = Qt.rgba(1, 1, 1, 0.12); - ctx.stroke(); + const radius = root.radius; + const trackWidth = root.handleSize; + // Background track: always show the full hue spectrum for (let i = 0; i < root.segmentCount; ++i) { const t1 = i / root.segmentCount; const t2 = (i + 1) / root.segmentCount; @@ -122,49 +133,76 @@ Item { ctx.beginPath(); ctx.arc(cx, cy, radius, a1, a2); - ctx.lineWidth = root.ringThickness; + ctx.lineWidth = trackWidth; ctx.lineCap = "round"; ctx.strokeStyle = Qt.hsla(t1, 1.0, 0.5, 1.0); ctx.stroke(); } - - const handleAngle = root.hueToAngle(root.currentHue); - const hx = cx + radius * Math.cos(handleAngle); - const hy = cy + radius * Math.sin(handleAngle); - - ctx.beginPath(); - ctx.arc(hx, hy, root.handleSize / 2, 0, Math.PI * 2); - ctx.fillStyle = Qt.rgba(1, 1, 1, 0.95); - ctx.fill(); - - ctx.beginPath(); - ctx.arc(hx, hy, root.handleSize / 2, 0, Math.PI * 2); - ctx.lineWidth = 1.5; - ctx.strokeStyle = Qt.rgba(0, 0, 0, 0.18); - ctx.stroke(); - - ctx.beginPath(); - ctx.arc(hx, hy, root.handleSize / 2 - 6, 0, Math.PI * 2); - ctx.fillStyle = root.drawing.penColor; - ctx.fill(); - - ctx.beginPath(); - ctx.arc(hx, hy, root.handleSize / 2 - 6, 0, Math.PI * 2); - ctx.lineWidth = 1; - ctx.strokeStyle = Qt.rgba(0, 0, 0, 0.20); - ctx.stroke(); } - onWidthChanged: requestPaint() + } + + Item { + id: handle + + height: root.handleSize + width: root.handleSize + x: root.handleCenterX - width / 2 + y: root.handleCenterY - height / 2 + z: 1 + + Elevation { + anchors.fill: parent + level: handleHover.containsMouse ? 2 : 1 + radius: rect.radius + } + + Rectangle { + id: rect + + anchors.fill: parent + color: root.thumbColor + radius: width / 2 + + MouseArea { + id: handleHover + + acceptedButtons: Qt.NoButton + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + } + + Rectangle { + anchors.centerIn: parent + color: root.drawing ? root.drawing.penColor : Qt.hsla(root.currentHue, 1.0, 0.5, 1.0) + height: width + radius: width / 2 + width: parent.width - 12 + } + } } MouseArea { + id: dragArea + acceptedButtons: Qt.LeftButton anchors.fill: parent + hoverEnabled: true + onCanceled: { + root.dragActive = false; + } onPositionChanged: mouse => { - if (mouse.buttons & Qt.LeftButton) + if ((mouse.buttons & Qt.LeftButton) && root.dragActive) + root.updateHueFromPoint(mouse.x, mouse.y, true); + } + onPressed: mouse => { + root.dragActive = root.pointIsOnTrack(mouse.x, mouse.y); + if (root.dragActive) root.updateHueFromPoint(mouse.x, mouse.y); } - onPressed: mouse => root.updateHueFromPoint(mouse.x, mouse.y) + onReleased: { + root.dragActive = false; + } } } diff --git a/Components/FilledSlider.qml b/Components/FilledSlider.qml index bab8051..d38d1cb 100644 --- a/Components/FilledSlider.qml +++ b/Components/FilledSlider.qml @@ -1,141 +1,29 @@ import QtQuick -import QtQuick.Templates import qs.Config -Slider { +BaseStyledSlider { id: root - property color color: DynamicColors.palette.m3secondary - required property string icon - property bool initialized - property real oldValue + trackContent: Component { + Item { + property var groove + readonly property real handleHeight: handleItem ? handleItem.height : 0 + property var handleItem + readonly property real handleWidth: handleItem ? handleItem.width : 0 - orientation: Qt.Vertical - - background: CustomRect { - color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainer, 2) - radius: Appearance.rounding.full - - CustomRect { - anchors.left: parent.left - anchors.right: parent.right - color: root.color - implicitHeight: parent.height - y - radius: parent.radius - y: root.handle.y - } - } - handle: Item { - id: handle - - property alias moving: icon.moving - - implicitHeight: root.width - implicitWidth: root.width - y: root.visualPosition * (root.availableHeight - height) - - Elevation { - anchors.fill: parent - level: handleInteraction.containsMouse ? 2 : 1 - radius: rect.radius - } - - CustomRect { - id: rect + // Set by BaseStyledSlider's Loader + property var rootSlider anchors.fill: parent - color: DynamicColors.palette.m3inverseSurface - radius: Appearance.rounding.full - MouseArea { - id: handleInteraction - - acceptedButtons: Qt.NoButton - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true + CustomRect { + color: rootSlider?.color + height: rootSlider?.isVertical ? handleHeight + (1 - rootSlider?.visualPosition) * (groove?.height - handleHeight) : groove?.height + radius: groove?.radius + width: rootSlider?.isHorizontal ? handleWidth + rootSlider?.visualPosition * (groove?.width - handleWidth) : groove?.width + x: rootSlider?.isHorizontal ? (rootSlider?.mirrored ? groove?.width - width : 0) : 0 + y: rootSlider?.isVertical ? groove?.height - height : 0 } - - MaterialIcon { - id: icon - - property bool moving - - function update(): void { - animate = !moving; - binding.when = moving; - font.pointSize = moving ? Appearance.font.size.small : Appearance.font.size.larger; - font.family = moving ? Appearance.font.family.sans : Appearance.font.family.material; - } - - anchors.centerIn: parent - color: DynamicColors.palette.m3inverseOnSurface - text: root.icon - - onMovingChanged: anim.restart() - - Binding { - id: binding - - property: "text" - target: icon - value: Math.round(root.value * 100) - when: false - } - - SequentialAnimation { - id: anim - - Anim { - duration: Appearance.anim.durations.normal / 2 - easing.bezierCurve: Appearance.anim.curves.standardAccel - property: "scale" - target: icon - to: 0 - } - - ScriptAction { - script: icon.update() - } - - Anim { - duration: Appearance.anim.durations.normal / 2 - easing.bezierCurve: Appearance.anim.curves.standardDecel - property: "scale" - target: icon - to: 1 - } - } - } - } - } - Behavior on value { - Anim { - duration: Appearance.anim.durations.large - } - } - - onPressedChanged: handle.moving = pressed - onValueChanged: { - if (!initialized) { - initialized = true; - return; - } - if (Math.abs(value - oldValue) < 0.01) - return; - oldValue = value; - handle.moving = true; - stateChangeDelay.restart(); - } - - Timer { - id: stateChangeDelay - - interval: 500 - - onTriggered: { - if (!root.pressed) - handle.moving = false; } } } diff --git a/Components/GradientSlider.qml b/Components/GradientSlider.qml new file mode 100644 index 0000000..c19f161 --- /dev/null +++ b/Components/GradientSlider.qml @@ -0,0 +1,47 @@ +import QtQuick +import qs.Config + +BaseStyledSlider { + id: root + + property real alpha: 1.0 + property real brightness: 1.0 + property string channel: "saturation" + readonly property color currentColor: Qt.hsva(hue, channel === "saturation" ? value : saturation, channel === "brightness" ? value : brightness, alpha) + property real hue: 0.0 + property real saturation: 1.0 + + from: 0 + to: 1 + + trackContent: Component { + Item { + property var groove + property var handleItem + property var rootSlider + + anchors.fill: parent + + Rectangle { + anchors.fill: parent + antialiasing: true + color: "transparent" + radius: groove?.radius ?? 0 + + gradient: Gradient { + orientation: rootSlider?.isHorizontal ? Gradient.Horizontal : Gradient.Vertical + + GradientStop { + color: root.channel === "saturation" ? Qt.hsva(root.hue, 0.0, root.brightness, root.alpha) : Qt.hsva(root.hue, root.saturation, 0.0, root.alpha) + position: 0.0 + } + + GradientStop { + color: root.channel === "saturation" ? Qt.hsva(root.hue, 1.0, root.brightness, root.alpha) : Qt.hsva(root.hue, root.saturation, 1.0, root.alpha) + position: 1.0 + } + } + } + } + } +} diff --git a/Modules/Drawing/Content.qml b/Modules/Drawing/Content.qml index 4bcffa0..98c4caf 100644 --- a/Modules/Drawing/Content.qml +++ b/Modules/Drawing/Content.qml @@ -12,10 +12,40 @@ Item { required property Canvas drawing required property var visibilities - implicitHeight: huePicker.implicitHeight + 12 + palette.implicitHeight + Appearance.padding.normal * 2 + function syncFromPenColor() { + if (!drawing) + return; + + if (!saturationSlider.pressed) + saturationSlider.value = drawing.penColor.hsvSaturation; + + if (!brightnessSlider.pressed) + brightnessSlider.value = drawing.penColor.hsvValue; + } + + function updatePenColorFromHsv() { + if (!drawing) + return; + + drawing.penColor = Qt.hsva(huePicker.currentHue, saturationSlider.value, brightnessSlider.value, drawing.penColor.a); + } + + implicitHeight: column.height + Appearance.padding.larger * 2 implicitWidth: huePicker.implicitWidth + Appearance.padding.normal * 2 + Component.onCompleted: syncFromPenColor() + + Connections { + function onPenColorChanged() { + root.syncFromPenColor(); + } + + target: root.drawing + } + Column { + id: column + anchors.centerIn: parent spacing: 12 @@ -25,6 +55,38 @@ Item { drawing: root.drawing } + GradientSlider { + id: saturationSlider + + brightness: brightnessSlider.value + channel: "saturation" + from: 0 + hue: huePicker.currentHue + icon: "\ue40a" + implicitHeight: 30 + implicitWidth: palette.width + orientation: Qt.Horizontal + to: 1 + + onMoved: root.updatePenColorFromHsv() + } + + GradientSlider { + id: brightnessSlider + + channel: "brightness" + from: 0 + hue: huePicker.currentHue + icon: "\ue1ac" + implicitHeight: 30 + implicitWidth: palette.width + orientation: Qt.Horizontal + saturation: saturationSlider.value + to: 1 + + onMoved: root.updatePenColorFromHsv() + } + GridLayout { id: palette @@ -38,12 +100,24 @@ Item { model: root.colors delegate: Item { + id: colorCircle + required property color modelData readonly property bool selected: Qt.colorEqual(root.drawing.penColor, modelData) Layout.fillWidth: true height: 28 + CustomRect { + anchors.centerIn: parent + border.color: Qt.rgba(0, 0, 0, 0.25) + border.width: Qt.colorEqual(modelData, "#ffffff") ? 1 : 0 + color: colorCircle.modelData + height: 20 + radius: width / 2 + width: 20 + } + CustomRect { anchors.centerIn: parent border.color: selected ? "#ffffff" : Qt.rgba(1, 1, 1, 0.28) @@ -52,25 +126,26 @@ Item { height: parent.height radius: width / 2 width: parent.height - } - CustomRect { - anchors.centerIn: parent - border.color: Qt.rgba(0, 0, 0, 0.25) - border.width: Qt.colorEqual(modelData, "#ffffff") ? 1 : 0 - color: modelData - height: 20 - radius: width / 2 - width: 20 - } - - MouseArea { - anchors.fill: parent - - onClicked: root.drawing.penColor = parent.modelData + StateLayer { + onClicked: root.drawing.penColor = colorCircle.modelData + } } } } } + + FilledSlider { + from: 1 + icon: "border_color" + implicitHeight: 30 + implicitWidth: palette.width + multiplier: 1 + orientation: Qt.Horizontal + to: 45 + value: root.drawing.penWidth + + onMoved: root.drawing.penWidth = value + } } }