// Hue, saturation and value for both ends of the Prism accent. // // This is the fine-tune behind a disclosure, not the fast path: the four wells // above cover what most edits are, and these six rows are for the last few // degrees. Each row is a labelled, keyboard-operable slider with a numeric // readout, because a hue ring alone tells someone with a colour vision // deficiency nothing. // // Committed on a debounce rather than per move. Each move used to write BOTH // accent preferences, so a single drag across the hue row spent the whole // gesture in apply-and-verify round trips and left the desktop repainting // behind the pointer. The sliders now track the drag locally and one commit // lands once it stops. import QtQuick import qs.config import qs.services import qs.widgets import "../../services/ThemeProfileModel.js" as ThemeProfileModel Column { id: editor width: parent ? parent.width : 620 spacing: 0 // The pair being dragged; null means "nothing pending, show what is stored". property var pending: null readonly property string shownAccent: editor.pending ? editor.pending.accent : String(ThemeProfiles.activeProfile.accent) readonly property string shownSecondary: editor.pending ? editor.pending.secondary : String(ThemeProfiles.activeProfile.secondary) readonly property var primaryHsv: ThemeProfileModel.hexToHsv(editor.shownAccent) || ({ h: 0, s: 0, v: 0 }) readonly property var secondaryHsv: ThemeProfileModel.hexToHsv(editor.shownSecondary) || ({ h: 0, s: 0, v: 0 }) function changeChannel(target: string, channel: string, ratio: real): void { const source = target === "primary" ? editor.primaryHsv : editor.secondaryHsv; const next = { h: source.h, s: source.s, v: source.v }; next[channel] = Math.round(Math.max(0, Math.min(1, ratio)) * (channel === "h" ? 360 : 100)); const changed = ThemeProfileModel.hsvToHex(next.h, next.s, next.v); if (!changed) return; editor.pending = { accent: target === "primary" ? changed : editor.shownAccent, secondary: target === "secondary" ? changed : editor.shownSecondary }; commitTimer.restart(); } component HsvRow: SettingRow { id: row required property string target required property string channel required property real channelValue required property real channelMaximum property string suffix: "%" controlWidth: 280 Item { id: keyboardSlider anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter width: 270 height: 32 activeFocusOnTab: true Accessible.role: Accessible.Slider Accessible.name: row.label Accessible.description: Math.round(row.channelValue) + row.suffix + ", range 0 to " + row.channelMaximum Accessible.focusable: true Accessible.focused: activeFocus Accessible.onIncreaseAction: keyboardSlider.step(1) Accessible.onDecreaseAction: keyboardSlider.step(-1) function step(direction: int): void { const value = Math.max(0, Math.min(row.channelMaximum, row.channelValue + direction)); editor.changeChannel(row.target, row.channel, value / row.channelMaximum); } Keys.onPressed: event => { if (event.key === Qt.Key_Left || event.key === Qt.Key_Down) { keyboardSlider.step(-1); event.accepted = true; } else if (event.key === Qt.Key_Right || event.key === Qt.Key_Up) { keyboardSlider.step(1); event.accepted = true; } else if (event.key === Qt.Key_Home) { editor.changeChannel(row.target, row.channel, 0); event.accepted = true; } else if (event.key === Qt.Key_End) { editor.changeChannel(row.target, row.channel, 1); event.accepted = true; } } Rectangle { anchors.left: parent.left anchors.right: readout.left anchors.rightMargin: 10 anchors.verticalCenter: parent.verticalCenter height: 24 radius: 9 color: "transparent" border.width: keyboardSlider.activeFocus ? 2 : 1 border.color: keyboardSlider.activeFocus ? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.08) ValueSlider { anchors.fill: parent anchors.margins: 4 value: row.channelMaximum > 0 ? row.channelValue / row.channelMaximum : 0 onMoved: ratio => editor.changeChannel(row.target, row.channel, ratio) } } Text { id: readout anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter width: 56 horizontalAlignment: Text.AlignRight text: Math.round(row.channelValue) + row.suffix color: Theme.fgDim font.family: Theme.fontFamily font.features: Theme.tabularFigures font.pixelSize: Theme.fontSizeSmall } } } HsvRow { target: "primary"; channel: "h"; channelValue: editor.primaryHsv.h; channelMaximum: 360 suffix: "°"; label: "Primary hue"; detail: "Colour family, measured from 0 to 360 degrees" } HsvRow { target: "primary"; channel: "s"; channelValue: editor.primaryHsv.s; channelMaximum: 100 label: "Primary saturation"; detail: "Colour intensity from grey to vivid" } HsvRow { target: "primary"; channel: "v"; channelValue: editor.primaryHsv.v; channelMaximum: 100 label: "Primary value"; detail: "Brightness from black to full colour" } HsvRow { target: "secondary"; channel: "h"; channelValue: editor.secondaryHsv.h; channelMaximum: 360 suffix: "°"; label: "Secondary hue"; detail: "Colour family at the far end of the Prism gradient" } HsvRow { target: "secondary"; channel: "s"; channelValue: editor.secondaryHsv.s; channelMaximum: 100 label: "Secondary saturation"; detail: "Colour intensity from grey to vivid" } HsvRow { target: "secondary"; channel: "v"; channelValue: editor.secondaryHsv.v; channelMaximum: 100 label: "Secondary value"; detail: "Brightness from black to full colour" divider: false } Timer { id: commitTimer interval: 200 onTriggered: { if (editor.pending === null) return; ThemeProfiles.setAccentPair(editor.pending.accent, editor.pending.secondary); releaseTimer.restart(); } } // Hands the sliders back to the stored pair. If the write was refused they // snap back to what is really in effect rather than showing a colour // nothing accepted. Timer { id: releaseTimer interval: 160 onTriggered: editor.pending = null } }