import QtQuick import QtQuick.Layouts import Quickshell import Quickshell.Wayland import Quickshell.Io import "../../config" as Cfg import "../../components" ModuleChip { id: root readonly property var cfg: Cfg.Config ConfigWriter { id: configWriter } width: chipRow.implicitWidth + 24 radius: panelRadius chipColor: t.cryptoBg chipColorHovered: t.cryptoBgHover chipBorderColor: t.cryptoBorder chipBorderColorHovered: t.cryptoBorderHover chipHoverEnabled: true chipTapAction: () => cryptoPopup.toggle() popupOpen: cryptoPopup.popupOpen chipAnimWidth: true property var localPairs: [{ coin: "bitcoin", currency: "usd" }] property int activeCoinIndex: 0 readonly property string localCoinId: localPairs.length > 0 ? localPairs[activeCoinIndex % localPairs.length].coin : "bitcoin" readonly property string localActiveCurrency: localPairs.length > 0 ? localPairs[activeCoinIndex % localPairs.length].currency : "usd" property string localVsCur: "usd" property int localDecimals: cfg.cryptoDecimals property int localRefreshSec: cfg.cryptoRefreshSec property int localRotateSec: cfg.cryptoRotateSec property bool localShowIcon: cfg.cryptoShowIcon function resolveWithPersistence() { root.localPairs = Array.isArray(cfg.cryptoPairs) && cfg.cryptoPairs.length > 0 ? cfg.cryptoPairs : [{ coin: "bitcoin", currency: "usd" }] root.localVsCur = root.localPairs[0].currency root.displayCurrency = root.localPairs[0].currency root.activeCoinIndex = 0 root.localDecimals = cfg.cryptoDecimals root.localRefreshSec = cfg.cryptoRefreshSec root.localRotateSec = cfg.cryptoRotateSec root.localShowIcon = cfg.cryptoShowIcon } function persistSettings() { const pairs = root.localPairs const decimals = Math.round(root.localDecimals) const refreshSec = Math.round(root.localRefreshSec) const rotateSec = Math.round(root.localRotateSec) const showIcon = root.localShowIcon const pairsQml = "[" + pairs.map(p => '{ coin: "' + p.coin + '", currency: "' + p.currency + '" }' ).join(", ") + "]" configWriter.writeInts([ { key: "cryptoDecimals", value: decimals }, { key: "cryptoRefreshSec", value: refreshSec }, { key: "cryptoRotateSec", value: rotateSec } ]) configWriter.writeBool("cryptoShowIcon", showIcon) configWriter.writeArray("cryptoPairs", pairsQml) } 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: "usd" property bool loading: false property bool loadingVisible: false property bool hasError: false property var sparklineData: [] property real change7d: 0 property var _priceCache: ({}) property int _fetchGen: 0 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 safeNumber(val, fallback) { const n = Number(val) return (typeof n === "number" && isFinite(n)) ? n : fallback } function safeString(val, maxLen, fallback) { if (typeof val !== "string") return fallback return val.slice(0, maxLen).replace(/[\x00-\x1f\x7f-\x9f]/g, "") } function rotatePrev() { if (root.localPairs.length <= 1) return root.activeCoinIndex = (root.activeCoinIndex - 1 + root.localPairs.length) % root.localPairs.length root._applyFromCache() rotateTimer.restart() } function rotateNext() { if (root.localPairs.length <= 1) return root.activeCoinIndex = (root.activeCoinIndex + 1) % root.localPairs.length root._applyFromCache() rotateTimer.restart() } function _applyFromCache() { var key = root.localCoinId + "_" + root.localActiveCurrency var entry = root._priceCache[key] if (!entry) { console.log("Crypto: no cache entry for", key); return } console.log("Crypto: applying", key, "price=" + entry.price) price = entry.price change24h = entry.change24h high24h = entry.high24h low24h = entry.low24h marketCap = entry.marketCap volume24h = entry.volume24h rank = entry.rank circulatingSupply = entry.circulatingSupply ath = entry.ath athChange = entry.athChange coinSymbol = safeString(entry.symbol, 8, root.localCoinId).toUpperCase() coinName = safeString(entry.name, 50, "") lastUpdated = new Date().toLocaleTimeString(Qt.locale(), "HH:mm:ss") root.displayCurrency = root.localActiveCurrency sparklineData = entry.sparkline || [] change7d = entry.change7d || 0 hasError = false } function fetchPrice() { loading = true hasError = false _fetchGen++ var myGen = _fetchGen var coinGroups = {} for (var i = 0; i < root.localPairs.length; i++) { var p = root.localPairs[i] if (!coinGroups[p.currency]) coinGroups[p.currency] = [] var coinList = coinGroups[p.currency] if (coinList.indexOf(p.coin) === -1) coinList.push(p.coin) } var currencies = Object.keys(coinGroups) var pending = currencies.length if (pending === 0) { loading = false; hasError = true; return } for (var ci = 0; ci < currencies.length; ci++) { var cur = currencies[ci] var ids = coinGroups[cur].join(",") var url = "https://api.coingecko.com/api/v3/coins/markets" + "?vs_currency=" + encodeURIComponent(cur || "usd") + "&ids=" + encodeURIComponent(ids) + "&order=market_cap_desc&per_page=250&page=1" + "&sparkline=true&price_change_percentage=24h,7d" var xhr = new XMLHttpRequest() xhr._currency = cur xhr.timeout = 15000 xhr.onreadystatechange = (function(xhr, cur, myGen) { return function() { if (myGen !== root._fetchGen) return if (xhr.readyState !== XMLHttpRequest.DONE) return if (xhr.status !== 200) { hasError = true } else { try { var text = xhr.responseText if (text.length <= 65536) { var data = JSON.parse(text) if (Array.isArray(data)) { for (var di = 0; di < data.length; di++) { var coin = data[di] var key = coin.id + "_" + cur console.log("Crypto: cache", key, "price=" + coin.current_price) root._priceCache[key] = { price: safeNumber(coin.current_price, -1), change24h: safeNumber(coin.price_change_percentage_24h, 0), high24h: safeNumber(coin.high_24h, 0), low24h: safeNumber(coin.low_24h, 0), marketCap: safeNumber(coin.market_cap, 0), volume24h: safeNumber(coin.total_volume, 0), rank: Math.trunc(safeNumber(coin.market_cap_rank, 0)), circulatingSupply: safeNumber(coin.circulating_supply, 0), ath: safeNumber(coin.ath, 0), athChange: safeNumber(coin.ath_change_percentage, 0), symbol: safeString(coin.symbol, 8, coin.id), name: safeString(coin.name, 50, ""), sparkline: (coin.sparkline_in_7d && Array.isArray(coin.sparkline_in_7d.price)) ? coin.sparkline_in_7d.price : [], change7d: safeNumber(coin.price_change_percentage_7d_in_currency, 0) } } } } } catch (_) {} } if (--pending <= 0) { loading = false root._applyFromCache() } } })(xhr, cur, myGen) xhr.open("GET", url) xhr.setRequestHeader("Accept", "application/json") xhr.send() } } Component.onCompleted: { resolveWithPersistence(); fetchPrice() } Timer { id: rotateTimer interval: root.localRotateSec * 1000 running: root.localRotateSec > 0 && root.localPairs.length > 1 repeat: true onTriggered: root.rotateNext() } Timer { id: refreshTimer interval: root.localRefreshSec * 1000 running: true repeat: true triggeredOnStart: true onTriggered: root.fetchPrice() } onLocalRefreshSecChanged: refreshTimer.restart() onLocalRotateSecChanged: rotateTimer.restart() 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 font.pixelSize: 14 Layout.alignment: Qt.AlignVCenter Layout.rightMargin: 7 } Text { text: root.coinSymbol + "/" + root.displayCurrency.toUpperCase() + ": " color: t.cryptoLabel font.pixelSize: 12 verticalAlignment: Text.AlignVCenter } Text { text: root.hasError ? "err" : root.price < 0 ? "…" : root.compactPrice(root.price) color: t.cryptoValue font.pixelSize: 13 font.bold: true verticalAlignment: Text.AlignVCenter } Text { visible: root.price >= 0 text: root.change24h >= 0 ? "▲" : "▼" color: root.change24h >= 0 ? (t.cryptoUp) : (t.cryptoDown) font.pixelSize: 9 verticalAlignment: Text.AlignVCenter Layout.alignment: Qt.AlignVCenter Layout.leftMargin: 4 Behavior on color { ColorAnimation { duration: 200 } } } } StandardPopup { id: cryptoPopup ns: "crypto" chipRoot: root cardWidth: 300 hasKeyboardFocus: true cardColor: t.cryptoPopupBg cardBorderColor: t.cryptoPopupBorder cardHeight: cardCol.implicitHeight + 24 layerEnabled: true 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: 36 radius: 12 color: t.cryptoFieldBg RowLayout { anchors.fill: parent anchors.margins: 4 spacing: 4 component TabBtn: Rectangle { required property string label required property int tabIndex property string icon: "" Layout.fillWidth: true height: 28 radius: 9 readonly property bool active: cryptoPopup.activeTab === tabIndex color: active ? (t.accent) : (tm.containsMouse ? (t.cryptoFieldBgFocus) : "transparent") Behavior on color { ColorAnimation { duration: 150 } } border.color: active ? Qt.rgba(1, 1, 1, 0.18) : "transparent" border.width: 1 Behavior on border.color { ColorAnimation { duration: 150 } } scale: tm.pressed ? 0.93 : 1.0 Behavior on scale { NumberAnimation { duration: 90; easing.type: Easing.OutQuad } } Row { anchors.centerIn: parent spacing: 5 Text { anchors.verticalCenter: parent.verticalCenter visible: parent.parent.icon !== "" text: parent.parent.icon color: parent.parent.active ? (t.cryptoTextOnAccent) : (t.cryptoPopupDim) font.pixelSize: 11 opacity: parent.parent.active ? 1.0 : 0.7 Behavior on color { ColorAnimation { duration: 150 } } Behavior on opacity { NumberAnimation { duration: 150 } } } Text { anchors.verticalCenter: parent.verticalCenter text: parent.parent.label color: parent.parent.active ? (t.cryptoTextOnAccent) : (t.cryptoPopupDim) font.pixelSize: 12 font.bold: parent.parent.active font.letterSpacing: parent.parent.active ? 0.4 : 0.0 Behavior on color { ColorAnimation { duration: 150 } } Behavior on font.letterSpacing { NumberAnimation { duration: 150 } } } } MouseArea { id: tm anchors.fill: parent; hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: cryptoPopup.activeTab = tabIndex } } TabBtn { label: "Price"; icon: "󰕾"; tabIndex: 0 } TabBtn { label: "Settings"; icon: "󰒓"; 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: cryptoPopup.activeTab === 0 ? 0 : -tabContainer.width opacity: cryptoPopup.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 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 font.pixelSize: 15 font.bold: true } Text { text: root.coinName !== "" ? root.coinName : (root.localCoinId.charAt(0).toUpperCase() + root.localCoinId.slice(1)) color: t.cryptoPopupDim font.pixelSize: 11 } } Rectangle { width: badge.implicitWidth + 12 height: 22; radius: 11 color: root.change24h >= 0 ? (t.cryptoUpBg) : (t.cryptoDownBg) Text { id: badge anchors.centerIn: parent text: (root.change24h >= 0 ? "▲ +" : "▼ ") + root.change24h.toFixed(2) + "%" color: root.change24h >= 0 ? (t.cryptoUp) : (t.cryptoDown) 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 font.pixelSize: 24 font.bold: true font.family: "monospace" } Rectangle { Layout.fillWidth: true; height: 1; color: t.calSeparator } component StatRow: RowLayout { property string label: "" property string value: "" Layout.fillWidth: true Text { text: label; color: t.cryptoPopupDim; font.pixelSize: 12; Layout.fillWidth: true } Text { text: value; color: t.cryptoPopupText; 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; font.pixelSize: 12; Layout.fillWidth: true } Text { text: root.ath > 0 ? root.fullPrice(root.ath) : "—" color: t.cryptoPopupText font.pixelSize: 12; font.bold: true; font.family: "monospace" } Text { visible: root.ath > 0 text: "(" + root.athChange.toFixed(2) + "%)" color: t.cryptoDown 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 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 font.pixelSize: 11 } } Item { Layout.fillWidth: true height: 6 Rectangle { anchors.fill: parent radius: 3 color: t.cryptoRangeTrack } Rectangle { height: parent.height radius: 3 color: root.change24h >= 0 ? (t.cryptoUp) : (t.cryptoDown) 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 font.pixelSize: 10 font.family: "monospace" } Item { Layout.fillWidth: true } Text { text: root.high24h > 0 ? root.fullPrice(root.high24h) : "—" color: t.cryptoUp font.pixelSize: 10 font.family: "monospace" } } } Rectangle { Layout.fillWidth: true; height: 1; color: t.calSeparator } ColumnLayout { Layout.fillWidth: true spacing: 5 RowLayout { Layout.fillWidth: true Text { text: "7d Chart" color: t.cryptoPopupDim font.pixelSize: 12 Layout.fillWidth: true } Rectangle { visible: root.sparklineData.length > 1 width: badge7d.implicitWidth + 12 height: 20; radius: 10 color: root.change7d >= 0 ? (t.cryptoUpBg) : (t.cryptoDownBg) Text { id: badge7d anchors.centerIn: parent text: (root.change7d >= 0 ? "▲ +" : "▼ ") + root.change7d.toFixed(2) + "%" color: root.change7d >= 0 ? (t.cryptoUp) : (t.cryptoDown) font.pixelSize: 10 font.bold: true } } } Canvas { id: sparklineCanvas Layout.fillWidth: true height: 52 property var prices: root.sparklineData property bool isUp: root.change7d >= 0 property string upColor: t.cryptoUp || "#4ade80" property string downColor: t.cryptoDown || "#f87171" onPricesChanged: requestPaint() onIsUpChanged: requestPaint() onUpColorChanged: requestPaint() onDownColorChanged:requestPaint() onPaint: { var ctx = getContext("2d") ctx.clearRect(0, 0, width, height) var pts = prices if (!pts || pts.length < 2) { ctx.fillStyle = Qt.rgba(1, 1, 1, 0.06) ctx.beginPath() ctx.roundedRect(0, 0, width, height, 6, 6) ctx.fill() return } var minP = pts[0], maxP = pts[0] for (var i = 1; i < pts.length; i++) { if (pts[i] < minP) minP = pts[i] if (pts[i] > maxP) maxP = pts[i] } var range = maxP - minP if (range === 0) range = 1 var pad = 4, pw = width - pad * 2, ph = height - pad * 2 var lineColor = isUp ? upColor : downColor ctx.beginPath() for (var j = 0; j < pts.length; j++) { var x = pad + (j / (pts.length - 1)) * pw var y = pad + ph - ((pts[j] - minP) / range) * ph if (j === 0) ctx.moveTo(x, y) else ctx.lineTo(x, y) } ctx.lineTo(pad + pw, pad + ph) ctx.lineTo(pad, pad + ph) ctx.closePath() var grad = ctx.createLinearGradient(0, 0, 0, height) grad.addColorStop(0, Qt.rgba( isUp ? 0.29 : 0.97, isUp ? 0.87 : 0.44, isUp ? 0.50 : 0.44, 0.22)) grad.addColorStop(1, Qt.rgba(0, 0, 0, 0)) ctx.fillStyle = grad ctx.fill() ctx.beginPath() for (var k = 0; k < pts.length; k++) { var lx = pad + (k / (pts.length - 1)) * pw var ly = pad + ph - ((pts[k] - minP) / range) * ph if (k === 0) ctx.moveTo(lx, ly) else ctx.lineTo(lx, ly) } ctx.strokeStyle = lineColor ctx.lineWidth = 1.5 ctx.lineJoin = "round" ctx.stroke() var lx2 = pad + pw var ly2 = pad + ph - ((pts[pts.length - 1] - minP) / range) * ph ctx.beginPath() ctx.arc(lx2, ly2, 3, 0, Math.PI * 2) ctx.fillStyle = lineColor ctx.fill() } } } Rectangle { Layout.fillWidth: true; height: 1; color: t.calSeparator } RowLayout { Layout.fillWidth: true Text { text: root.hasError ? "⚠ fetch error" : "Updated " + (root.lastUpdated || "—") color: t.cryptoPopupDim font.pixelSize: 11 Layout.fillWidth: true } Rectangle { visible: root.localPairs.length > 1 width: 26; height: 26; radius: 8 color: prevMouse.containsMouse ? (prevMouse.pressed ? (t.accent) : (t.cryptoFieldBgFocus)) : "transparent" Behavior on color { ColorAnimation { duration: 120 } } Text { anchors.centerIn: parent text: "‹" color: t.cryptoIcon font.pixelSize: 14 } MouseArea { id: prevMouse anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor onClicked: root.rotatePrev() } } Rectangle { visible: root.localPairs.length > 1 width: 26; height: 26; radius: 8 color: nextMouse.containsMouse ? (nextMouse.pressed ? (t.accent) : (t.cryptoFieldBgFocus)) : "transparent" Behavior on color { ColorAnimation { duration: 120 } } Text { anchors.centerIn: parent text: "›" color: t.cryptoIcon font.pixelSize: 14 } MouseArea { id: nextMouse anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor onClicked: root.rotateNext() } } Rectangle { id: refreshBtn width: 26; height: 26; radius: 8 color: rMouse.containsMouse ? (rMouse.pressed ? (t.accent) : (t.cryptoFieldBgFocus)) : "transparent" Behavior on color { ColorAnimation { duration: 120 } } Text { id: refreshIcon anchors.centerIn: parent text: "󰑐" color: t.cryptoIcon 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 height: tabContainer.height x: cryptoPopup.activeTab === 1 ? 0 : tabContainer.width opacity: cryptoPopup.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 font.pixelSize: 11 font.capitalization: Font.AllUppercase } Rectangle { Layout.fillWidth: true height: 30 radius: 8 color: fi.activeFocus ? (t.cryptoFieldBgFocus) : (t.cryptoFieldBg) border.color: fi.activeFocus ? (t.accent) : "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 font.pixelSize: 12 font.family: "monospace" selectionColor: t.accent clip: true Text { anchors.fill: parent text: parent.parent.parent.placeholder color: t.cryptoPlaceholder 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; font.pixelSize: 12; Layout.fillWidth: true } Rectangle { width: 24; height: 24; radius: 7 color: mm.containsMouse ? (mm.pressed ? (t.accent) : (t.cryptoFieldBgFocus)) : (t.cryptoFieldBg) Behavior on color { ColorAnimation { duration: 100 } } Text { anchors.centerIn: parent; text: "−"; font.pixelSize: 14; font.bold: true; color: t.cryptoPopupText } 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: t.cryptoFieldBg Text { anchors.centerIn: parent; text: value; color: t.cryptoPopupText; font.pixelSize: 12; font.bold: true; font.family: "monospace" } } Rectangle { width: 24; height: 24; radius: 7 color: pm.containsMouse ? (pm.pressed ? (t.accent) : (t.cryptoFieldBgFocus)) : (t.cryptoFieldBg) Behavior on color { ColorAnimation { duration: 100 } } Text { anchors.centerIn: parent; text: "+"; font.pixelSize: 14; font.bold: true; color: t.cryptoPopupText } 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; font.pixelSize: 12; Layout.fillWidth: true } Rectangle { width: 36; height: 20; radius: 10 color: checked ? (t.accent) : (t.cryptoFieldBgFocus) 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: 8 Text { text: "Pairs to cycle" color: t.cryptoPopupDim font.pixelSize: 11 font.capitalization: Font.AllUppercase } ColumnLayout { Layout.fillWidth: true spacing: 8 Item { Layout.fillWidth: true height: 28 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 } 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 } } 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) : "#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) : (t.cryptoPopupText) 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 ? (t.cryptoChipRemoveBg) : "transparent" Behavior on color { ColorAnimation { duration: 100 } } Text { anchors.centerIn: parent text: "×"; font.pixelSize: 11; font.bold: true color: t.cryptoDown } 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 root.fetchPrice() } } } } } } } } Item { Layout.fillWidth: true height: 3 visible: chipScroll.contentWidth > chipScroll.width Rectangle { anchors.fill: parent radius: 2 color: t.cryptoFieldBg } Rectangle { height: parent.height radius: 2 color: t.accent 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 font.pixelSize: 11 font.capitalization: Font.AllUppercase } Rectangle { Layout.fillWidth: true height: 30; radius: 8 color: addCoinInput.activeFocus ? (t.cryptoFieldBgFocus) : (t.cryptoFieldBg) border.color: addCoinInput.activeFocus ? (t.accent) : "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 font.pixelSize: 12; font.family: "monospace" selectionColor: t.accent clip: true Text { anchors.fill: parent text: "e.g. bitcoin, monero, solana" color: t.cryptoPlaceholder 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 font.pixelSize: 11 font.capitalization: Font.AllUppercase } RowLayout { Layout.fillWidth: true spacing: 6 Rectangle { Layout.fillWidth: true height: 30; radius: 8 color: addCurrencyInput.activeFocus ? (t.cryptoFieldBgFocus) : (t.cryptoFieldBg) border.color: addCurrencyInput.activeFocus ? (t.accent) : "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 font.pixelSize: 12; font.family: "monospace" selectionColor: t.accent clip: true Text { anchors.fill: parent text: "e.g. usd, eur, brl" color: t.cryptoPlaceholder 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 = "" root.fetchPrice() } } } } } } Rectangle { Layout.fillWidth: true Layout.topMargin: 4 height: 32; radius: 9 color: addPairMouse.containsMouse ? (addPairMouse.pressed ? (t.accent) : (t.cryptoFieldBgFocus)) : (t.cryptoApplyIdleBg) Behavior on color { ColorAnimation { duration: 130 } } Text { anchors.centerIn: parent text: "+ Add Pair" color: addPairMouse.containsMouse ? (t.cryptoTextOnAccent) : (t.cryptoPopupText) font.pixelSize: 12; font.bold: true Behavior on color { ColorAnimation { duration: 130 } } } 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 = "" root.fetchPrice() } } } } } Item { Layout.fillHeight: true } Rectangle { Layout.fillWidth: true; height: 1; color: t.calSeparator } 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 font.pixelSize: 12 Layout.fillWidth: true } Rectangle { width: 24; height: 24; radius: 7 color: decMouse.containsMouse ? (decMouse.pressed ? (t.accent) : (t.cryptoFieldBgFocus)) : (t.cryptoFieldBg) Behavior on color { ColorAnimation { duration: 100 } } Text { anchors.centerIn: parent; text: "−"; font.pixelSize: 14; font.bold: true; color: t.cryptoPopupText } 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 ? (t.cryptoFieldBgFocus) : (t.cryptoFieldBg) border.color: refreshInput.activeFocus ? (t.accent) : "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 font.pixelSize: 12 font.bold: true font.family: "monospace" selectionColor: t.accent 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) : (t.cryptoFieldBgFocus)) : (t.cryptoFieldBg) Behavior on color { ColorAnimation { duration: 100 } } Text { anchors.centerIn: parent; text: "+"; font.pixelSize: 14; font.bold: true; color: t.cryptoPopupText } MouseArea { id: incMouse anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor onClicked: if (root.localRefreshSec < 3600) root.localRefreshSec += 1 } } } RowLayout { Layout.fillWidth: true Text { text: "Rotate (seconds)" color: t.cryptoPopupDim font.pixelSize: 12 Layout.fillWidth: true } Rectangle { width: 24; height: 24; radius: 7 color: rotDecMouse.containsMouse ? (rotDecMouse.pressed ? (t.accent) : (t.cryptoFieldBgFocus)) : (t.cryptoFieldBg) Behavior on color { ColorAnimation { duration: 100 } } Text { anchors.centerIn: parent; text: "−"; font.pixelSize: 14; font.bold: true; color: t.cryptoPopupText } MouseArea { id: rotDecMouse anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor onClicked: if (root.localRotateSec > 1) root.localRotateSec -= 1 } } Rectangle { width: 54; height: 24; radius: 6 color: rotateInput.activeFocus ? (t.cryptoFieldBgFocus) : (t.cryptoFieldBg) border.color: rotateInput.activeFocus ? (t.accent) : "transparent" border.width: 1 Behavior on color { ColorAnimation { duration: 120 } } Behavior on border.color { ColorAnimation { duration: 120 } } TextInput { id: rotateInput anchors { fill: parent; leftMargin: 6; rightMargin: 6; topMargin: 4; bottomMargin: 4 } text: root.localRotateSec.toString() color: t.cryptoPopupText font.pixelSize: 12 font.bold: true font.family: "monospace" selectionColor: t.accent horizontalAlignment: TextInput.AlignHCenter inputMethodHints: Qt.ImhDigitsOnly validator: IntValidator { bottom: 1; top: 600 } clip: true onActiveFocusChanged: if (!activeFocus) { const v = parseInt(text) if (!isNaN(v) && v >= 1 && v <= 600) root.localRotateSec = v else text = root.localRotateSec.toString() } onAccepted: { const v = parseInt(text) if (!isNaN(v) && v >= 1 && v <= 600) root.localRotateSec = v else text = root.localRotateSec.toString() focus = false } Connections { target: root function onLocalRotateSecChanged() { if (!rotateInput.activeFocus) rotateInput.text = root.localRotateSec.toString() } } } } Rectangle { width: 24; height: 24; radius: 7 color: rotIncMouse.containsMouse ? (rotIncMouse.pressed ? (t.accent) : (t.cryptoFieldBgFocus)) : (t.cryptoFieldBg) Behavior on color { ColorAnimation { duration: 100 } } Text { anchors.centerIn: parent; text: "+"; font.pixelSize: 14; font.bold: true; color: t.cryptoPopupText } MouseArea { id: rotIncMouse anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor onClicked: if (root.localRotateSec < 600) root.localRotateSec += 1 } } } ToggleRow { label: "Show icon on bar" checked: root.localShowIcon onToggled: (v) => root.localShowIcon = v } Rectangle { Layout.fillWidth: true; height: 1; color: t.calSeparator } Rectangle { Layout.fillWidth: true height: 32 radius: 9 color: applyMouse.containsMouse ? (applyMouse.pressed ? Qt.darker(t.accent, 1.15) : (t.accent)) : (t.cryptoApplyIdleBg) Behavior on color { ColorAnimation { duration: 130 } } Text { anchors.centerIn: parent text: "Apply & Refresh" color: applyMouse.containsMouse ? (t.cryptoTextOnAccent) : (t.cryptoPopupText) 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.activeCoinIndex = 0 root.persistSettings() root.fetchPrice() cryptoPopup.activeTab = 0 } } } Item { height: 2 } } } } } }