1 import Base 2 import ./fmt.bend as F 3 import ./model.bend as M 4 import ./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. 14 type Best is Data: 15 Best{slot: String, email: String, free: U32} 16 17 def 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". 23 def 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 26 def version_of(+h: M.Hdr) -> String: 27 match h: 28 case M.Hdr{v, l, p, t, i, n}: 29 v 30 31 def title(+h: M.Hdr) -> List<&2, F.Seg>: 32 [F.sg("cswap tui", F.RBold{}), C.muted(" " ++ version_of(h))] 33 34 def live_of(+h: M.Hdr) -> Bool: 35 match h: 36 case M.Hdr{v, l, p, t, i, n}: 37 l 38 39 def 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 46 def 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 53 def 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 60 def 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 65 def 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 72 def 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 77 def 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 84 def 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 89 def 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 96 def 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 101 def 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 106 def threshold_of(+h: M.Hdr) -> Maybe<&2, U32>: 107 match h: 108 case M.Hdr{v, l, p, t, i, n}: 109 t 110 111 def is_multi(+b: List<&2, List<&2, F.Seg>>) -> Bool: 112 Nat.is_gt(List.length(&2, List<&2, F.Seg>, b), 1n) 113 114 def 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 117 def gap_if(+b: Bool) -> List<&2, List<&2, F.Seg>>: 118 match b: 119 case True{}: 120 [[]] 121 case False{}: 122 [] 123 124 def 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 133 def 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 140 def 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 143 def 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. 148 def 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 155 def 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 160 def 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. 170 def 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 177 def 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 184 def free_of(+a: M.Acct) -> Maybe<&2, U32>: 185 free_some(busiest(M.acct_wins(a), None{})) 186 187 def eligible(+a: M.Acct) -> Bool: 188 Bool.not(M.acct_active(a)) && Bool.not(M.acct_disabled(a)) 189 190 def best_free(+b: Best) -> U32: 191 match b: 192 case Best{s, e, f}: 193 f 194 195 def 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 202 def 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 209 def 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 216 def 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 223 def 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 230 def best_name(+b: Best) -> String: 231 match b: 232 case Best{s, e, f}: 233 s ++ " " ++ e 234 235 # ---- the daemon log -------------------------------------------------------------------------------- 236 237 def 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 244 def 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 251 def 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 258 def log_of(+s: M.Snap) -> List<&2, String>: 259 match s: 260 case M.Snap{now, off, h, as, lg}: 261 lg 262 263 def hdr_of(+s: M.Snap) -> M.Hdr: 264 match s: 265 case M.Snap{now, off, h, as, lg}: 266 h 267 268 def now_of(+s: M.Snap) -> U32: 269 match s: 270 case M.Snap{now, off, h, as, lg}: 271 now 272 273 def 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 280 def 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 283 def 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 286 def 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 293 def 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. 299 def 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))