1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
local CURRENCIES = { "USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF" }
local POSITIONS = {
"top",
"top-left",
"top-right",
"bottom",
"bottom-left",
"bottom-right",
}
local THEMES =
{ "rebind", "rebind-light", "catppuccin", "tokyo-night", "contrast" }
local cfg = rb.ui.schema({
menu_key = rb.ui.keybind("F8", { label = "Open the menu" }),
toggle_key = rb.ui.keybind("F7", { label = "Show or hide the ticker" }),
coins = rb.ui.text("bitcoin, ethereum, solana", {
label = "Coins",
tooltip = "CoinGecko ids, separated by commas: the end of a coin's page address on coingecko.com",
}),
currency = rb.ui.select("USD", CURRENCIES, { label = "Currency" }),
refresh = rb.ui.slider(60, {
label = "Refresh every",
min = 30,
max = 600,
step = 30,
suffix = " s",
}),
position = rb.ui.select("top", POSITIONS, { label = "Position" }),
theme = rb.ui.select("rebind", THEMES, { label = "Theme" }),
beep = rb.ui.toggle(true, { label = "Beep on alerts" }),
})
local API =
"https://api.coingecko.com/api/v3/coins/markets?vs_currency=%s&ids=%s"
local REQUEST = {
headers = { ["User-Agent"] = "Rebind Crypto Ticker" },
timeout = 10000,
}
local ALERTS_FILE = ".alerts.json"
local SYMBOLS = { USD = "$", EUR = "€", GBP = "£", JPY = "¥" }
local coins = {}
local note = "loading"
local updated = nil
local shown = true
local wake = false
local strip, strip_key = nil, nil
local alerts = {}
local function money(price)
local sign = SYMBOLS[cfg.currency] or (cfg.currency .. " ")
if price >= 1000 then
local reversed = string.reverse(tostring(math.floor(price + 0.5)))
local grouped = string.reverse((string.gsub(reversed, "(%d%d%d)", "%1,")))
return sign .. (string.gsub(grouped, "^,", ""))
elseif price >= 1 then
return sign .. string.format("%.2f", price)
elseif price <= 0 then
return sign .. "0"
end
local decimals = math.min(3 - math.floor(math.log10(price)), 12)
local text = string.format("%." .. decimals .. "f", price)
return sign .. (string.gsub(text, "(%.%d%d%d-)0+$", "%1"))
end
local function percent(change)
if type(change) ~= "number" then
return { "", "faint" }
end
local arrow = if change >= 0 then "▲" else "▼"
local role = if change >= 0 then "positive" else "danger"
return { string.format("%s%.1f%%", arrow, math.abs(change)), role }
end
local function ids()
local list, bad = {}, {}
for _, part in string.split(cfg.coins, ",") do
local id = string.lower((string.gsub(part, "%s", "")))
if string.match(id, "^[%w%-]+$") then
table.insert(list, id)
elseif id ~= "" then
table.insert(bad, id)
end
end
return list, bad
end
local function draw()
if not shown then
if strip then
strip.remove()
strip = nil
end
return
end
local spans = {}
for i, c in coins do
if i > 1 then
table.insert(spans, { " " })
end
table.insert(spans, { string.upper(c.symbol) .. " ", "dim" })
table.insert(spans, { money(c.price) .. " ", "text", true })
table.insert(spans, percent(c.change))
end
if note then
local gap = if #coins > 0 then " " else ""
table.insert(spans, { gap .. note, "faint" })
end
local key = cfg.position .. "/" .. cfg.theme
if strip and strip_key ~= key then
strip.remove()
strip = nil
end
local ok, err = pcall(function()
if strip then
strip.set(spans)
else
strip =
rb.overlay.status(spans, { anchor = cfg.position, theme = cfg.theme })
strip_key = key
end
end)
if not ok then
rb.log.warn("could not draw the ticker: " .. tostring(err))
end
end
local function notice(text, options)
options.theme = cfg.theme
if cfg.position == "top-right" then
options.offset_y = 56
end
local ok, err = pcall(rb.overlay.toast, text, options)
if not ok then
rb.log.warn("could not show a notice: " .. tostring(err))
end
end
local function save_alerts()
local ok, err = pcall(rb.file.write_json, ALERTS_FILE, alerts)
if not ok then
rb.log.warn("could not save alerts: " .. tostring(err))
end
end
local function load_alerts()
if not rb.file.exists(ALERTS_FILE) then
return
end
local ok, saved = pcall(rb.file.read_json, ALERTS_FILE)
if not ok or type(saved) ~= "table" then
return
end
for _, a in saved do
if
type(a) == "table"
and type(a.id) == "string"
and type(a.symbol) == "string"
and type(a.currency) == "string"
and type(a.above) == "boolean"
and type(a.price) == "number"
then
table.insert(alerts, a)
end
end
end
local function remove_alert(a)
local i = table.find(alerts, a)
if i then
table.remove(alerts, i)
save_alerts()
end
end
local function alerts_for(id)
local list = {}
for _, a in alerts do
if a.id == id and a.currency == cfg.currency then
table.insert(list, a)
end
end
return list
end
local function crossed(a, price)
if a.above then
return price >= a.price
end
return price <= a.price
end
local function check_alerts()
local by_id = {}
for _, c in coins do
by_id[c.id] = c
end
local fired = {}
for i = #alerts, 1, -1 do
local a = alerts[i]
local c = by_id[a.id]
if c and a.currency == cfg.currency and crossed(a, c.price) then
table.remove(alerts, i)
table.insert(fired, { alert = a, coin = c })
end
end
if #fired == 0 then
return
end
save_alerts()
for _, f in fired do
local role = if f.alert.above then "positive" else "danger"
local text = {
{ string.upper(f.alert.symbol) .. " ", "text", true },
{ money(f.coin.price), role, true },
}
local verb = if f.alert.above then "rose above " else "fell below "
notice(text, {
title = "Price alert",
meta = verb .. money(f.alert.price),
duration = 10000,
})
end
if cfg.beep then
rb.audio.beep()
end
end
local function read_rows(data, wanted)
local by_id = {}
for _, row in data do
if
type(row) == "table"
and type(row.id) == "string"
and type(row.current_price) == "number"
then
by_id[row.id] = row
end
end
local found, missing = {}, {}
for _, id in wanted do
local row = by_id[id]
if row then
table.insert(found, {
id = id,
symbol = if type(row.symbol) == "string" then row.symbol else id,
name = if type(row.name) == "string" then row.name else id,
price = row.current_price,
change = row.price_change_percentage_24h,
})
else
table.insert(missing, id)
end
end
return found, missing
end
local function refresh()
local wanted, bad = ids()
if #wanted == 0 then
coins = {}
note = if #bad > 0
then "unknown: " .. table.concat(bad, ", ")
else "add coins in the settings"
draw()
return
end
local currency = string.lower(cfg.currency)
local url = string.format(API, currency, table.concat(wanted, ","))
local ok, res = pcall(rb.net.get, url, REQUEST)
if not ok then
note = "offline"
rb.log.warn("CoinGecko did not answer: " .. tostring(res))
elseif res.status == 429 then
note = "CoinGecko is limiting requests"
elseif res.status ~= 200 then
note = "CoinGecko answered " .. res.status
else
local parsed, data = pcall(rb.json.parse, res.body)
if not parsed or type(data) ~= "table" then
note = "CoinGecko sent something unreadable"
else
local found, missing = read_rows(data, wanted)
for _, id in bad do
table.insert(missing, id)
end
coins = found
updated = os.date("%H:%M")
note = nil
if #missing > 0 then
note = "unknown: " .. table.concat(missing, ", ")
end
check_alerts()
end
end
draw()
end
local open_main
local function open_coin(c)
local items = {}
for _, step in { 1, 5, -1, -5 } do
local target = c.price * (1 + step / 100)
local verb = if step > 0 then "Alert above " else "Alert below "
table.insert(items, {
label = verb .. money(target),
hint = string.format("%+d%%", step),
action = function()
table.insert(alerts, {
id = c.id,
symbol = c.symbol,
currency = cfg.currency,
above = step > 0,
price = target,
})
save_alerts()
local text = "Alert set for "
.. string.upper(c.symbol)
.. " at "
.. money(target)
notice(text, {})
end,
})
end
for _, a in alerts_for(c.id) do
local side = if a.above then "above " else "below "
table.insert(items, {
label = "Remove " .. side .. money(a.price),
action = function()
remove_alert(a)
end,
})
end
table.insert(items, {
label = "Back",
action = function()
open_main()
end,
})
local menu, why = rb.overlay.menu(items, {
title = string.upper(c.symbol),
subtitle = c.name .. " " .. money(c.price),
theme = cfg.theme,
})
if not menu then
rb.log.warn("no menu: " .. why)
end
end
open_main = function()
local items = {}
for _, c in coins do
local count = #alerts_for(c.id)
local hint = nil
if count > 0 then
hint = count .. (if count == 1 then " alert" else " alerts")
end
table.insert(items, {
label = string.upper(c.symbol) .. " " .. money(c.price),
hint = hint,
action = function()
open_coin(c)
end,
})
end
table.insert(items, {
label = if shown then "Hide the ticker" else "Show the ticker",
action = function()
shown = not shown
draw()
end,
})
table.insert(items, {
label = "Refresh now",
action = function()
wake = true
end,
})
if #alerts > 0 then
table.insert(items, {
label = "Clear every alert",
hint = tostring(#alerts),
action = function()
alerts = {}
save_alerts()
end,
})
end
local menu, why = rb.overlay.menu(items, {
title = "Crypto Ticker",
subtitle = note or (if updated then "updated " .. updated else ""),
theme = cfg.theme,
})
if not menu then
rb.log.warn("no menu: " .. why)
end
end
rb.bind(cfg.menu_key, function()
open_main()
end)
rb.bind(cfg.toggle_key, function()
shown = not shown
draw()
end)
rb.on("start", function()
load_alerts()
draw()
rb.run(function()
while true do
refresh()
local waited = 0
while waited < cfg.refresh * 1000 and not wake do
rb.sleep(250)
waited += 250
end
wake = false
end
end)
end)