tui/view.bend

cswap-bend-cli@0.1.0.0 · 300 lines · 51 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/tui/fmt.bend as F
3import cswap-bend-cli@0.1.0.0/tui/model.bend as M
4import cswap-bend-cli@0.1.0.0/tui/card.bend as C
5
6# One frame of the dashboard as text: the title and auto-switch line, the
7# accounts, the next best candidate and the tail of the daemon log. Pure: the
8# same Snap and View always draw the same text.
9
10# ---- numbers ------------------------------------------------------------------
11
12# The candidate with the most headroom among the accounts that are neither
13# active nor disabled; the first wins a tie.
14type Best is Data:
15  Best{slot: String, email: String, free: U32}
16
17def frac2(+r: U32) -> String:
18  F.sel.s(U32.is_eq((r % 10 : U32), 0), U32.show((r / 10 : U32)), F.sel.s(U32.is_lt(r, 10), "0" ++ U32.show(r), U32.show(r)))
19
20# ---- the header ---------------------------------------------------------------------
21
22# 9000 -> "90", 8550 -> "85.5", 8505 -> "85.05".
23def pct_label(+c: U32) -> String:
24  F.sel.s(U32.is_eq((c % 100 : U32), 0), U32.show((c / 100 : U32)), U32.show((c / 100 : U32)) ++ "." ++ frac2((c % 100 : U32)))
25
26def version_of(+h: M.Hdr) -> String:
27  match h:
28    case M.Hdr{v, l, p, t, i, n}:
29      v
30
31def title(+h: M.Hdr) -> List<&2, F.Seg>:
32  [F.sg("cswap tui", F.RBold{}), C.muted("  " ++ version_of(h))]
33
34def live_of(+h: M.Hdr) -> Bool:
35  match h:
36    case M.Hdr{v, l, p, t, i, n}:
37      l
38
39def state_seg(+live: Bool) -> F.Seg:
40  match live:
41    case True{}:
42      F.sg("● auto-switch LIVE", F.ROk{})
43    case False{}:
44      F.sg("○ auto-switch STOPPED", F.RWarn{})
45
46def opt_text(+m: Maybe<&2, String>) -> List<&2, F.Seg>:
47  match m:
48    case None{}:
49      []
50    case Some{s}:
51      [C.muted(" · " ++ s)]
52
53def thr_some(+m: Maybe<&2, U32>) -> Maybe<&2, String>:
54  match m:
55    case None{}:
56      None{}
57    case Some{c}:
58      Some{"threshold " ++ pct_label(c) ++ "%"}
59
60def thr_text(+h: M.Hdr) -> Maybe<&2, String>:
61  match h:
62    case M.Hdr{v, l, p, t, i, n}:
63      thr_some(t)
64
65def every_some(+m: Maybe<&2, U32>) -> Maybe<&2, String>:
66  match m:
67    case None{}:
68      None{}
69    case Some{c}:
70      Some{"every " ++ U32.show(c) ++ "s"}
71
72def every_text(+h: M.Hdr) -> Maybe<&2, String>:
73  match h:
74    case M.Hdr{v, l, p, t, i, n}:
75      every_some(i)
76
77def live_pid(+live: Bool, +p: Maybe<&2, U32>) -> Maybe<&2, String>:
78  match live p:
79    case True{} Some{n}:
80      Some{"pid " ++ U32.show(n)}
81    case _ _:
82      None{}
83
84def pid_text(+h: M.Hdr) -> Maybe<&2, String>:
85  match h:
86    case M.Hdr{v, l, p, t, i, n}:
87      live_pid(l, p)
88
89def next_poll(+live: Bool, +n: Maybe<&2, U32>, +now: U32) -> Maybe<&2, String>:
90  match live n:
91    case True{} Some{t}:
92      Some{F.sel.s(U32.is_gt(t, now), "next poll in " ++ F.duration((t - now : U32)), "polling now")}
93    case _ _:
94      None{}
95
96def poll_text(+h: M.Hdr, +now: U32) -> Maybe<&2, String>:
97  match h:
98    case M.Hdr{v, l, p, t, i, n}:
99      next_poll(l, n, now)
100
101def state_line(+h: M.Hdr, +now: U32) -> List<&2, F.Seg>:
102  List.append(&2, F.Seg, [state_seg(live_of(h))], List.append(&2, F.Seg, opt_text(thr_text(h)), List.append(&2, F.Seg, opt_text(every_text(h)), List.append(&2, F.Seg, opt_text(poll_text(h, now)), opt_text(pid_text(h))))))
103
104# ---- the accounts -----------------------------------------------------------------------
105
106def threshold_of(+h: M.Hdr) -> Maybe<&2, U32>:
107  match h:
108    case M.Hdr{v, l, p, t, i, n}:
109      t
110
111def is_multi(+b: List<&2, List<&2, F.Seg>>) -> Bool:
112  Nat.is_gt(List.length(&2, List<&2, F.Seg>, b), 1n)
113
114def lines_cat(+a: List<&2, List<&2, F.Seg>>, +b: List<&2, List<&2, F.Seg>>) -> List<&2, List<&2, F.Seg>>:
115  List.append(&2, List<&2, F.Seg>, a, b)
116
117def gap_if(+b: Bool) -> List<&2, List<&2, F.Seg>>:
118  match b:
119    case True{}:
120      [[]]
121    case False{}:
122      []
123
124def empty_or(+as: List<&2, M.Acct>, +bs: List<&2, List<&2, F.Seg>>) -> List<&2, List<&2, F.Seg>>:
125  match as:
126    case Nil{}:
127      [[C.muted("No managed accounts yet. Add one with: cswap add")]]
128    case _:
129      bs
130
131# ---- the next best account ---------------------------------------------------------------------
132
133def block_pick(+active: Bool, +a: M.Acct, +v: M.View, +h: M.Hdr, +now: U32, +off: U32) -> List<&2, List<&2, F.Seg>>:
134  match active:
135    case True{}:
136      C.card(a, v, threshold_of(h), now, off)
137    case False{}:
138      [C.mini(a, now)]
139
140def block(+a: M.Acct, +v: M.View, +h: M.Hdr, +now: U32, +off: U32) -> List<&2, List<&2, F.Seg>>:
141  block_pick(M.acct_active(a), a, v, h, now, off)
142
143def block_join(+b: List<&2, List<&2, F.Seg>>, +rest: List<&2, List<&2, F.Seg>>, +prev: Bool, +first: Bool) -> List<&2, List<&2, F.Seg>>:
144  lines_cat(gap_if(Bool.not(first) && (prev || is_multi(b))), lines_cat(b, rest))
145
146# The account lines in slot order. A blank line separates blocks when either
147# side is a multi-line card.
148def blocks(+as: List<&2, M.Acct>, +v: M.View, +h: M.Hdr, +now: U32, +off: U32, +prev: Bool, +first: Bool) -> List<&2, List<&2, F.Seg>>:
149  match as:
150    case Nil{}:
151      Nil{}
152    case Con{a, t}:
153      block_join(block(a, v, h, now, off), blocks(t, v, h, now, off, is_multi(block(a, v, h, now, off)), False{}), prev, first)
154
155def accounts_lines(+s: M.Snap, +v: M.View) -> List<&2, List<&2, F.Seg>>:
156  match s:
157    case M.Snap{now, off, h, as, lg}:
158      empty_or(as, blocks(as, v, h, now, off, False{}, True{}))
159
160def max_opt(+a: Maybe<&2, U32>, +b: Maybe<&2, U32>) -> Maybe<&2, U32>:
161  match a b:
162    case None{} _:
163      b
164    case Some{x} None{}:
165      a
166    case Some{x} Some{y}:
167      Some{U32.max(x, y)}
168
169# Headroom is what is left of the busiest known window.
170def busiest(+ws: List<&2, M.Win>, +acc: Maybe<&2, U32>) -> Maybe<&2, U32>:
171  match ws:
172    case Nil{}:
173      acc
174    case Con{w, t}:
175      busiest(t, max_opt(acc, M.win_pct(w)))
176
177def free_some(+m: Maybe<&2, U32>) -> Maybe<&2, U32>:
178  match m:
179    case None{}:
180      None{}
181    case Some{c}:
182      Some{(10000 - U32.min(c, 10000) : U32)}
183
184def free_of(+a: M.Acct) -> Maybe<&2, U32>:
185  free_some(busiest(M.acct_wins(a), None{}))
186
187def eligible(+a: M.Acct) -> Bool:
188  Bool.not(M.acct_active(a)) && Bool.not(M.acct_disabled(a))
189
190def best_free(+b: Best) -> U32:
191  match b:
192    case Best{s, e, f}:
193      f
194
195def better(+cur: Maybe<&2, Best>, +f: U32) -> Bool:
196  match cur:
197    case None{}:
198      True{}
199    case Some{b}:
200      U32.is_gt(f, best_free(b))
201
202def take_if(+yes: Bool, +a: M.Acct, +x: U32, +cur: Maybe<&2, Best>) -> Maybe<&2, Best>:
203  match yes:
204    case True{}:
205      Some{Best{M.acct_slot(a), M.acct_email(a), x}}
206    case False{}:
207      cur
208
209def take(+a: M.Acct, +f: Maybe<&2, U32>, +cur: Maybe<&2, Best>) -> Maybe<&2, Best>:
210  match f:
211    case None{}:
212      cur
213    case Some{x}:
214      take_if(better(cur, x), a, x, cur)
215
216def next_pick(+ok: Bool, +a: M.Acct, +cur: Maybe<&2, Best>) -> Maybe<&2, Best>:
217  match ok:
218    case True{}:
219      take(a, free_of(a), cur)
220    case False{}:
221      cur
222
223def next_best(+as: List<&2, M.Acct>, +cur: Maybe<&2, Best>) -> Maybe<&2, Best>:
224  match as:
225    case Nil{}:
226      cur
227    case Con{a, t}:
228      next_best(t, next_pick(eligible(a), a, cur))
229
230def best_name(+b: Best) -> String:
231  match b:
232    case Best{s, e, f}:
233      s ++ " " ++ e
234
235# ---- the daemon log --------------------------------------------------------------------------------
236
237def best_line(+m: Maybe<&2, Best>) -> List<&2, F.Seg>:
238  match m:
239    case None{}:
240      [C.muted("next best  none (no other account has a reading)")]
241    case Some{b}:
242      [C.muted("next best  "), F.sg(best_name(b), F.RAccent{}), C.muted(" · " ++ pct_label(best_free(b)) ++ "% free")]
243
244def log_each(+ls: List<&2, String>) -> List<&2, List<&2, F.Seg>>:
245  match ls:
246    case Nil{}:
247      Nil{}
248    case Con{l, t}:
249      Con{[C.muted("  " ++ l)], log_each(t)}
250
251def log_lines(+ls: List<&2, String>) -> List<&2, List<&2, F.Seg>>:
252  match ls:
253    case Nil{}:
254      [[C.muted("  no daemon log yet")]]
255    case _:
256      log_each(ls)
257
258def log_of(+s: M.Snap) -> List<&2, String>:
259  match s:
260    case M.Snap{now, off, h, as, lg}:
261      lg
262
263def hdr_of(+s: M.Snap) -> M.Hdr:
264  match s:
265    case M.Snap{now, off, h, as, lg}:
266      h
267
268def now_of(+s: M.Snap) -> U32:
269  match s:
270    case M.Snap{now, off, h, as, lg}:
271      now
272
273def accts_of(+s: M.Snap) -> List<&2, M.Acct>:
274  match s:
275    case M.Snap{now, off, h, as, lg}:
276      as
277
278# ---- the frame ------------------------------------------------------------------------------------------
279
280def head_lines(+s: M.Snap) -> List<&2, List<&2, F.Seg>>:
281  [title(hdr_of(s)), state_line(hdr_of(s), now_of(s)), []]
282
283def foot_lines(+s: M.Snap) -> List<&2, List<&2, F.Seg>>:
284  lines_cat([[], best_line(next_best(accts_of(s), None{})), [], [F.sg("daemon log", F.RBold{})]], log_lines(log_of(s)))
285
286def fit_all(+ls: List<&2, List<&2, F.Seg>>, +w: Nat) -> List<&2, List<&2, F.Seg>>:
287  match ls:
288    case Nil{}:
289      Nil{}
290    case Con{l, t}:
291      Con{F.fit(l, w), fit_all(t, w)}
292
293def viewcolor(+v: M.View) -> Bool:
294  match v:
295    case M.View{w, c}:
296      c
297
298# The whole frame: each line cut to the terminal width, painted unless NO_COLOR.
299def frame(+s: M.Snap, +v: M.View) -> String:
300  F.render(fit_all(lines_cat(head_lines(s), lines_cat(accounts_lines(s, v), foot_lines(s))), U32.to_nat(C.view_width(v))), viewcolor(v))