drawing sliders

This commit is contained in:
Zacharias-Brohn
2026-03-15 22:41:10 +01:00
parent 9c955581fa
commit a4e086192d
5 changed files with 411 additions and 198 deletions
+165
View File
@@ -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;
}
}
}
+92 -54
View File
@@ -1,6 +1,7 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import qs.Config
Item { Item {
id: root id: root
@@ -8,11 +9,18 @@ Item {
readonly property real arcStartAngle: 0.75 * Math.PI readonly property real arcStartAngle: 0.75 * Math.PI
readonly property real arcSweep: 1.5 * Math.PI readonly property real arcSweep: 1.5 * Math.PI
property real currentHue: 0 property real currentHue: 0
property bool dragActive: false
required property var drawing 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 lastChromaticHue: 0
property real ringThickness: 12 readonly property real radius: (Math.min(width, height) - handleSize) / 2
readonly property int segmentCount: 180 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) { function hueToAngle(hue) {
return arcStartAngle + arcSweep * hue; return arcStartAngle + arcSweep * hue;
@@ -26,18 +34,25 @@ Item {
return a; 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() { function syncFromPenColor() {
if (!drawing) if (!drawing)
return; return;
const c = drawing.penColor; const c = drawing.penColor;
// QML color exposes HSL channels directly. if (c.hsvSaturation > 0) {
// If the current color is chromatic, move the handle to that hue. currentHue = c.hsvHue;
// If it is achromatic (black/white/gray), keep the last useful hue. lastChromaticHue = c.hsvHue;
if (c.hslSaturation > 0) {
currentHue = c.hslHue;
lastChromaticHue = c.hslHue;
} else { } else {
currentHue = lastChromaticHue; currentHue = lastChromaticHue;
} }
@@ -45,16 +60,15 @@ Item {
canvas.requestPaint(); canvas.requestPaint();
} }
function updateHueFromPoint(x, y) { function updateHueFromPoint(x, y, force = false) {
const cx = canvas.width / 2; const cx = width / 2;
const cy = canvas.height / 2; const cy = height / 2;
const dx = x - cx; const dx = x - cx;
const dy = y - cy; const dy = y - cy;
const distance = Math.sqrt(dx * dx + dy * dy); 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; return;
const angle = normalizeAngle(Math.atan2(dy, dx)); const angle = normalizeAngle(Math.atan2(dy, dx));
@@ -71,7 +85,7 @@ Item {
currentHue = relative / arcSweep; currentHue = relative / arcSweep;
lastChromaticHue = currentHue; 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 implicitHeight: 180
@@ -80,6 +94,9 @@ Item {
Component.onCompleted: syncFromPenColor() Component.onCompleted: syncFromPenColor()
onCurrentHueChanged: canvas.requestPaint() onCurrentHueChanged: canvas.requestPaint()
onDrawingChanged: syncFromPenColor() onDrawingChanged: syncFromPenColor()
onHandleSizeChanged: canvas.requestPaint()
onHeightChanged: canvas.requestPaint()
onWidthChanged: canvas.requestPaint()
Connections { Connections {
function onPenColorChanged() { function onPenColorChanged() {
@@ -97,7 +114,6 @@ Item {
renderTarget: Canvas.Image renderTarget: Canvas.Image
Component.onCompleted: requestPaint() Component.onCompleted: requestPaint()
onHeightChanged: requestPaint()
onPaint: { onPaint: {
const ctx = getContext("2d"); const ctx = getContext("2d");
ctx.reset(); ctx.reset();
@@ -105,15 +121,10 @@ Item {
const cx = width / 2; const cx = width / 2;
const cy = height / 2; const cy = height / 2;
const radius = (Math.min(width, height) - root.handleSize - 8) / 2; const radius = root.radius;
const trackWidth = root.handleSize;
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();
// Background track: always show the full hue spectrum
for (let i = 0; i < root.segmentCount; ++i) { for (let i = 0; i < root.segmentCount; ++i) {
const t1 = i / root.segmentCount; const t1 = i / root.segmentCount;
const t2 = (i + 1) / root.segmentCount; const t2 = (i + 1) / root.segmentCount;
@@ -122,49 +133,76 @@ Item {
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, radius, a1, a2); ctx.arc(cx, cy, radius, a1, a2);
ctx.lineWidth = root.ringThickness; ctx.lineWidth = trackWidth;
ctx.lineCap = "round"; ctx.lineCap = "round";
ctx.strokeStyle = Qt.hsla(t1, 1.0, 0.5, 1.0); ctx.strokeStyle = Qt.hsla(t1, 1.0, 0.5, 1.0);
ctx.stroke(); 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 { MouseArea {
id: dragArea
acceptedButtons: Qt.LeftButton acceptedButtons: Qt.LeftButton
anchors.fill: parent anchors.fill: parent
hoverEnabled: true
onCanceled: {
root.dragActive = false;
}
onPositionChanged: mouse => { 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); root.updateHueFromPoint(mouse.x, mouse.y);
} }
onPressed: mouse => root.updateHueFromPoint(mouse.x, mouse.y) onReleased: {
root.dragActive = false;
}
} }
} }
+16 -128
View File
@@ -1,141 +1,29 @@
import QtQuick import QtQuick
import QtQuick.Templates
import qs.Config import qs.Config
Slider { BaseStyledSlider {
id: root id: root
property color color: DynamicColors.palette.m3secondary trackContent: Component {
required property string icon Item {
property bool initialized property var groove
property real oldValue readonly property real handleHeight: handleItem ? handleItem.height : 0
property var handleItem
readonly property real handleWidth: handleItem ? handleItem.width : 0
orientation: Qt.Vertical // Set by BaseStyledSlider's Loader
property var rootSlider
background: CustomRect { anchors.fill: parent
color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainer, 2)
radius: Appearance.rounding.full
CustomRect { CustomRect {
anchors.left: parent.left color: rootSlider?.color
anchors.right: parent.right height: rootSlider?.isVertical ? handleHeight + (1 - rootSlider?.visualPosition) * (groove?.height - handleHeight) : groove?.height
color: root.color radius: groove?.radius
implicitHeight: parent.height - y width: rootSlider?.isHorizontal ? handleWidth + rootSlider?.visualPosition * (groove?.width - handleWidth) : groove?.width
radius: parent.radius x: rootSlider?.isHorizontal ? (rootSlider?.mirrored ? groove?.width - width : 0) : 0
y: root.handle.y y: rootSlider?.isVertical ? groove?.height - height : 0
} }
} }
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
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 * 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;
}
} }
} }
+47
View File
@@ -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
}
}
}
}
}
}
+92 -17
View File
@@ -12,10 +12,40 @@ Item {
required property Canvas drawing required property Canvas drawing
required property var visibilities 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 implicitWidth: huePicker.implicitWidth + Appearance.padding.normal * 2
Component.onCompleted: syncFromPenColor()
Connections {
function onPenColorChanged() {
root.syncFromPenColor();
}
target: root.drawing
}
Column { Column {
id: column
anchors.centerIn: parent anchors.centerIn: parent
spacing: 12 spacing: 12
@@ -25,6 +55,38 @@ Item {
drawing: root.drawing 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 { GridLayout {
id: palette id: palette
@@ -38,12 +100,24 @@ Item {
model: root.colors model: root.colors
delegate: Item { delegate: Item {
id: colorCircle
required property color modelData required property color modelData
readonly property bool selected: Qt.colorEqual(root.drawing.penColor, modelData) readonly property bool selected: Qt.colorEqual(root.drawing.penColor, modelData)
Layout.fillWidth: true Layout.fillWidth: true
height: 28 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 { CustomRect {
anchors.centerIn: parent anchors.centerIn: parent
border.color: selected ? "#ffffff" : Qt.rgba(1, 1, 1, 0.28) border.color: selected ? "#ffffff" : Qt.rgba(1, 1, 1, 0.28)
@@ -52,25 +126,26 @@ Item {
height: parent.height height: parent.height
radius: width / 2 radius: width / 2
width: parent.height width: parent.height
StateLayer {
onClicked: root.drawing.penColor = colorCircle.modelData
}
}
}
}
} }
CustomRect { FilledSlider {
anchors.centerIn: parent from: 1
border.color: Qt.rgba(0, 0, 0, 0.25) icon: "border_color"
border.width: Qt.colorEqual(modelData, "#ffffff") ? 1 : 0 implicitHeight: 30
color: modelData implicitWidth: palette.width
height: 20 multiplier: 1
radius: width / 2 orientation: Qt.Horizontal
width: 20 to: 45
} value: root.drawing.penWidth
MouseArea { onMoved: root.drawing.penWidth = value
anchors.fill: parent
onClicked: root.drawing.penColor = parent.modelData
}
}
}
} }
} }
} }