626 lines
23 KiB
QML
626 lines
23 KiB
QML
// ColorPickerWindow.qml
|
||
import QtQuick
|
||
import QtQuick.Window
|
||
import QtQuick.Controls
|
||
import QtQuick.Layouts
|
||
import QtQuick.Controls.Material
|
||
import TactileIPC 1.0
|
||
|
||
Window {
|
||
id: root
|
||
width: 360
|
||
height: 650
|
||
visible: false
|
||
modality: Qt.ApplicationModal
|
||
flags: Qt.Dialog
|
||
title: qsTr("颜色选择")
|
||
Material.theme: Backend.lightMode ? Material.Light : Material.Dark
|
||
Material.accent: Material.Green
|
||
Material.primary: Material.Green
|
||
|
||
readonly property bool isDark: !Backend.lightMode
|
||
readonly property color windowBg: isDark ? "#1F1F1F" : "#F7F7F7"
|
||
readonly property color panelBorder: isDark ? "#2E2E2E" : "#D9D9D9"
|
||
readonly property color surfaceBorder: isDark ? "#343434" : "#CFCFCF"
|
||
readonly property color textPrimary: isDark ? "#EDEDED" : "#1F1F1F"
|
||
readonly property color textSecondary: isDark ? "#D8D8D8" : "#616161"
|
||
readonly property color textMuted: isDark ? "#BEBEBE" : "#6E6E6E"
|
||
readonly property color controlBg: isDark ? "#2A2A2A" : "#FFFFFF"
|
||
readonly property color controlBorder: isDark ? "#3A3A3A" : "#CFCFCF"
|
||
readonly property color highlightBg: isDark ? "#55FFFFFF" : "#33000000"
|
||
readonly property color highlightText: isDark ? "#111111" : "#FFFFFF"
|
||
readonly property color indicatorBorder: isDark ? "#6A6A6A" : "#9E9E9E"
|
||
readonly property color accentColor: Material.color(Material.Green)
|
||
|
||
// ===== API =====
|
||
property color color: "#FF7032D2" // AARRGGBB
|
||
signal accepted(color c)
|
||
signal rejected()
|
||
|
||
function openWith(c) {
|
||
syncFromColor(c ?? root.color)
|
||
visible = true
|
||
requestActivate()
|
||
}
|
||
|
||
Keys.onEscapePressed: {
|
||
visible = false
|
||
rejected()
|
||
}
|
||
|
||
// ===== internal HSV(A) =====
|
||
property real h: 0.75 // 0..1
|
||
property real s: 0.45
|
||
property real v: 0.82
|
||
property real a: 1.0
|
||
|
||
property bool _lock: false
|
||
|
||
function clamp01(x) { return Math.max(0, Math.min(1, x)) }
|
||
function clampInt(x, lo, hi) { return Math.max(lo, Math.min(hi, Math.round(x))) }
|
||
|
||
function hsvToRgb(hh, ss, vv) {
|
||
const h6 = (hh % 1) * 6
|
||
const c = vv * ss
|
||
const x = c * (1 - Math.abs((h6 % 2) - 1))
|
||
const m = vv - c
|
||
let r1=0, g1=0, b1=0
|
||
if (0 <= h6 && h6 < 1) { r1=c; g1=x; b1=0 }
|
||
else if (1 <= h6 && h6 < 2) { r1=x; g1=c; b1=0 }
|
||
else if (2 <= h6 && h6 < 3) { r1=0; g1=c; b1=x }
|
||
else if (3 <= h6 && h6 < 4) { r1=0; g1=x; b1=c }
|
||
else if (4 <= h6 && h6 < 5) { r1=x; g1=0; b1=c }
|
||
else { r1=c; g1=0; b1=x }
|
||
return { r: r1 + m, g: g1 + m, b: b1 + m }
|
||
}
|
||
|
||
function rgbToHsv(r, g, b) {
|
||
const max = Math.max(r, g, b)
|
||
const min = Math.min(r, g, b)
|
||
const d = max - min
|
||
let hh = 0
|
||
if (d !== 0) {
|
||
if (max === r) hh = ((g - b) / d) % 6
|
||
else if (max === g) hh = (b - r) / d + 2
|
||
else hh = (r - g) / d + 4
|
||
hh /= 6
|
||
if (hh < 0) hh += 1
|
||
}
|
||
const ss = (max === 0) ? 0 : d / max
|
||
const vv = max
|
||
return { h: hh, s: ss, v: vv }
|
||
}
|
||
|
||
function toHex2(v01) {
|
||
const n = clampInt(clamp01(v01) * 255, 0, 255)
|
||
const s = n.toString(16).toUpperCase()
|
||
return (s.length === 1) ? ("0" + s) : s
|
||
}
|
||
|
||
function colorToHexAARRGGBB(c) {
|
||
return "#" + toHex2(c.a) + toHex2(c.r) + toHex2(c.g) + toHex2(c.b)
|
||
}
|
||
|
||
function parseHex(str) {
|
||
let t = ("" + str).trim()
|
||
if (t.startsWith("0x") || t.startsWith("0X")) t = t.slice(2)
|
||
if (t.startsWith("#")) t = t.slice(1)
|
||
if (t.length === 6) {
|
||
const rr = parseInt(t.slice(0,2), 16)
|
||
const gg = parseInt(t.slice(2,4), 16)
|
||
const bb = parseInt(t.slice(4,6), 16)
|
||
if ([rr,gg,bb].some(x => isNaN(x))) return null
|
||
return Qt.rgba(rr/255, gg/255, bb/255, 1)
|
||
}
|
||
if (t.length === 8) {
|
||
const aa = parseInt(t.slice(0,2), 16)
|
||
const rr = parseInt(t.slice(2,4), 16)
|
||
const gg = parseInt(t.slice(4,6), 16)
|
||
const bb = parseInt(t.slice(6,8), 16)
|
||
if ([aa,rr,gg,bb].some(x => isNaN(x))) return null
|
||
return Qt.rgba(rr/255, gg/255, bb/255, aa/255)
|
||
}
|
||
return null
|
||
}
|
||
|
||
function syncFromHSV() {
|
||
if (_lock) return
|
||
_lock = true
|
||
const rgb = hsvToRgb(h, s, v)
|
||
color = Qt.rgba(rgb.r, rgb.g, rgb.b, a)
|
||
|
||
// 更新 UI
|
||
hexField.text = colorToHexAARRGGBB(color)
|
||
alphaPercent.text = clampInt(a*100, 0, 100) + "%"
|
||
|
||
rField.value = clampInt(color.r * 255, 0, 255)
|
||
gField.value = clampInt(color.g * 255, 0, 255)
|
||
bField.value = clampInt(color.b * 255, 0, 255)
|
||
|
||
// HSV 显示用度/百分比
|
||
hField.value = clampInt(h * 360, 0, 360)
|
||
sField.value = clampInt(s * 100, 0, 100)
|
||
vField.value = clampInt(v * 100, 0, 100)
|
||
|
||
svCanvas.requestPaint()
|
||
hueCanvas.requestPaint()
|
||
alphaCanvas.requestPaint()
|
||
_lock = false
|
||
}
|
||
|
||
function syncFromColor(c) {
|
||
if (_lock) return
|
||
_lock = true
|
||
const hsv = rgbToHsv(c.r, c.g, c.b)
|
||
h = hsv.h; s = hsv.s; v = hsv.v; a = c.a
|
||
color = Qt.rgba(c.r, c.g, c.b, a)
|
||
|
||
hexField.text = colorToHexAARRGGBB(color)
|
||
alphaPercent.text = clampInt(a*100, 0, 100) + "%"
|
||
|
||
rField.value = clampInt(c.r * 255, 0, 255)
|
||
gField.value = clampInt(c.g * 255, 0, 255)
|
||
bField.value = clampInt(c.b * 255, 0, 255)
|
||
|
||
hField.value = clampInt(h * 360, 0, 360)
|
||
sField.value = clampInt(s * 100, 0, 100)
|
||
vField.value = clampInt(v * 100, 0, 100)
|
||
|
||
svCanvas.requestPaint()
|
||
hueCanvas.requestPaint()
|
||
alphaCanvas.requestPaint()
|
||
_lock = false
|
||
}
|
||
|
||
function applyRGB(r255, g255, b255) {
|
||
if (_lock) return
|
||
const rr = clampInt(r255, 0, 255) / 255
|
||
const gg = clampInt(g255, 0, 255) / 255
|
||
const bb = clampInt(b255, 0, 255) / 255
|
||
const hsv = rgbToHsv(rr, gg, bb)
|
||
h = hsv.h; s = hsv.s; v = hsv.v
|
||
syncFromHSV()
|
||
}
|
||
|
||
function applyHSV(hDeg, sPct, vPct) {
|
||
if (_lock) return
|
||
h = clamp01(hDeg / 360)
|
||
s = clamp01(sPct / 100)
|
||
v = clamp01(vPct / 100)
|
||
syncFromHSV()
|
||
}
|
||
|
||
// 初始同步
|
||
Component.onCompleted: syncFromColor(root.color)
|
||
|
||
// ====== UI: 深色窗口面板(不是卡片)======
|
||
Rectangle {
|
||
anchors.fill: parent
|
||
radius: 0
|
||
color: root.windowBg
|
||
border.width: 1
|
||
border.color: root.panelBorder
|
||
|
||
ColumnLayout {
|
||
anchors.fill: parent
|
||
anchors.margins: 10
|
||
spacing: 10
|
||
|
||
// 顶部:标题 + 勾选(参考图右上角)
|
||
// RowLayout {
|
||
// Layout.fillWidth: true
|
||
// spacing: 8
|
||
|
||
// Label {
|
||
// text: root.title
|
||
// color: root.textPrimary
|
||
// font.pixelSize: 14
|
||
// font.weight: Font.DemiBold
|
||
// Layout.fillWidth: true
|
||
// }
|
||
|
||
// CheckBox {
|
||
// id: alphaToggle
|
||
// checked: true
|
||
|
||
// contentItem: Label {
|
||
// text: qsTr("实时预览")
|
||
// color: root.textSecondary
|
||
// verticalAlignment: Text.AlignVCenter
|
||
// elide: Text.ElideRight
|
||
// }
|
||
|
||
// indicator: Rectangle {
|
||
// implicitWidth: 16
|
||
// implicitHeight: 16
|
||
// radius: 3
|
||
// border.width: 1
|
||
// border.color: root.indicatorBorder
|
||
// color: alphaToggle.checked ? root.accentColor : "transparent"
|
||
|
||
// // 选中勾
|
||
// Canvas {
|
||
// anchors.fill: parent
|
||
// onPaint: {
|
||
// const ctx = getContext("2d")
|
||
// ctx.clearRect(0,0,width,height)
|
||
// if (!alphaToggle.checked) return
|
||
// ctx.strokeStyle = "white"
|
||
// ctx.lineWidth = 2
|
||
// ctx.lineCap = "round"
|
||
// ctx.beginPath()
|
||
// ctx.moveTo(width*0.25, height*0.55)
|
||
// ctx.lineTo(width*0.45, height*0.72)
|
||
// ctx.lineTo(width*0.78, height*0.30)
|
||
// ctx.stroke()
|
||
// }
|
||
// }
|
||
// }
|
||
// }
|
||
|
||
|
||
// ToolButton {
|
||
// text: "✕"
|
||
// onClicked: { root.visible = false; root.rejected() }
|
||
// }
|
||
// }
|
||
|
||
// HSV 面板
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
Layout.preferredHeight: 185
|
||
radius: 8
|
||
border.width: 1
|
||
border.color: root.surfaceBorder
|
||
clip: true
|
||
|
||
Canvas {
|
||
id: svCanvas
|
||
anchors.fill: parent
|
||
onPaint: {
|
||
const ctx = getContext("2d")
|
||
const w = width, hh = height
|
||
ctx.clearRect(0,0,w,hh)
|
||
|
||
const rgbHue = root.hsvToRgb(root.h, 1, 1)
|
||
|
||
// X: white -> hue
|
||
const gx = ctx.createLinearGradient(0,0,w,0)
|
||
gx.addColorStop(0, "rgb(255,255,255)")
|
||
gx.addColorStop(1, "rgb(" + Math.round(rgbHue.r*255) + "," + Math.round(rgbHue.g*255) + "," + Math.round(rgbHue.b*255) + ")")
|
||
ctx.fillStyle = gx
|
||
ctx.fillRect(0,0,w,hh)
|
||
|
||
// Y: transparent -> black
|
||
const gy = ctx.createLinearGradient(0,0,0,hh)
|
||
gy.addColorStop(0, "rgba(0,0,0,0)")
|
||
gy.addColorStop(1, "rgba(0,0,0,1)")
|
||
ctx.fillStyle = gy
|
||
ctx.fillRect(0,0,w,hh)
|
||
|
||
// handle
|
||
const x = root.s * w
|
||
const y = (1 - root.v) * hh
|
||
ctx.beginPath()
|
||
ctx.arc(x, y, 7, 0, Math.PI*2)
|
||
ctx.lineWidth = 2
|
||
ctx.strokeStyle = "rgba(255,255,255,0.95)"
|
||
ctx.stroke()
|
||
ctx.beginPath()
|
||
ctx.arc(x, y, 6, 0, Math.PI*2)
|
||
ctx.lineWidth = 1
|
||
ctx.strokeStyle = "rgba(0,0,0,0.55)"
|
||
ctx.stroke()
|
||
}
|
||
}
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
function apply(mx, my) {
|
||
root.s = root.clamp01(mx / width)
|
||
root.v = root.clamp01(1 - (my / height))
|
||
root.syncFromHSV()
|
||
}
|
||
onPressed: (e) => apply(e.x, e.y)
|
||
onPositionChanged: (e) => { if (pressed) apply(e.x, e.y) }
|
||
}
|
||
}
|
||
|
||
// Hue 彩虹条(参考图那种)
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
Layout.preferredHeight: 16
|
||
radius: 8
|
||
border.width: 1
|
||
border.color: root.surfaceBorder
|
||
clip: true
|
||
|
||
Canvas {
|
||
id: hueCanvas
|
||
anchors.fill: parent
|
||
onPaint: {
|
||
const ctx = getContext("2d")
|
||
const w = width, hh = height
|
||
ctx.clearRect(0,0,w,hh)
|
||
const grad = ctx.createLinearGradient(0,0,w,0)
|
||
for (let i=0; i<=6; i++) {
|
||
const t = i/6
|
||
const rgb = root.hsvToRgb(t, 1, 1)
|
||
grad.addColorStop(t, "rgb(" + Math.round(rgb.r*255) + "," + Math.round(rgb.g*255) + "," + Math.round(rgb.b*255) + ")")
|
||
}
|
||
ctx.fillStyle = grad
|
||
ctx.fillRect(0,0,w,hh)
|
||
|
||
const x = root.h * w
|
||
ctx.beginPath()
|
||
ctx.arc(x, hh/2, 7, 0, Math.PI*2)
|
||
ctx.lineWidth = 2
|
||
ctx.strokeStyle = "rgba(255,255,255,0.95)"
|
||
ctx.stroke()
|
||
ctx.beginPath()
|
||
ctx.arc(x, hh/2, 6, 0, Math.PI*2)
|
||
ctx.lineWidth = 1
|
||
ctx.strokeStyle = "rgba(0,0,0,0.55)"
|
||
ctx.stroke()
|
||
}
|
||
}
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
function apply(mx) {
|
||
root.h = root.clamp01(mx / width)
|
||
root.syncFromHSV()
|
||
}
|
||
onPressed: (e) => apply(e.x)
|
||
onPositionChanged: (e) => { if (pressed) apply(e.x) }
|
||
}
|
||
}
|
||
|
||
// Alpha 条(棋盘 + 渐变 + 右侧圆点)
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
Layout.preferredHeight: 16
|
||
radius: 8
|
||
border.width: 1
|
||
border.color: root.surfaceBorder
|
||
clip: true
|
||
|
||
// checker
|
||
Canvas {
|
||
id: checkerCanvas
|
||
anchors.fill: parent
|
||
onPaint: {
|
||
const ctx = getContext("2d")
|
||
const w = width, hh = height
|
||
ctx.clearRect(0,0,w,hh)
|
||
const s = 8
|
||
for (let y=0; y<hh; y+=s) {
|
||
for (let x=0; x<w; x+=s) {
|
||
const on = ((x/s + y/s) % 2) === 0
|
||
ctx.fillStyle = on ? "rgba(255,255,255,0.16)" : "rgba(0,0,0,0.0)"
|
||
ctx.fillRect(x,y,s,s)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Canvas {
|
||
id: alphaCanvas
|
||
anchors.fill: parent
|
||
onPaint: {
|
||
const ctx = getContext("2d")
|
||
const w = width, hh = height
|
||
ctx.clearRect(0,0,w,hh)
|
||
|
||
const rgb = root.hsvToRgb(root.h, root.s, root.v)
|
||
const grad = ctx.createLinearGradient(0,0,w,0)
|
||
grad.addColorStop(0, "rgba(" + Math.round(rgb.r*255) + "," + Math.round(rgb.g*255) + "," + Math.round(rgb.b*255) + ",0)")
|
||
grad.addColorStop(1, "rgba(" + Math.round(rgb.r*255) + "," + Math.round(rgb.g*255) + "," + Math.round(rgb.b*255) + ",1)")
|
||
ctx.fillStyle = grad
|
||
ctx.fillRect(0,0,w,hh)
|
||
|
||
const x = root.a * w
|
||
ctx.beginPath()
|
||
ctx.arc(x, hh/2, 7, 0, Math.PI*2)
|
||
ctx.lineWidth = 2
|
||
ctx.strokeStyle = "rgba(255,255,255,0.95)"
|
||
ctx.stroke()
|
||
ctx.beginPath()
|
||
ctx.arc(x, hh/2, 6, 0, Math.PI*2)
|
||
ctx.lineWidth = 1
|
||
ctx.strokeStyle = "rgba(0,0,0,0.55)"
|
||
ctx.stroke()
|
||
}
|
||
}
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
function apply(mx) {
|
||
root.a = root.clamp01(mx / width)
|
||
root.syncFromHSV()
|
||
}
|
||
onPressed: (e) => apply(e.x)
|
||
onPositionChanged: (e) => { if (pressed) apply(e.x) }
|
||
}
|
||
}
|
||
|
||
// 下方:色块 + Hex + 透明度百分比
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 10
|
||
|
||
// 预览色块(带棋盘底)
|
||
Rectangle {
|
||
width: 44
|
||
height: 44
|
||
radius: 8
|
||
border.width: 1
|
||
border.color: root.surfaceBorder
|
||
clip: true
|
||
|
||
Canvas {
|
||
anchors.fill: parent
|
||
onPaint: {
|
||
const ctx = getContext("2d")
|
||
const w = width, hh = height
|
||
ctx.clearRect(0,0,w,hh)
|
||
const s = 10
|
||
for (let y=0; y<hh; y+=s) {
|
||
for (let x=0; x<w; x+=s) {
|
||
const on = ((x/s + y/s) % 2) === 0
|
||
ctx.fillStyle = on ? "rgba(255,255,255,0.14)" : "rgba(0,0,0,0.0)"
|
||
ctx.fillRect(x,y,s,s)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
anchors.fill: parent
|
||
color: root.color
|
||
}
|
||
}
|
||
|
||
TextField {
|
||
id: hexField
|
||
Layout.fillWidth: true
|
||
text: "#FF7032D2"
|
||
placeholderText: "#AARRGGBB"
|
||
inputMethodHints: Qt.ImhPreferUppercase | Qt.ImhNoPredictiveText
|
||
|
||
// 用 palette,而不是 color/selectionColor/selectedTextColor
|
||
palette.text: root.textPrimary
|
||
palette.placeholderText: root.textMuted
|
||
palette.highlight: root.highlightBg
|
||
palette.highlightedText: root.highlightText
|
||
palette.base: root.controlBg // 输入框底色(有的 style 会用它)
|
||
palette.buttonText: root.textPrimary
|
||
|
||
background: Rectangle {
|
||
radius: 8
|
||
color: root.controlBg
|
||
border.width: 1
|
||
border.color: root.controlBorder
|
||
}
|
||
|
||
function applyIfValid() {
|
||
const c = root.parseHex(text)
|
||
if (c) root.syncFromColor(c)
|
||
}
|
||
onTextEdited: applyIfValid()
|
||
onEditingFinished: {
|
||
const c = root.parseHex(text)
|
||
if (c) root.syncFromColor(c)
|
||
else text = root.colorToHexAARRGGBB(root.color)
|
||
}
|
||
onAccepted: {
|
||
applyIfValid()
|
||
focus = false
|
||
}
|
||
}
|
||
|
||
|
||
Rectangle {
|
||
width: 54
|
||
height: 44
|
||
radius: 8
|
||
color: root.controlBg
|
||
border.width: 1
|
||
border.color: root.controlBorder
|
||
Label {
|
||
id: alphaPercent
|
||
anchors.centerIn: parent
|
||
text: "100%"
|
||
color: root.textPrimary
|
||
font.pixelSize: 12
|
||
}
|
||
}
|
||
}
|
||
|
||
// RGB / HSV 数值区(像参考图那样两行块)
|
||
GridLayout {
|
||
Layout.fillWidth: true
|
||
columns: 3
|
||
columnSpacing: 8
|
||
rowSpacing: 8
|
||
|
||
// ---- Row1: RGB ----
|
||
Label { text: "RGB"; color: root.textMuted; Layout.alignment: Qt.AlignVCenter }
|
||
SpinBox {
|
||
id: rField
|
||
from: 0; to: 255; editable: true
|
||
value: 112
|
||
Layout.fillWidth: true
|
||
onValueModified: root.applyRGB(value, gField.value, bField.value)
|
||
}
|
||
SpinBox {
|
||
id: gField
|
||
from: 0; to: 255; editable: true
|
||
value: 50
|
||
Layout.fillWidth: true
|
||
onValueModified: root.applyRGB(rField.value, value, bField.value)
|
||
}
|
||
Item { width: 1; height: 1 } // 占位,让下一行对齐
|
||
SpinBox {
|
||
id: bField
|
||
from: 0; to: 255; editable: true
|
||
value: 210
|
||
Layout.fillWidth: true
|
||
Layout.columnSpan: 2
|
||
onValueModified: root.applyRGB(rField.value, gField.value, value)
|
||
}
|
||
|
||
// ---- Row2: HSV ----
|
||
Label { text: "HSV"; color: root.textMuted; Layout.alignment: Qt.AlignVCenter }
|
||
SpinBox {
|
||
id: hField
|
||
from: 0; to: 360; editable: true
|
||
value: 263
|
||
Layout.fillWidth: true
|
||
onValueModified: root.applyHSV(value, sField.value, vField.value)
|
||
}
|
||
SpinBox {
|
||
id: sField
|
||
from: 0; to: 100; editable: true
|
||
value: 64
|
||
Layout.fillWidth: true
|
||
onValueModified: root.applyHSV(hField.value, value, vField.value)
|
||
}
|
||
Item { width: 1; height: 1 }
|
||
SpinBox {
|
||
id: vField
|
||
from: 0; to: 100; editable: true
|
||
value: 51
|
||
Layout.fillWidth: true
|
||
Layout.columnSpan: 2
|
||
onValueModified: root.applyHSV(hField.value, sField.value, value)
|
||
}
|
||
}
|
||
|
||
// 底部按钮(像窗口)
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 10
|
||
|
||
Item { Layout.fillWidth: true }
|
||
|
||
Button {
|
||
text: qsTr("Cancel")
|
||
onClicked: { root.visible = false; root.rejected() }
|
||
}
|
||
Button {
|
||
text: qsTr("OK")
|
||
highlighted: true
|
||
onClicked: { root.visible = false; root.accepted(root.color) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 每次 HSV 改变,UI 同步
|
||
onHChanged: if (!_lock) syncFromHSV()
|
||
onSChanged: if (!_lock) syncFromHSV()
|
||
onVChanged: if (!_lock) syncFromHSV()
|
||
onAChanged: if (!_lock) syncFromHSV()
|
||
}
|