6 Commits
13 changed files with 343 additions and 114 deletions
+21 -12
View File
@@ -10,18 +10,21 @@ Three display styles, configurable in the settings dialog:
| Style | Description |
|---|---|
| *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 |
| *Nixie tubes* | Retro amber glowing digits in glass tubes (with a soft flicker), 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 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
trend (up/down/unchanged) is shown on a small LED next to the display.
In the split-flap and nixie styles a trend arrow sits right of the € sign:
green ▲ when the price went up, red ▼ when it went down. When the price is
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 down | red | red |
| Price unchanged / first reading | grey | grey |
| API failure (network error, 4xx/5xx, rate limit 429) | `--` | grey |
| Price up | green | green |
| Price down | red | red |
| Price unchanged | grey | last arrow stays |
| First reading / API failure | grey / `--` | no arrow |
Format: `68,969 €` (thousands separators follow the system locale, rounded to
whole euros) preceded by the bundled Bitcoin icon in the compact and full
@@ -32,19 +35,25 @@ representations.
- **Refresh interval (minutes)** — default 5, minimum **5**, maximum 1440.
The 5-minute floor exists because the CoinGecko API allows one request per
5 minutes. A hint text in the config dialog explains this.
- **Split-flap cascade (seconds)** — default 3, range 115. Spreads the flip
animation of a price change evenly over this many seconds (applies to the
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*.
## Installation
### Graphical
Double-click `bitcoin-price-1.2.0.plasmoid` in Dolphin (Plasma installs it to
Double-click `bitcoin-price-1.3.0.plasmoid` in Dolphin (Plasma installs it to
`~/.local/share/plasma/plasmoids/` automatically). The latest build is attached
to the [v1.2.0 release](../../releases/tag/v1.2.0).
to the [v1.3.0 release](../../releases/tag/v1.2.0).
### Terminal
```bash
kpackagetool5 --type Plasma/Applet --upgrade bitcoin-price-1.2.0.plasmoid
kpackagetool5 --type Plasma/Applet --upgrade bitcoin-price-1.3.0.plasmoid
# Then restart the shell if needed:
plasmashell --replace &
```
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+12
View File
@@ -14,6 +14,18 @@
</choices>
<default>0</default>
</entry>
<entry name="flapSeconds" type="Int">
<label>Seconds over which the split-flap cascade spreads a price change</label>
<default>3</default>
<min>1</min>
<max>15</max>
</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">
<label>Refresh interval in minutes (minimum 5 because of the CoinGecko rate limit)</label>
<default>5</default>
+112 -31
View File
@@ -1,7 +1,11 @@
/*
* Split-flap display mode — airport departure board style.
* White characters on black cells with a flap split line;
* changed characters flip with a cascading animation.
* Only digits are shown as flaps; the euro sign is rendered as plain
* 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.
*/
@@ -12,17 +16,45 @@ Row {
// wired by the parent loader
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;
}
}
// total seconds over which the reset-to-zero + new value cycle spreads
property int flapSeconds: 3
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 bool ready: false
readonly property real cellFont: height * 0.72
readonly property real cellWidth: cellFont * 0.80
// one flap open+close takes 260 ms; keep delays above that
readonly property int flapDuration: 260
Component.onCompleted: {
chars = display.split("");
chars = boardText.split("");
ready = true;
}
@@ -30,16 +62,26 @@ Row {
if (!ready) {
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);
// 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++) {
var from = i < chars.length ? chars[i] : "";
var cell = rep.itemAt(i);
var to = i < nc.length ? nc[i] : "";
if (from !== to) {
var cell = rep.itemAt(i);
if (cell) {
cell.flipTo(to, i * 45);
}
if (cell) {
cell.schedule(to, i * resetStep, valueStart + i * valueStep);
}
}
chars = nc;
@@ -59,28 +101,43 @@ Row {
border.color: "#26262b"
border.width: 1
// plain property (not a binding) so the flip animation
// controls when the character actually changes
// plain properties (not bindings) so the animations control
// when the character actually changes
property string ch: ""
property string pendingCh: ""
property string targetCh: ""
property string animCh: ""
Component.onCompleted: {
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) {
return;
}
pendingCh = newCh;
delayTimer.interval = Math.max(0, delay);
delayTimer.restart();
animCh = newCh;
flipAnim.restart();
}
Timer {
id: delayTimer
id: resetTimer
interval: 0
onTriggered: flipAnim.start()
onTriggered: cell.startFlip("0")
}
Timer {
id: valueTimer
interval: 0
onTriggered: cell.startFlip(cell.targetCh)
}
transform: Scale {
@@ -99,7 +156,7 @@ Row {
easing.type: Easing.InQuad
}
ScriptAction {
script: cell.ch = cell.pendingCh
script: cell.ch = cell.animCh
}
NumberAnimation {
target: scaleTransform
@@ -130,15 +187,39 @@ Row {
}
}
// trend LED (green/red/grey) — boards are monochrome, so the
// trend shows here instead of in the letter color
Rectangle {
width: root.height * 0.14
height: width
radius: width / 2
anchors.verticalCenter: parent.verticalCenter
color: root.trendColor
border.color: "#26262b"
border.width: 1
// breathing room between the board and the currency sign
Item {
width: root.height * 0.22
height: 1
}
// euro sign in the plain-text font, white; bottom-aligned with the
// price digits (bottomMargin tuned so the glyph bottom matches)
Text {
anchors.bottom: parent.bottom
anchors.bottomMargin: root.height * 0.02
text: "\u20AC"
visible: root.digitsOnly.length > 0
color: "#ffffff"
font.family: Qt.application.font.family
font.pixelSize: root.height * 0.85
}
// same breathing room between the euro sign and the arrow
Item {
width: root.height * 0.22
height: 1
}
// trend arrow: green up / red down; unchanged keeps the last arrow.
// Bottom-aligned with the euro sign and the digits.
Text {
anchors.bottom: parent.bottom
anchors.bottomMargin: root.height * 0.16
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.9
}
}
+121 -63
View File
@@ -1,7 +1,11 @@
/*
* Nixie tube display mode — retro amber glowing digits in glass tubes.
* 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
@@ -11,13 +15,34 @@ Row {
// wired by the parent loader
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
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 glowFont: height * 0.80
readonly property bool flickerOn: flickerIntensity > 0 && digits.length > 0
Repeater {
model: root.digits.length
@@ -48,71 +73,115 @@ Row {
opacity: 0.06
}
Text {
id: glowText
anchors.centerIn: parent
text: root.digits[index]
color: "#ffb648"
font.family: "DejaVu Serif"
font.pixelSize: root.glowFont
// digit + glow layers, flickered as one group
Item {
id: glowGroup
anchors.fill: parent
// layered glow: two scaled copies behind the crisp digit
Text {
id: glowText
anchors.centerIn: parent
text: parent.text
color: "#ff8c00"
opacity: 0.30
font: parent.font
scale: 1.24
text: root.digits[index]
color: "#ffb648"
font.family: "DejaVu Serif"
font.pixelSize: root.glowFont
// 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
color: "#ff9d1e"
opacity: 0.50
font: parent.font
scale: 1.10
}
// nixie flicker: random dropout of the cathode glow.
// repeat: true keeps the cycle alive; each trigger reschedules
// the next interval (intensity 40 -> ~4 s between flicks,
// 100 -> ~1.5 s).
Timer {
id: flickTimer
repeat: true
running: root.flickerOn
interval: 1500
onTriggered: {
flick.restart();
scheduleNext();
}
// occasional gentle nixie flicker
Timer {
interval: 2200 + Math.random() * 3200
repeat: false
running: root.visible && root.digits.length > 0
onTriggered: {
flick.stop();
flick.start();
interval = 2200 + Math.random() * 3200;
restart();
function scheduleNext() {
var base = 6000 - root.flickerIntensity * 45;
interval = Math.max(700, base * (0.6 + Math.random() * 0.8));
}
Component.onCompleted: scheduleNext()
}
SequentialAnimation {
id: flick
ScriptAction {
script: {
var range = root.flickerIntensity / 100 * 0.75;
glowGroup.opacity = 1 - (0.15 + Math.random() * range);
}
}
SequentialAnimation {
id: flick
NumberAnimation {
target: glowText
property: "opacity"
to: 0.82
duration: 70
}
NumberAnimation {
target: glowText
property: "opacity"
to: 1.0
duration: 140
}
PauseAnimation { duration: 40 + Math.random() * 140 }
NumberAnimation {
target: glowGroup
property: "opacity"
to: 1.0
duration: 90 + Math.random() * 160
easing.type: Easing.OutQuad
}
}
}
}
// 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; bottom-aligned with the
// price digits (bottomMargin tuned so the glyph bottom matches)
Text {
anchors.verticalCenter: parent.verticalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: root.height * -0.015
text: "\u20AC"
color: "#8a5a20"
font.family: "DejaVu Serif"
font.pixelSize: root.glowFont * 0.6
visible: root.digits.length > 0
color: "#ffffff"
font.family: Qt.application.font.family
font.pixelSize: root.height * 0.85
}
// same breathing room between the euro sign and the arrow
Item {
width: root.height * 0.22
height: 1
}
// trend arrow: green up / red down; unchanged keeps the last arrow.
// Bottom-aligned with the euro sign and the digits.
Text {
anchors.bottom: parent.bottom
anchors.bottomMargin: root.height * 0.10
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.81
}
// error / empty state
@@ -124,15 +193,4 @@ Row {
font.pixelSize: root.glowFont * 0.8
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
}
}
+23
View File
@@ -2,6 +2,7 @@
* Config page for nl.a14r.bitcoinprice.
* Follows the canonical Plasma 5 config dialog pattern:
* Kirigami.FormLayout + cfg_ prefixed property aliases.
* Mode-specific settings are only visible for the matching style.
*/
import QtQuick 2.15
@@ -14,6 +15,8 @@ Kirigami.FormLayout {
// The cfg_ prefix is what Plasma uses to read/write plasmoid.configuration
property alias cfg_displayMode: modeCombo.currentIndex
property alias cfg_refreshMinutes: intervalSpin.value
property alias cfg_flapSeconds: flapSpin.value
property alias cfg_nixieFlicker: flickSpin.value
Item {
Kirigami.FormData.isSection: true
@@ -25,6 +28,26 @@ Kirigami.FormLayout {
model: [i18n("Plain text"), i18n("Split-flap board"), i18n("Nixie tubes")]
}
QQC2.SpinBox {
id: flapSpin
visible: modeCombo.currentIndex === 1
Kirigami.FormData.label: i18n("Split-flap cascade (seconds):")
from: 1
to: 15
stepSize: 1
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 {
id: intervalSpin
Kirigami.FormData.label: i18n("Refresh interval (minutes):")
+53 -7
View File
@@ -25,11 +25,32 @@ Item {
readonly property int refreshMinutes: Math.max(5, plasmoid.configuration.refreshMinutes)
// Seconds over which the split-flap display cascades to a new price
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
property int displayMode: 0
readonly property string modeSource: displayMode === 1 ? "DisplayFlip.qml"
: displayMode === 2 ? "DisplayNixie.qml"
: "DisplayText.qml"
// Enum config values arrive either as an int index or as the choice
// name, depending on the Plasma version — accept both.
function modeFromConfig(v) {
if (typeof v === "number") {
return Math.max(0, Math.min(2, Math.round(v)));
}
if (v === "SplitFlap") {
return 1;
}
if (v === "Nixie") {
return 2;
}
return 0;
}
readonly property string apiUrl: "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=eur"
// -1 = down, 0 = unchanged / no previous reading yet, 1 = up
@@ -72,7 +93,10 @@ Item {
Component.onCompleted: {
try {
displayMode = Math.max(0, Math.min(2, plasmoid.configuration.displayMode));
// live binding: config changes from the dialog apply immediately
displayMode = Qt.binding(function () {
return modeFromConfig(plasmoid.configuration.displayMode);
});
} catch (e) {
// outside a real plasmoid shell (tests): keep the default
}
@@ -103,8 +127,8 @@ Item {
Image {
id: btcIcon
source: "../images/bitcoin.svg"
sourceSize.width: PlasmaCore.Units.iconSizes.smallMedium
sourceSize.height: PlasmaCore.Units.iconSizes.smallMedium
sourceSize.width: Math.round(PlasmaCore.Units.iconSizes.smallMedium * 1.1)
sourceSize.height: Math.round(PlasmaCore.Units.iconSizes.smallMedium * 1.1)
anchors.verticalCenter: parent.verticalCenter
fillMode: Image.PreserveAspectFit
visible: root.state === "ok"
@@ -118,7 +142,18 @@ Item {
source: root.modeSource
onLoaded: {
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) {
item.flapSeconds = Qt.binding(function () { return root.flapSeconds; });
}
if (item.flickerIntensity !== undefined) {
item.flickerIntensity = Qt.binding(function () { return root.nixieFlicker; });
}
}
}
}
@@ -139,8 +174,8 @@ Item {
Image {
anchors.horizontalCenter: parent.horizontalCenter
source: "../images/bitcoin.svg"
sourceSize.width: PlasmaCore.Units.iconSizes.large
sourceSize.height: PlasmaCore.Units.iconSizes.large
sourceSize.width: Math.round(PlasmaCore.Units.iconSizes.large * 1.1)
sourceSize.height: Math.round(PlasmaCore.Units.iconSizes.large * 1.1)
fillMode: Image.PreserveAspectFit
visible: root.state === "ok"
}
@@ -153,7 +188,18 @@ Item {
source: root.modeSource
onLoaded: {
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) {
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-Email=hermes@a14r.be
X-KDE-PluginInfo-Name=nl.a14r.bitcoinprice
X-KDE-PluginInfo-Version=1.2.0
X-KDE-PluginInfo-Version=1.6.0
X-KDE-PluginInfo-Website=https://gitea.a14r.be/hermes/plasma-bitcoin-price
X-KDE-PluginInfo-Category=Utilities
X-KDE-PluginInfo-License=MIT