2 Commits
9 changed files with 264 additions and 113 deletions
+16 -11
View File
@@ -10,19 +10,21 @@ Three display styles, configurable in the settings dialog:
| Style | Description | | Style | Description |
|---|---| |---|---|
| *Plain text* | Trend-colored text (green/red/grey) | | *Plain text* | Trend-colored text (green/red/grey) |
| *Split-flap board* | Airport board: white characters on black flaps, cascading flip animation on price changes, trend LED | | *Split-flap board* | Airport board: white digit flaps, white € sign and a trend arrow in the default font. On a price change all flaps first reset to 0 (swept over ~2 s), then take their new values — the whole cycle spreads over the configured cascade time |
| *Nixie tubes* | Retro amber glowing digits in glass tubes (with a soft flicker), trend LED | | *Nixie tubes* | Retro amber glowing digits in glass tubes with configurable random flicker, white € sign and a trend arrow in the default font |
In the split-flap and nixie styles the digits keep their display color and the In the split-flap and nixie styles a trend arrow sits right of the € sign:
trend (up/down/unchanged) is shown on a small LED next to the display. green ▲ when the price went up, red ▼ when it went down. When the price is
Display style changes apply immediately, without restarting the shell. unchanged the last arrow stays; on the very first reading (and on API errors)
no arrow is shown. Display style changes apply immediately, without
restarting the shell.
| Situation | Plain text color | Trend LED | | Situation | Plain text color | Arrow in flip/nixie modes |
|---|---|---| |---|---|---|
| Price up | green | green | | Price up | green | green |
| Price down | red | red | | Price down | red | red |
| Price unchanged / first reading | grey | grey | | Price unchanged | grey | last arrow stays |
| API failure (network error, 4xx/5xx, rate limit 429) | `--` | grey | | First reading / API failure | grey / `--` | no arrow |
Format: `68,969 €` (thousands separators follow the system locale, rounded to Format: `68,969 €` (thousands separators follow the system locale, rounded to
whole euros) preceded by the bundled Bitcoin icon in the compact and full whole euros) preceded by the bundled Bitcoin icon in the compact and full
@@ -35,7 +37,10 @@ representations.
5 minutes. A hint text in the config dialog explains this. 5 minutes. A hint text in the config dialog explains this.
- **Split-flap cascade (seconds)** — default 3, range 115. Spreads the flip - **Split-flap cascade (seconds)** — default 3, range 115. Spreads the flip
animation of a price change evenly over this many seconds (applies to the animation of a price change evenly over this many seconds (applies to the
split-flap display style). split-flap display style). Only shown when that style is selected.
- **Nixie flicker (0 = off)** — default 40, range 0100. Random cathode-dropout
flicker of the nixie tubes; 0 disables it, higher values flicker more often
and deeper. Only shown when the nixie style is selected.
Configurable via right-click on the widget → *Configure Bitcoin Price…**General*. Configurable via right-click on the widget → *Configure Bitcoin Price…**General*.
Binary file not shown.
Binary file not shown.
+6
View File
@@ -20,6 +20,12 @@
<min>1</min> <min>1</min>
<max>15</max> <max>15</max>
</entry> </entry>
<entry name="nixieFlicker" type="Int">
<label>Nixie tube flicker intensity (0 = off, 100 = frequent and deep)</label>
<default>40</default>
<min>0</min>
<max>100</max>
</entry>
<entry name="refreshMinutes" type="Int"> <entry name="refreshMinutes" type="Int">
<label>Refresh interval in minutes (minimum 5 because of the CoinGecko rate limit)</label> <label>Refresh interval in minutes (minimum 5 because of the CoinGecko rate limit)</label>
<default>5</default> <default>5</default>
+97 -37
View File
@@ -1,7 +1,11 @@
/* /*
* Split-flap display mode — airport departure board style. * Split-flap display mode — airport departure board style.
* White characters on black cells with a flap split line; * Only digits are shown as flaps; the euro sign is rendered as plain
* changed characters flip with a cascading animation. * text (default font, white) next to the board, followed by a trend
* arrow (green up / red down; unchanged keeps the last arrow).
* On a price change the board first resets every flap to "0" (swept
* over ~2 s), then takes the new values (swept over the remainder of
* the cascade setting).
* Pure QtQuick so it can be tested outside the plasmoid shell. * Pure QtQuick so it can be tested outside the plasmoid shell.
*/ */
@@ -12,12 +16,36 @@ Row {
// wired by the parent loader // wired by the parent loader
property string display: "--" property string display: "--"
property color trendColor: "#9e9e9e" // 1 = up, -1 = down, 0 = unchanged -> keep showing the last arrow
// seconds over which the cascade to a new price is spread property int trendDir: 0
property int lastDir: 0
onTrendDirChanged: {
if (trendDir !== 0) {
lastDir = trendDir;
}
}
// total seconds over which the reset-to-zero + new value cycle spreads
property int flapSeconds: 3 property int flapSeconds: 3
spacing: height * 0.06 spacing: height * 0.06
function digitsOf(s) {
var out = "";
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
if ((ch >= "0" && ch <= "9") || ch === "-") {
out += ch;
}
}
return out;
}
// UI-only derived values (euro/arrow visibility); logic must not rely
// on these — QML gives no ordering guarantee between a change handler
// and the re-evaluation of dependent bindings.
readonly property string digitsOnly: digitsOf(display)
readonly property string boardText: digitsOnly.length > 0 ? digitsOnly : "--"
property var chars: [] property var chars: []
property bool ready: false property bool ready: false
readonly property real cellFont: height * 0.72 readonly property real cellFont: height * 0.72
@@ -26,7 +54,7 @@ Row {
readonly property int flapDuration: 260 readonly property int flapDuration: 260
Component.onCompleted: { Component.onCompleted: {
chars = display.split(""); chars = boardText.split("");
ready = true; ready = true;
} }
@@ -34,23 +62,26 @@ Row {
if (!ready) { if (!ready) {
return; return;
} }
var nc = display.split(""); // compute from display directly: derived bindings may still be
// stale at this point (QML makes no ordering guarantee)
var digits = digitsOf(display);
var nc = (digits.length > 0 ? digits : "--").split("");
var n = Math.max(chars.length, nc.length); var n = Math.max(chars.length, nc.length);
// spread the changed cells evenly across flapSeconds
var changed = []; // Phase 1: every flap resets to "0", swept over ~2 s.
// Phase 2: every flap takes its new value, swept over the
// remainder of the total cascade time.
var total = Math.max(1, flapSeconds) * 1000;
var resetSpan = Math.min(2000, Math.floor(total * 2 / 3));
var valueStart = resetSpan;
var resetStep = n > 1 ? Math.floor(Math.max(0, resetSpan - flapDuration) / (n - 1)) : 0;
var valueStep = n > 1 ? Math.floor(Math.max(0, total - valueStart - flapDuration) / (n - 1)) : 0;
for (var i = 0; i < n; i++) { for (var i = 0; i < n; i++) {
var from = i < chars.length ? chars[i] : ""; var cell = rep.itemAt(i);
var to = i < nc.length ? nc[i] : ""; var to = i < nc.length ? nc[i] : "";
if (from !== to) {
changed.push([i, to]);
}
}
var span = Math.max(1, flapSeconds) * 1000 - flapDuration;
var step = changed.length > 1 ? Math.max(0, Math.floor(span / (changed.length - 1))) : 0;
for (var j = 0; j < changed.length; j++) {
var cell = rep.itemAt(changed[j][0]);
if (cell) { if (cell) {
cell.flipTo(changed[j][1], j * step); cell.schedule(to, i * resetStep, valueStart + i * valueStep);
} }
} }
chars = nc; chars = nc;
@@ -70,28 +101,43 @@ Row {
border.color: "#26262b" border.color: "#26262b"
border.width: 1 border.width: 1
// plain property (not a binding) so the flip animation // plain properties (not bindings) so the animations control
// controls when the character actually changes // when the character actually changes
property string ch: "" property string ch: ""
property string pendingCh: "" property string targetCh: ""
property string animCh: ""
Component.onCompleted: { Component.onCompleted: {
ch = root.chars[index] !== undefined ? root.chars[index] : ""; ch = root.chars[index] !== undefined ? root.chars[index] : "";
} }
function flipTo(newCh, delay) { // schedule the two-phase cycle: reset to "0", then target
function schedule(t, resetDelay, valueDelay) {
targetCh = t;
resetTimer.interval = Math.max(0, resetDelay);
resetTimer.restart();
valueTimer.interval = Math.max(0, valueDelay);
valueTimer.restart();
}
function startFlip(newCh) {
if (newCh === ch) { if (newCh === ch) {
return; return;
} }
pendingCh = newCh; animCh = newCh;
delayTimer.interval = Math.max(0, delay); flipAnim.restart();
delayTimer.restart();
} }
Timer { Timer {
id: delayTimer id: resetTimer
interval: 0 interval: 0
onTriggered: flipAnim.start() onTriggered: cell.startFlip("0")
}
Timer {
id: valueTimer
interval: 0
onTriggered: cell.startFlip(cell.targetCh)
} }
transform: Scale { transform: Scale {
@@ -110,7 +156,7 @@ Row {
easing.type: Easing.InQuad easing.type: Easing.InQuad
} }
ScriptAction { ScriptAction {
script: cell.ch = cell.pendingCh script: cell.ch = cell.animCh
} }
NumberAnimation { NumberAnimation {
target: scaleTransform target: scaleTransform
@@ -141,15 +187,29 @@ Row {
} }
} }
// trend LED (green/red/grey) — boards are monochrome, so the // breathing room between the board and the currency sign
// trend shows here instead of in the letter color Item {
Rectangle { width: root.height * 0.22
width: root.height * 0.14 height: 1
height: width }
radius: width / 2
// euro sign in the plain-text font, white
Text {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
color: root.trendColor text: "\u20AC"
border.color: "#26262b" visible: root.digitsOnly.length > 0
border.width: 1 color: "#ffffff"
font.family: Qt.application.font.family
font.pixelSize: root.height * 0.85
}
// trend arrow: green up / red down; unchanged keeps the last arrow
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.lastDir > 0 ? "\u25B2" : "\u25BC"
visible: root.digitsOnly.length > 0 && root.lastDir !== 0
color: root.lastDir > 0 ? "#2ecc71" : "#e74c3c"
font.family: Qt.application.font.family
font.pixelSize: root.height * 0.5
} }
} }
+110 -62
View File
@@ -1,7 +1,11 @@
/* /*
* Nixie tube display mode — retro amber glowing digits in glass tubes. * Nixie tube display mode — retro amber glowing digits in glass tubes.
* Glow is faked with layered text copies, so no QtGraphicalEffects * Glow is faked with layered text copies, so no QtGraphicalEffects
* dependency. Pure QtQuick: testable outside the plasmoid shell. * dependency. Tubes flicker randomly (nixie cathode style); the
* flicker intensity is configurable (0 = off, 100 = nervous).
* The euro sign is plain text (default font, white), followed by a
* trend arrow (green up / red down; unchanged keeps the last arrow).
* Pure QtQuick: testable outside the plasmoid shell.
*/ */
import QtQuick 2.15 import QtQuick 2.15
@@ -11,13 +15,34 @@ Row {
// wired by the parent loader // wired by the parent loader
property string display: "--" property string display: "--"
property color trendColor: "#9e9e9e" // 1 = up, -1 = down, 0 = unchanged -> keep showing the last arrow
property int trendDir: 0
property int lastDir: 0
onTrendDirChanged: {
if (trendDir !== 0) {
lastDir = trendDir;
}
}
// 0 = no flicker, 100 = frequent and deep flicker
property int flickerIntensity: 40
spacing: height * 0.05 spacing: height * 0.05
readonly property string digits: display.replace(/[^0-9]/g, "") function digitsOf(s) {
var out = "";
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
if (ch >= "0" && ch <= "9") {
out += ch;
}
}
return out;
}
readonly property string digits: digitsOf(display)
readonly property real tubeWidth: height * 0.52 readonly property real tubeWidth: height * 0.52
readonly property real glowFont: height * 0.80 readonly property real glowFont: height * 0.80
readonly property bool flickerOn: flickerIntensity > 0 && digits.length > 0
Repeater { Repeater {
model: root.digits.length model: root.digits.length
@@ -48,71 +73,105 @@ Row {
opacity: 0.06 opacity: 0.06
} }
Text { // digit + glow layers, flickered as one group
id: glowText Item {
anchors.centerIn: parent id: glowGroup
text: root.digits[index] anchors.fill: parent
color: "#ffb648"
font.family: "DejaVu Serif"
font.pixelSize: root.glowFont
// layered glow: two scaled copies behind the crisp digit
Text { Text {
id: glowText
anchors.centerIn: parent anchors.centerIn: parent
text: parent.text text: root.digits[index]
color: "#ff8c00" color: "#ffb648"
opacity: 0.30 font.family: "DejaVu Serif"
font: parent.font font.pixelSize: root.glowFont
scale: 1.24
// layered glow: two scaled copies behind the crisp digit
Text {
anchors.centerIn: parent
text: parent.text
color: "#ff8c00"
opacity: 0.30
font: parent.font
scale: 1.24
}
Text {
anchors.centerIn: parent
text: parent.text
color: "#ff9d1e"
opacity: 0.50
font: parent.font
scale: 1.10
}
} }
Text { }
anchors.centerIn: parent
text: parent.text // nixie flicker: random dropout of the cathode glow.
color: "#ff9d1e" // repeat: true keeps the cycle alive; each trigger reschedules
opacity: 0.50 // the next interval (intensity 40 -> ~4 s between flicks,
font: parent.font // 100 -> ~1.5 s).
scale: 1.10 Timer {
id: flickTimer
repeat: true
running: root.flickerOn
interval: 1500
onTriggered: {
flick.restart();
scheduleNext();
} }
// occasional gentle nixie flicker function scheduleNext() {
Timer { var base = 6000 - root.flickerIntensity * 45;
interval: 2200 + Math.random() * 3200 interval = Math.max(700, base * (0.6 + Math.random() * 0.8));
repeat: false }
running: root.visible && root.digits.length > 0
onTriggered: { Component.onCompleted: scheduleNext()
flick.stop(); }
flick.start();
interval = 2200 + Math.random() * 3200; SequentialAnimation {
restart(); id: flick
ScriptAction {
script: {
var range = root.flickerIntensity / 100 * 0.75;
glowGroup.opacity = 1 - (0.15 + Math.random() * range);
} }
} }
SequentialAnimation { PauseAnimation { duration: 40 + Math.random() * 140 }
id: flick NumberAnimation {
NumberAnimation { target: glowGroup
target: glowText property: "opacity"
property: "opacity" to: 1.0
to: 0.82 duration: 90 + Math.random() * 160
duration: 70 easing.type: Easing.OutQuad
}
NumberAnimation {
target: glowText
property: "opacity"
to: 1.0
duration: 140
}
} }
} }
} }
} }
// currency indicator, dim amber like an indicator lamp // breathing room between the tubes and the currency sign
Item {
width: root.height * 0.22
height: 1
}
// euro sign in the plain-text font, white
Text { Text {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: "\u20AC" text: "\u20AC"
color: "#8a5a20"
font.family: "DejaVu Serif"
font.pixelSize: root.glowFont * 0.6
visible: root.digits.length > 0 visible: root.digits.length > 0
color: "#ffffff"
font.family: Qt.application.font.family
font.pixelSize: root.height * 0.85
}
// trend arrow: green up / red down; unchanged keeps the last arrow
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.lastDir > 0 ? "\u25B2" : "\u25BC"
visible: root.digits.length > 0 && root.lastDir !== 0
color: root.lastDir > 0 ? "#2ecc71" : "#e74c3c"
font.family: Qt.application.font.family
font.pixelSize: root.height * 0.45
} }
// error / empty state // error / empty state
@@ -124,15 +183,4 @@ Row {
font.pixelSize: root.glowFont * 0.8 font.pixelSize: root.glowFont * 0.8
visible: root.digits.length === 0 visible: root.digits.length === 0
} }
// trend LED (green/red/grey)
Rectangle {
width: root.height * 0.11
height: width
radius: width / 2
anchors.verticalCenter: parent.verticalCenter
color: root.trendColor
border.color: "#4a2e12"
border.width: 1
}
} }
+13
View File
@@ -2,6 +2,7 @@
* Config page for nl.a14r.bitcoinprice. * Config page for nl.a14r.bitcoinprice.
* Follows the canonical Plasma 5 config dialog pattern: * Follows the canonical Plasma 5 config dialog pattern:
* Kirigami.FormLayout + cfg_ prefixed property aliases. * Kirigami.FormLayout + cfg_ prefixed property aliases.
* Mode-specific settings are only visible for the matching style.
*/ */
import QtQuick 2.15 import QtQuick 2.15
@@ -15,6 +16,7 @@ Kirigami.FormLayout {
property alias cfg_displayMode: modeCombo.currentIndex property alias cfg_displayMode: modeCombo.currentIndex
property alias cfg_refreshMinutes: intervalSpin.value property alias cfg_refreshMinutes: intervalSpin.value
property alias cfg_flapSeconds: flapSpin.value property alias cfg_flapSeconds: flapSpin.value
property alias cfg_nixieFlicker: flickSpin.value
Item { Item {
Kirigami.FormData.isSection: true Kirigami.FormData.isSection: true
@@ -28,6 +30,7 @@ Kirigami.FormLayout {
QQC2.SpinBox { QQC2.SpinBox {
id: flapSpin id: flapSpin
visible: modeCombo.currentIndex === 1
Kirigami.FormData.label: i18n("Split-flap cascade (seconds):") Kirigami.FormData.label: i18n("Split-flap cascade (seconds):")
from: 1 from: 1
to: 15 to: 15
@@ -35,6 +38,16 @@ Kirigami.FormLayout {
editable: true editable: true
} }
QQC2.SpinBox {
id: flickSpin
visible: modeCombo.currentIndex === 2
Kirigami.FormData.label: i18n("Nixie flicker (0 = off):")
from: 0
to: 100
stepSize: 10
editable: true
}
QQC2.SpinBox { QQC2.SpinBox {
id: intervalSpin id: intervalSpin
Kirigami.FormData.label: i18n("Refresh interval (minutes):") Kirigami.FormData.label: i18n("Refresh interval (minutes):")
+21 -2
View File
@@ -28,6 +28,9 @@ Item {
// Seconds over which the split-flap display cascades to a new price // Seconds over which the split-flap display cascades to a new price
readonly property int flapSeconds: Math.max(1, plasmoid.configuration.flapSeconds) readonly property int flapSeconds: Math.max(1, plasmoid.configuration.flapSeconds)
// Nixie tube flicker intensity (0 = off, 100 = frequent and deep)
readonly property int nixieFlicker: Math.max(0, Math.min(100, plasmoid.configuration.nixieFlicker))
// 0 = plain text, 1 = split-flap board, 2 = nixie tubes // 0 = plain text, 1 = split-flap board, 2 = nixie tubes
property int displayMode: 0 property int displayMode: 0
readonly property string modeSource: displayMode === 1 ? "DisplayFlip.qml" readonly property string modeSource: displayMode === 1 ? "DisplayFlip.qml"
@@ -139,10 +142,18 @@ Item {
source: root.modeSource source: root.modeSource
onLoaded: { onLoaded: {
item.display = Qt.binding(function () { return root.displayText; }); item.display = Qt.binding(function () { return root.displayText; });
item.trendColor = Qt.binding(function () { return root.priceColor; }); if (item.trendColor !== undefined) {
item.trendColor = Qt.binding(function () { return root.priceColor; });
}
if (item.trendDir !== undefined) {
item.trendDir = Qt.binding(function () { return root.trend; });
}
if (item.flapSeconds !== undefined) { if (item.flapSeconds !== undefined) {
item.flapSeconds = Qt.binding(function () { return root.flapSeconds; }); item.flapSeconds = Qt.binding(function () { return root.flapSeconds; });
} }
if (item.flickerIntensity !== undefined) {
item.flickerIntensity = Qt.binding(function () { return root.nixieFlicker; });
}
} }
} }
} }
@@ -177,10 +188,18 @@ Item {
source: root.modeSource source: root.modeSource
onLoaded: { onLoaded: {
item.display = Qt.binding(function () { return root.displayText; }); item.display = Qt.binding(function () { return root.displayText; });
item.trendColor = Qt.binding(function () { return root.priceColor; }); if (item.trendColor !== undefined) {
item.trendColor = Qt.binding(function () { return root.priceColor; });
}
if (item.trendDir !== undefined) {
item.trendDir = Qt.binding(function () { return root.trend; });
}
if (item.flapSeconds !== undefined) { if (item.flapSeconds !== undefined) {
item.flapSeconds = Qt.binding(function () { return root.flapSeconds; }); item.flapSeconds = Qt.binding(function () { return root.flapSeconds; });
} }
if (item.flickerIntensity !== undefined) {
item.flickerIntensity = Qt.binding(function () { return root.nixieFlicker; });
}
} }
} }
+1 -1
View File
@@ -10,7 +10,7 @@ X-KDE-ServiceTypes=Plasma/Applet
X-KDE-PluginInfo-Author=Arnold Cordewiner X-KDE-PluginInfo-Author=Arnold Cordewiner
X-KDE-PluginInfo-Email=hermes@a14r.be X-KDE-PluginInfo-Email=hermes@a14r.be
X-KDE-PluginInfo-Name=nl.a14r.bitcoinprice X-KDE-PluginInfo-Name=nl.a14r.bitcoinprice
X-KDE-PluginInfo-Version=1.3.0 X-KDE-PluginInfo-Version=1.5.0
X-KDE-PluginInfo-Website=https://gitea.a14r.be/hermes/plasma-bitcoin-price X-KDE-PluginInfo-Website=https://gitea.a14r.be/hermes/plasma-bitcoin-price
X-KDE-PluginInfo-Category=Utilities X-KDE-PluginInfo-Category=Utilities
X-KDE-PluginInfo-License=MIT X-KDE-PluginInfo-License=MIT