Files
quickdots/bar/modules/Crypto.qml

1201 lines
62 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import Quickshell.Io
import "../../config" as Cfg
Rectangle {
id: root
property real panelRadius: Cfg.Config.radius / 2
property int borderWidth: Cfg.Config.panelBorderWidth
property bool isTop: true
property var targetScreen: null
property int barHeight: Cfg.Config.barHeight
property int barMargin: Cfg.Config.margin
readonly property var t: Cfg.Config.theme
readonly property var cfg: Cfg.Config
readonly property string configPath:
Quickshell.env("HOME") + "/.config/quickshell/config/config.qml"
Process {
id: cryptoConfigWriter
running: false
onExited: (code) => {
if (code !== 0)
console.warn("[Crypto] Failed to patch config.qml (exit " + code + ")")
}
}
width: chipRow.implicitWidth + 24
height: cfg.moduleHeight
radius: panelRadius
color: hoverHandler.hovered ? (t.cryptoBgHover ?? "#15ffffff") : (t.cryptoBg ?? "transparent")
border.color: (hoverHandler.hovered || cryptoPopup.popupOpen) ? (t.cryptoBorderHover ?? t.accent ?? "#ffc19375") : (t.cryptoBorder ?? "transparent")
border.width: borderWidth
Behavior on width { NumberAnimation { duration: 250; easing.type: Easing.OutCubic } }
Behavior on color { ColorAnimation { duration: 150 } }
Behavior on border.color { ColorAnimation { duration: 150 } }
property var localPairs: [{ coin: cfg.cryptoCoinId ?? "bitcoin", currency: cfg.cryptoVsCurrency ?? "usd" }]
property int activeCoinIndex: 0
readonly property string localCoinId: localPairs.length > 0
? localPairs[activeCoinIndex % localPairs.length].coin
: (cfg.cryptoCoinId ?? "bitcoin")
readonly property string localActiveCurrency: localPairs.length > 0
? localPairs[activeCoinIndex % localPairs.length].currency
: (cfg.cryptoVsCurrency ?? "usd")
property string localVsCur: cfg.cryptoVsCurrency ?? "usd"
property int localDecimals: cfg.cryptoDecimals ?? 1
property int localRefreshSec: cfg.cryptoRefreshSec ?? 60
property bool localShowIcon: cfg.cryptoShowIcon ?? true
function resolveWithPersistence() {
const cfgCoin = cfg.cryptoCoinId ?? "bitcoin"
const cfgCur = cfg.cryptoVsCurrency ?? "usd"
let cfgPairs
if (typeof cfg.cryptoPairs !== "undefined" && Array.isArray(cfg.cryptoPairs) && cfg.cryptoPairs.length > 0) {
cfgPairs = cfg.cryptoPairs
} else if (typeof cfg.cryptoCoinIds !== "undefined" && Array.isArray(cfg.cryptoCoinIds) && cfg.cryptoCoinIds.length > 0) {
cfgPairs = cfg.cryptoCoinIds.map(c => ({ coin: c, currency: cfgCur }))
} else {
cfgPairs = [{ coin: cfgCoin, currency: cfgCur }]
}
root.localPairs = cfgPairs
root.localVsCur = cfgPairs.length > 0 ? cfgPairs[0].currency : cfgCur
root.displayCurrency = cfgPairs.length > 0 ? cfgPairs[0].currency : cfgCur
root.activeCoinIndex = 0
root.localDecimals = cfg.cryptoDecimals ?? 1
root.localRefreshSec = cfg.cryptoRefreshSec ?? 60
root.localShowIcon = cfg.cryptoShowIcon ?? true
}
function persistSettings() {
const pairs = root.localPairs
const decimals = Math.round(root.localDecimals)
const refreshSec = Math.round(root.localRefreshSec)
const showIcon = root.localShowIcon
const cfgPath = root.configPath
const pairsQml = "[" + pairs.map(p =>
'{ coin: "' + p.coin + '", currency: "' + p.currency + '" }'
).join(", ") + "]"
const sh =
"CFG=" + JSON.stringify(cfgPath) + "\n" +
"sed -i" +
" -e 's/\\(property int[ ]\\+cryptoDecimals:[ ]\\+\\)[0-9.]\\+/\\1" + decimals.toString() + "/'" +
" -e 's/\\(property int[ ]\\+cryptoRefreshSec:[ ]\\+\\)[0-9.]\\+/\\1" + refreshSec.toString() + "/'" +
" -e 's/\\(property bool[ ]\\+cryptoShowIcon:[ ]\\+\\)\\(true\\|false\\)/\\1" + (showIcon ? "true" : "false") + "/'" +
" \"$CFG\"\n" +
"QS_PAIRS='" + pairsQml + "'\n" +
"awk -v pairs=\"$QS_PAIRS\" '" +
"/property var cryptoPairs:/{print \" property var cryptoPairs: \"pairs;sk=/\\]/?0:1;next}" +
"sk&&/\\]/{sk=0;next}" +
"sk{next}" +
"{print}'" +
" \"$CFG\" > \"${CFG}.tmp\" && mv \"${CFG}.tmp\" \"$CFG\"\n"
cryptoConfigWriter.command = ["/bin/sh", "-c", sh]
cryptoConfigWriter.running = true
}
property real price: -1
property real change24h: 0
property real high24h: 0
property real low24h: 0
property real marketCap: 0
property real volume24h: 0
property int rank: 0
property real circulatingSupply: 0
property real ath: 0
property real athChange: 0
property string coinSymbol: localCoinId.toUpperCase().slice(0, 4)
property string coinName: ""
property string lastUpdated: ""
property string displayCurrency: cfg.cryptoVsCurrency ?? "usd"
property bool loading: false
property bool loadingVisible: false
property bool hasError: false
Timer {
id: minLoadTimer
interval: 2000
repeat: false
onTriggered: { if (!root.loading) root.loadingVisible = false }
}
onLoadingChanged: {
if (loading) {
loadingVisible = true
minLoadTimer.restart()
} else {
if (!minLoadTimer.running)
loadingVisible = false
}
}
function compactPrice(val) {
if (val < 0) return "—"
const factor = Math.pow(10, localDecimals)
if (val >= 1_000_000) {
const n = Math.trunc(val * factor / 1_000_000)
return (n / factor).toFixed(localDecimals) + "m"
}
if (val >= 1_000) {
const n = Math.trunc(val * factor / 1_000)
return (n / factor).toFixed(localDecimals) + "k"
}
return val.toFixed(Math.max(localDecimals, 2))
}
function fullPrice(val) {
if (val < 0) return "—"
return val.toLocaleString(Qt.locale(), "f", 2)
}
function compactLarge(val) {
if (val <= 0) return "—"
if (val >= 1_000_000_000) return (val / 1_000_000_000).toFixed(2) + "B"
if (val >= 1_000_000) return (val / 1_000_000).toFixed(2) + "M"
if (val >= 1_000) return (val / 1_000).toFixed(2) + "k"
return val.toFixed(2)
}
function fetchPrice() {
if (loading) return
loading = true
hasError = false
const url =
"https://api.coingecko.com/api/v3/coins/markets" +
"?vs_currency=" + encodeURIComponent(localActiveCurrency || "usd") +
"&ids=" + encodeURIComponent(localCoinId || "bitcoin") +
"&order=market_cap_desc&per_page=1&page=1" +
"&sparkline=false&price_change_percentage=24h"
const xhr = new XMLHttpRequest()
xhr.onreadystatechange = function () {
if (xhr.readyState !== XMLHttpRequest.DONE) return
loading = false
if (xhr.status !== 200) { hasError = true; return }
try {
const data = JSON.parse(xhr.responseText)
if (!data || data.length === 0) { hasError = true; return }
const coin = data[0]
price = coin.current_price ?? -1
change24h = coin.price_change_percentage_24h ?? 0
high24h = coin.high_24h ?? 0
low24h = coin.low_24h ?? 0
marketCap = coin.market_cap ?? 0
volume24h = coin.total_volume ?? 0
rank = coin.market_cap_rank ?? 0
circulatingSupply = coin.circulating_supply ?? 0
ath = coin.ath ?? 0
athChange = coin.ath_change_percentage ?? 0
coinSymbol = (coin.symbol ?? localCoinId).toUpperCase()
coinName = coin.name ?? ""
lastUpdated = new Date().toLocaleTimeString(Qt.locale(), "HH:mm:ss")
root.displayCurrency = root.localActiveCurrency
root.activeCoinIndex = (root.activeCoinIndex + 1) % Math.max(1, root.localPairs.length)
} catch (_) {
hasError = true
}
}
xhr.open("GET", url)
xhr.setRequestHeader("Accept", "application/json")
xhr.send()
}
Component.onCompleted: { resolveWithPersistence(); fetchPrice() }
Timer {
id: refreshTimer
interval: root.localRefreshSec * 1000
running: true
repeat: true
onTriggered: root.fetchPrice()
}
onLocalRefreshSecChanged: refreshTimer.restart()
HoverHandler {
id: hoverHandler
cursorShape: Qt.PointingHandCursor
}
TapHandler { onTapped: cryptoPopup.toggle() }
RowLayout {
id: chipRow
anchors.centerIn: parent
spacing: 0
SequentialAnimation on opacity {
running: root.loadingVisible
loops: Animation.Infinite
NumberAnimation { from: 1; to: 0.25; duration: 500; easing.type: Easing.InOutSine }
NumberAnimation { from: 0.25; to: 1; duration: 500; easing.type: Easing.InOutSine }
}
Connections {
target: root
function onLoadingVisibleChanged() {
if (!root.loadingVisible) chipRow.opacity = 1
}
}
Text {
visible: root.localShowIcon
text: "󰿤"
color: t.cryptoIcon ?? t.accent ?? "#ffc19375"
font.pixelSize: 14
Layout.alignment: Qt.AlignVCenter
Layout.rightMargin: 7
}
Text {
text: root.coinSymbol + "/" + root.displayCurrency.toUpperCase() + ": "
color: t.cryptoLabel ?? t.textDim ?? "#997a8996"
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
}
Text {
text: root.hasError ? "err" : root.price < 0 ? "…" : root.compactPrice(root.price)
color: t.cryptoValue ?? t.textMain ?? "#ffe8eef2"
font.pixelSize: 13
font.bold: true
verticalAlignment: Text.AlignVCenter
}
Text {
visible: root.price >= 0
text: root.change24h >= 0 ? "▲" : "▼"
color: root.change24h >= 0 ? (t.cryptoUp ?? "#ffb4c9a1") : (t.cryptoDown ?? "#fff38ba8")
font.pixelSize: 9
verticalAlignment: Text.AlignVCenter
Layout.alignment: Qt.AlignVCenter
Layout.leftMargin: 4
Behavior on color { ColorAnimation { duration: 200 } }
}
}
PanelWindow {
id: cryptoPopup
screen: root.targetScreen
visible: false
color: "transparent"
property bool popupOpen: false
property real popupCenterX: 0
function open() { popupCenterX = root.mapToItem(null, 0, 0).x + root.width / 2; hideTimer.stop(); visible = true; popupOpen = true }
function close() { popupOpen = false; hideTimer.restart() }
function toggle() { popupOpen ? close() : open() }
Timer {
id: hideTimer
interval: Cfg.Config.popupAnimDuration + 70
onTriggered: cryptoPopup.visible = false
}
anchors { top: true; bottom: true; left: true; right: true }
WlrLayershell.layer: WlrLayer.Top
WlrLayershell.namespace: "main-shell-crypto"
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
mask: popupOpen ? null : _noInput
Region { id: _noInput }
TapHandler { onTapped: cryptoPopup.close() }
Item {
id: popClip
readonly property int cardW: 300
readonly property int screenPad: root.barMargin + 10
x: {
let cx = cryptoPopup.popupCenterX
return Math.max(screenPad,
Math.min(cx - cardW / 2, parent.width - cardW - screenPad))
}
width: cardW
anchors.top: root.isTop ? parent.top : undefined
anchors.bottom: root.isTop ? undefined : parent.bottom
anchors.topMargin: root.isTop ? (root.barHeight + 10) : 0
anchors.bottomMargin: root.isTop ? 0 : (root.barHeight + 10)
height: parent.height - root.barHeight - 10
clip: true
Rectangle {
id: card
width: parent.width
height: cardCol.implicitHeight + 24
anchors.top: root.isTop ? parent.top : undefined
anchors.bottom: root.isTop ? undefined : parent.bottom
transform: Translate {
y: cryptoPopup.popupOpen ? 0 : (root.isTop ? -card.height : card.height)
Behavior on y {
NumberAnimation { duration: Cfg.Config.popupAnimDuration; easing.type: Easing.OutExpo }
enabled: cryptoPopup.visible
}
}
opacity: cryptoPopup.popupOpen ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 200 } }
Behavior on height { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
color: t.cryptoPopupBg ?? t.calCardBg ?? "#cc0c0e12"
radius: Cfg.Config.popupRadius
border.color: t.cryptoPopupBorder ?? t.calCardBorder ?? "transparent"
border.width: Cfg.Config.popupBorderWidth
layer.enabled: true
MouseArea { anchors.fill: parent; propagateComposedEvents: false }
property int activeTab: 0
ColumnLayout {
id: cardCol
anchors { left: parent.left; right: parent.right; top: parent.top }
anchors.margins: 16
anchors.topMargin: 16
spacing: 10
Rectangle {
Layout.fillWidth: true
height: 30
radius: 10
color: "#15ffffff"
RowLayout {
anchors.fill: parent
anchors.margins: 3
spacing: 3
component TabBtn: Rectangle {
required property string label
required property int tabIndex
Layout.fillWidth: true
height: 24
radius: 8
readonly property bool active: card.activeTab === tabIndex
color: active
? (t.accent ?? "#ffc19375")
: (tm.containsMouse ? "#22ffffff" : "transparent")
Behavior on color { ColorAnimation { duration: 130 } }
Text {
anchors.centerIn: parent
text: parent.label
color: parent.active ? "#ff0c0e12" : (t.cryptoPopupDim ?? t.textDim ?? "#997a8996")
font.pixelSize: 11
font.bold: parent.active
Behavior on color { ColorAnimation { duration: 130 } }
}
MouseArea {
id: tm
anchors.fill: parent; hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: card.activeTab = tabIndex
}
}
TabBtn { label: "Price"; tabIndex: 0 }
TabBtn { label: "Settings"; tabIndex: 1 }
}
}
Item {
id: tabContainer
Layout.fillWidth: true
clip: true
Layout.preferredHeight: Math.max(priceContent.implicitHeight,
settingsContent.implicitHeight)
ColumnLayout {
id: priceContent
width: tabContainer.width
x: card.activeTab === 0 ? 0 : -tabContainer.width
opacity: card.activeTab === 0 ? 1.0 : 0.0
Behavior on x { NumberAnimation { duration: 260; easing.type: Easing.OutCubic } }
Behavior on opacity { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } }
spacing: 10
RowLayout {
Layout.fillWidth: true
Text {
text: "󰿤"
color: t.cryptoIcon ?? t.accent ?? "#ffc19375"
font.pixelSize: 22
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
spacing: 1
Layout.fillWidth: true
Layout.leftMargin: 8
Text {
text: root.coinSymbol + " / " + root.displayCurrency.toUpperCase()
color: t.cryptoPopupHeader ?? t.clockPopupHeader ?? "#ffc19375"
font.pixelSize: 15
font.bold: true
}
Text {
text: root.coinName !== "" ? root.coinName : (root.localCoinId.charAt(0).toUpperCase() + root.localCoinId.slice(1))
color: t.cryptoPopupDim ?? t.textDim ?? "#997a8996"
font.pixelSize: 11
}
}
Rectangle {
width: badge.implicitWidth + 12
height: 22; radius: 11
color: root.change24h >= 0 ? "#22b4c9a1" : "#22f38ba8"
Text {
id: badge
anchors.centerIn: parent
text: (root.change24h >= 0 ? "▲ +" : "▼ ") + root.change24h.toFixed(2) + "%"
color: root.change24h >= 0 ? (t.cryptoUp ?? "#ffb4c9a1") : (t.cryptoDown ?? "#fff38ba8")
font.pixelSize: 11
font.bold: true
}
}
}
Text {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
text: root.price < 0 ? "Loading…" : root.displayCurrency.toUpperCase() + " " + root.fullPrice(root.price)
color: t.cryptoPopupText ?? t.textMain ?? "#ffe8eef2"
font.pixelSize: 24
font.bold: true
font.family: "monospace"
}
Rectangle { Layout.fillWidth: true; height: 1; color: t.calSeparator ?? "#334a5d6e" }
component StatRow: RowLayout {
property string label: ""
property string value: ""
Layout.fillWidth: true
Text { text: label; color: t.cryptoPopupDim ?? t.textDim ?? "#997a8996"; font.pixelSize: 12; Layout.fillWidth: true }
Text { text: value; color: t.cryptoPopupText ?? t.textMain ?? "#ffe8eef2"; font.pixelSize: 12; font.bold: true; font.family: "monospace" }
}
StatRow { label: "Rank"; value: root.rank > 0 ? "#" + root.rank : "—" }
StatRow { label: "24h High"; value: root.high24h > 0 ? root.fullPrice(root.high24h) : "—" }
StatRow { label: "24h Low"; value: root.low24h > 0 ? root.fullPrice(root.low24h) : "—" }
StatRow { label: "Market Cap"; value: root.compactLarge(root.marketCap) }
StatRow { label: "24h Volume"; value: root.compactLarge(root.volume24h) }
StatRow { label: "Circ. Supply"; value: root.circulatingSupply > 0 ? root.compactLarge(root.circulatingSupply) + " " + root.coinSymbol : "—" }
RowLayout {
Layout.fillWidth: true
Text { text: "ATH"; color: t.cryptoPopupDim ?? t.textDim ?? "#997a8996"; font.pixelSize: 12; Layout.fillWidth: true }
Text {
text: root.ath > 0 ? root.fullPrice(root.ath) : "—"
color: t.cryptoPopupText ?? t.textMain ?? "#ffe8eef2"
font.pixelSize: 12; font.bold: true; font.family: "monospace"
}
Text {
visible: root.ath > 0
text: "(" + root.athChange.toFixed(2) + "%)"
color: t.cryptoDown ?? "#fff38ba8"
font.pixelSize: 11; font.bold: true; font.family: "monospace"
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 5
RowLayout {
Layout.fillWidth: true
Text {
text: "24h Range"
color: t.cryptoPopupDim ?? t.textDim ?? "#997a8996"
font.pixelSize: 12
Layout.fillWidth: true
}
Text {
visible: root.high24h > root.low24h && root.price >= 0
text: ((root.price - root.low24h) / (root.high24h - root.low24h) * 100).toFixed(1) + "% of range"
color: t.cryptoPopupDim ?? t.textDim ?? "#997a8996"
font.pixelSize: 11
}
}
Item {
Layout.fillWidth: true
height: 6
Rectangle {
anchors.fill: parent
radius: 3
color: "#1affffff"
}
Rectangle {
height: parent.height
radius: 3
color: root.change24h >= 0
? (t.cryptoUp ?? "#ffb4c9a1")
: (t.cryptoDown ?? "#fff38ba8")
width: (root.high24h > root.low24h && root.price >= 0)
? Math.max(radius * 2,
Math.min(parent.width,
(root.price - root.low24h) / (root.high24h - root.low24h)
* parent.width))
: 0
Behavior on width { NumberAnimation { duration: 500; easing.type: Easing.OutCubic } }
}
}
RowLayout {
Layout.fillWidth: true
Text {
text: root.low24h > 0 ? root.fullPrice(root.low24h) : "—"
color: t.cryptoDown ?? "#fff38ba8"
font.pixelSize: 10
font.family: "monospace"
}
Item { Layout.fillWidth: true }
Text {
text: root.high24h > 0 ? root.fullPrice(root.high24h) : "—"
color: t.cryptoUp ?? "#ffb4c9a1"
font.pixelSize: 10
font.family: "monospace"
}
}
}
Rectangle { Layout.fillWidth: true; height: 1; color: t.calSeparator ?? "#334a5d6e" }
RowLayout {
Layout.fillWidth: true
Text {
text: root.hasError ? "⚠ fetch error" : "Updated " + (root.lastUpdated || "—")
color: t.cryptoPopupDim ?? t.textDim ?? "#997a8996"
font.pixelSize: 11
Layout.fillWidth: true
}
Rectangle {
id: refreshBtn
width: 26; height: 26; radius: 8
color: rMouse.containsMouse
? (rMouse.pressed ? (t.accent ?? "#ffc19375") : "#22ffffff")
: "transparent"
Behavior on color { ColorAnimation { duration: 120 } }
Text {
id: refreshIcon
anchors.centerIn: parent
text: "󰑐"
color: t.cryptoIcon ?? t.accent ?? "#ffc19375"
font.pixelSize: 14
RotationAnimator {
id: spinAnim
target: refreshIcon
from: 0; to: 360
duration: 600
easing.type: Easing.OutCubic
running: false
}
}
MouseArea {
id: rMouse
anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor
onClicked: {
spinAnim.restart()
root.fetchPrice()
}
}
}
}
}
ColumnLayout {
id: settingsContent
width: tabContainer.width
x: card.activeTab === 1 ? 0 : tabContainer.width
opacity: card.activeTab === 1 ? 1.0 : 0.0
Behavior on x { NumberAnimation { duration: 260; easing.type: Easing.OutCubic } }
Behavior on opacity { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } }
spacing: 12
component FieldRow: ColumnLayout {
property string label: ""
property string placeholder: ""
property alias fieldText: fi.text
spacing: 4
Layout.fillWidth: true
Text {
text: label
color: t.cryptoPopupDim ?? t.textDim ?? "#997a8996"
font.pixelSize: 11
font.capitalization: Font.AllUppercase
}
Rectangle {
Layout.fillWidth: true
height: 30
radius: 8
color: fi.activeFocus ? "#22ffffff" : "#15ffffff"
border.color: fi.activeFocus ? (t.accent ?? "#ffc19375") : "transparent"
border.width: 1
Behavior on color { ColorAnimation { duration: 120 } }
Behavior on border.color { ColorAnimation { duration: 120 } }
TextInput {
id: fi
anchors { fill: parent; leftMargin: 10; rightMargin: 10; topMargin: 7; bottomMargin: 7 }
color: t.cryptoPopupText ?? t.textMain ?? "#ffe8eef2"
font.pixelSize: 12
font.family: "monospace"
selectionColor: t.accent ?? "#ffc19375"
clip: true
Text {
anchors.fill: parent
text: parent.parent.parent.placeholder
color: "#55" + (t.textDim ?? "#7a8996").slice(1)
font.pixelSize: 12
font.family: "monospace"
visible: fi.text === "" && !fi.activeFocus
verticalAlignment: Text.AlignVCenter
}
}
}
}
component StepRow: RowLayout {
property string label: ""
property int value: 1
property int minVal: 0
property int maxVal: 999
signal stepped(int v)
Layout.fillWidth: true
Text { text: label; color: t.cryptoPopupDim ?? t.textDim ?? "#997a8996"; font.pixelSize: 12; Layout.fillWidth: true }
Rectangle {
width: 24; height: 24; radius: 7
color: mm.containsMouse ? (mm.pressed ? (t.accent ?? "#ffc19375") : "#22ffffff") : "#15ffffff"
Behavior on color { ColorAnimation { duration: 100 } }
Text { anchors.centerIn: parent; text: ""; font.pixelSize: 14; font.bold: true; color: t.cryptoPopupText ?? t.textMain ?? "#ffe8eef2" }
MouseArea { id: mm; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: if (value > minVal) stepped(value - 1) }
}
Rectangle {
width: 54; height: 24; radius: 6; color: "#15ffffff"
Text { anchors.centerIn: parent; text: value; color: t.cryptoPopupText ?? t.textMain ?? "#ffe8eef2"; font.pixelSize: 12; font.bold: true; font.family: "monospace" }
}
Rectangle {
width: 24; height: 24; radius: 7
color: pm.containsMouse ? (pm.pressed ? (t.accent ?? "#ffc19375") : "#22ffffff") : "#15ffffff"
Behavior on color { ColorAnimation { duration: 100 } }
Text { anchors.centerIn: parent; text: "+"; font.pixelSize: 14; font.bold: true; color: t.cryptoPopupText ?? t.textMain ?? "#ffe8eef2" }
MouseArea { id: pm; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: if (value < maxVal) stepped(value + 1) }
}
}
component ToggleRow: RowLayout {
property string label: ""
property bool checked: false
signal toggled(bool v)
Layout.fillWidth: true
Text { text: label; color: t.cryptoPopupDim ?? t.textDim ?? "#997a8996"; font.pixelSize: 12; Layout.fillWidth: true }
Rectangle {
width: 36; height: 20; radius: 10
color: checked ? (t.accent ?? "#ffc19375") : "#22ffffff"
Behavior on color { ColorAnimation { duration: 150 } }
Rectangle {
width: 14; height: 14; radius: 7; color: "#ffffffff"
anchors.verticalCenter: parent.verticalCenter
x: checked ? parent.width - 17 : 3
Behavior on x { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
}
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: toggled(!checked) }
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text {
text: "Pairs to cycle"
color: t.cryptoPopupDim ?? t.textDim ?? "#997a8996"
font.pixelSize: 11
font.capitalization: Font.AllUppercase
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Item {
Layout.fillWidth: true
height: 24
clip: true
Rectangle {
anchors { left: parent.left; top: parent.top; bottom: parent.bottom }
width: 12; z: 2
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: t.cryptoPopupBg ?? "#cc0c0e12" }
GradientStop { position: 1.0; color: "transparent" }
}
visible: chipScroll.contentX > 0
}
Rectangle {
anchors { right: parent.right; top: parent.top; bottom: parent.bottom }
width: 12; z: 2
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: "transparent" }
GradientStop { position: 1.0; color: t.cryptoPopupBg ?? "#cc0c0e12" }
}
visible: chipScroll.contentX < chipScroll.contentWidth - chipScroll.width - 1
}
Flickable {
id: chipScroll
anchors.fill: parent
contentWidth: chipRow2.implicitWidth
contentHeight: height
clip: true
flickableDirection: Flickable.HorizontalFlick
boundsBehavior: Flickable.StopAtBounds
Row {
id: chipRow2
spacing: 8
Repeater {
model: root.localPairs
delegate: Item {
readonly property bool isActive:
index === root.activeCoinIndex % Math.max(1, root.localPairs.length)
height: 24
width: chipLabel.implicitWidth + dotSpace.width + 6
+ (root.localPairs.length > 1 ? 18 : 0)
Rectangle {
id: dotSpace
anchors.verticalCenter: parent.verticalCenter
x: 0; width: 5; height: 5; radius: 3
color: isActive ? (t.accent ?? "#ffc19375") : "#33ffffff"
Behavior on color { ColorAnimation { duration: 150 } }
}
Text {
id: chipLabel
anchors.verticalCenter: parent.verticalCenter
x: dotSpace.width + 5
text: (modelData.coin ?? "?") + "/" + (modelData.currency ?? "?").toUpperCase()
color: isActive
? (t.accent ?? "#ffc19375")
: (t.cryptoPopupText ?? t.textMain ?? "#ffe8eef2")
font.pixelSize: 12
font.bold: isActive
font.family: "monospace"
Behavior on color { ColorAnimation { duration: 150 } }
}
Rectangle {
visible: root.localPairs.length > 1
anchors { right: parent.right
verticalCenter: parent.verticalCenter }
width: 14; height: 14; radius: 4
color: rmChipMouse.containsMouse ? "#44f38ba8" : "transparent"
Behavior on color { ColorAnimation { duration: 100 } }
Text {
anchors.centerIn: parent
text: "×"; font.pixelSize: 11; font.bold: true
color: t.cryptoDown ?? "#fff38ba8"
}
MouseArea {
id: rmChipMouse
anchors.fill: parent; hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
let arr = root.localPairs.slice()
arr.splice(index, 1)
root.localPairs = arr
if (root.activeCoinIndex >= arr.length)
root.activeCoinIndex = 0
}
}
}
}
}
}
}
}
Item {
Layout.fillWidth: true
height: 3
visible: chipScroll.contentWidth > chipScroll.width
Rectangle {
anchors.fill: parent
radius: 2
color: "#15ffffff"
}
Rectangle {
height: parent.height
radius: 2
color: t.accent ?? "#ffc19375"
opacity: 0.7
readonly property real ratio: chipScroll.width / Math.max(1, chipScroll.contentWidth)
width: Math.max(16, parent.width * ratio)
x: (parent.width - width) *
(chipScroll.contentWidth > chipScroll.width
? chipScroll.contentX / (chipScroll.contentWidth - chipScroll.width)
: 0)
Behavior on x { NumberAnimation { duration: 80 } }
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text {
text: "Coin ID"
color: t.cryptoPopupDim ?? t.textDim ?? "#997a8996"
font.pixelSize: 11
font.capitalization: Font.AllUppercase
}
Rectangle {
Layout.fillWidth: true
height: 30; radius: 8
color: addCoinInput.activeFocus ? "#22ffffff" : "#15ffffff"
border.color: addCoinInput.activeFocus ? (t.accent ?? "#ffc19375") : "transparent"
border.width: 1
Behavior on color { ColorAnimation { duration: 120 } }
Behavior on border.color { ColorAnimation { duration: 120 } }
TextInput {
id: addCoinInput
anchors { fill: parent; leftMargin: 10; rightMargin: 10; topMargin: 7; bottomMargin: 7 }
color: t.cryptoPopupText ?? t.textMain ?? "#ffe8eef2"
font.pixelSize: 12; font.family: "monospace"
selectionColor: t.accent ?? "#ffc19375"
clip: true
Text {
anchors.fill: parent
text: "e.g. bitcoin, monero, solana"
color: "#55" + (t.textDim ?? "#7a8996").slice(1)
font.pixelSize: 12; font.family: "monospace"
visible: parent.text === "" && !parent.activeFocus
verticalAlignment: Text.AlignVCenter
}
onAccepted: addCurrencyInput.forceActiveFocus()
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text {
text: "Currency"
color: t.cryptoPopupDim ?? t.textDim ?? "#997a8996"
font.pixelSize: 11
font.capitalization: Font.AllUppercase
}
RowLayout {
Layout.fillWidth: true
spacing: 6
Rectangle {
Layout.fillWidth: true
height: 30; radius: 8
color: addCurrencyInput.activeFocus ? "#22ffffff" : "#15ffffff"
border.color: addCurrencyInput.activeFocus ? (t.accent ?? "#ffc19375") : "transparent"
border.width: 1
Behavior on color { ColorAnimation { duration: 120 } }
Behavior on border.color { ColorAnimation { duration: 120 } }
TextInput {
id: addCurrencyInput
anchors { fill: parent; leftMargin: 10; rightMargin: 10; topMargin: 7; bottomMargin: 7 }
color: t.cryptoPopupText ?? t.textMain ?? "#ffe8eef2"
font.pixelSize: 12; font.family: "monospace"
selectionColor: t.accent ?? "#ffc19375"
clip: true
Text {
anchors.fill: parent
text: "e.g. usd, eur, brl"
color: "#55" + (t.textDim ?? "#7a8996").slice(1)
font.pixelSize: 12; font.family: "monospace"
visible: parent.text === "" && !parent.activeFocus
verticalAlignment: Text.AlignVCenter
}
onAccepted: {
const c = addCoinInput.text.trim().toLowerCase()
const cur = addCurrencyInput.text.trim().toLowerCase() || root.localVsCur
if (c !== "") {
root.localPairs = root.localPairs.concat([{ coin: c, currency: cur }])
addCoinInput.text = ""
addCurrencyInput.text = ""
}
}
}
}
Rectangle {
width: 30; height: 30; radius: 8
color: addPairMouse.containsMouse
? (addPairMouse.pressed ? (t.accent ?? "#ffc19375") : "#22ffffff")
: "#15ffffff"
Behavior on color { ColorAnimation { duration: 100 } }
Text {
anchors.centerIn: parent; text: "+"
font.pixelSize: 14; font.bold: true
color: t.cryptoPopupText ?? t.textMain ?? "#ffe8eef2"
}
MouseArea {
id: addPairMouse
anchors.fill: parent; hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
const c = addCoinInput.text.trim().toLowerCase()
const cur = addCurrencyInput.text.trim().toLowerCase() || root.localVsCur
if (c !== "") {
root.localPairs = root.localPairs.concat([{ coin: c, currency: cur }])
addCoinInput.text = ""
addCurrencyInput.text = ""
}
}
}
}
}
}
}
StepRow {
label: "Decimals in label"
value: root.localDecimals
minVal: 0
maxVal: 8
onStepped: (v) => root.localDecimals = v
}
RowLayout {
Layout.fillWidth: true
Text {
text: "Refresh (seconds)"
color: t.cryptoPopupDim ?? t.textDim ?? "#997a8996"
font.pixelSize: 12
Layout.fillWidth: true
}
Rectangle {
width: 24; height: 24; radius: 7
color: decMouse.containsMouse
? (decMouse.pressed ? (t.accent ?? "#ffc19375") : "#22ffffff")
: "#15ffffff"
Behavior on color { ColorAnimation { duration: 100 } }
Text { anchors.centerIn: parent; text: ""; font.pixelSize: 14; font.bold: true; color: t.cryptoPopupText ?? t.textMain ?? "#ffe8eef2" }
MouseArea {
id: decMouse
anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor
onClicked: if (root.localRefreshSec > 10) root.localRefreshSec -= 1
}
}
Rectangle {
width: 54; height: 24; radius: 6
color: refreshInput.activeFocus ? "#22ffffff" : "#15ffffff"
border.color: refreshInput.activeFocus ? (t.accent ?? "#ffc19375") : "transparent"
border.width: 1
Behavior on color { ColorAnimation { duration: 120 } }
Behavior on border.color { ColorAnimation { duration: 120 } }
TextInput {
id: refreshInput
anchors { fill: parent; leftMargin: 6; rightMargin: 6; topMargin: 4; bottomMargin: 4 }
text: root.localRefreshSec.toString()
color: t.cryptoPopupText ?? t.textMain ?? "#ffe8eef2"
font.pixelSize: 12
font.bold: true
font.family: "monospace"
selectionColor: t.accent ?? "#ffc19375"
horizontalAlignment: TextInput.AlignHCenter
inputMethodHints: Qt.ImhDigitsOnly
validator: IntValidator { bottom: 10; top: 3600 }
clip: true
onActiveFocusChanged: if (!activeFocus) {
const v = parseInt(text)
if (!isNaN(v) && v >= 10 && v <= 3600)
root.localRefreshSec = v
else
text = root.localRefreshSec.toString()
}
onAccepted: {
const v = parseInt(text)
if (!isNaN(v) && v >= 10 && v <= 3600)
root.localRefreshSec = v
else
text = root.localRefreshSec.toString()
focus = false
}
Connections {
target: root
function onLocalRefreshSecChanged() {
if (!refreshInput.activeFocus)
refreshInput.text = root.localRefreshSec.toString()
}
}
}
}
Rectangle {
width: 24; height: 24; radius: 7
color: incMouse.containsMouse
? (incMouse.pressed ? (t.accent ?? "#ffc19375") : "#22ffffff")
: "#15ffffff"
Behavior on color { ColorAnimation { duration: 100 } }
Text { anchors.centerIn: parent; text: "+"; font.pixelSize: 14; font.bold: true; color: t.cryptoPopupText ?? t.textMain ?? "#ffe8eef2" }
MouseArea {
id: incMouse
anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor
onClicked: if (root.localRefreshSec < 3600) root.localRefreshSec += 1
}
}
}
ToggleRow {
label: "Show icon on bar"
checked: root.localShowIcon
onToggled: (v) => root.localShowIcon = v
}
Rectangle { Layout.fillWidth: true; height: 1; color: t.calSeparator ?? "#334a5d6e" }
Rectangle {
Layout.fillWidth: true
height: 32
radius: 9
color: applyMouse.containsMouse
? (applyMouse.pressed
? Qt.darker(t.accent ?? "#ffc19375", 1.15)
: (t.accent ?? "#ffc19375"))
: (t.cryptoApplyIdleBg ?? "#33c19375")
Behavior on color { ColorAnimation { duration: 130 } }
Text {
anchors.centerIn: parent
text: "Apply & Refresh"
color: applyMouse.containsMouse ? "#ff0c0e12" : (t.cryptoPopupText ?? t.textMain ?? "#ffe8eef2")
font.pixelSize: 12
font.bold: true
Behavior on color { ColorAnimation { duration: 130 } }
}
MouseArea {
id: applyMouse
anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor
onClicked: {
root.price = -1
root.activeCoinIndex = 0
root.coinSymbol = root.localCoinId.toUpperCase().slice(0, 4)
root.persistSettings()
root.fetchPrice()
card.activeTab = 0
}
}
}
Text {
Layout.alignment: Qt.AlignHCenter
text: "Reset to defaults"
color: t.cryptoPopupDim ?? t.textDim ?? "#997a8996"
font.pixelSize: 11
font.underline: true
MouseArea {
anchors.fill: parent; cursorShape: Qt.PointingHandCursor
onClicked: {
const cfgCoin = cfg.cryptoCoinId ?? "bitcoin"
const cfgCur = cfg.cryptoVsCurrency ?? "usd"
let cfgPairs
if (typeof cfg.cryptoPairs !== "undefined" && Array.isArray(cfg.cryptoPairs) && cfg.cryptoPairs.length > 0) {
cfgPairs = cfg.cryptoPairs
} else if (typeof cfg.cryptoCoinIds !== "undefined" && Array.isArray(cfg.cryptoCoinIds) && cfg.cryptoCoinIds.length > 0) {
cfgPairs = cfg.cryptoCoinIds.map(c => ({ coin: c, currency: cfgCur }))
} else {
cfgPairs = [{ coin: cfgCoin, currency: cfgCur }]
}
root.localPairs = cfgPairs
root.localVsCur = cfgCur
root.displayCurrency = cfgPairs.length > 0 ? cfgPairs[0].currency : cfgCur
root.activeCoinIndex = 0
root.localDecimals = cfg.cryptoDecimals ?? 1
root.localRefreshSec = cfg.cryptoRefreshSec ?? 60
root.localShowIcon = cfg.cryptoShowIcon ?? true
root.persistSettings()
root.price = -1
card.activeTab = 0
}
}
}
Item { height: 2 }
}
}
}
}
}
}
}