From e70c021371fd36b1c7d1e10b64ab61b3c81d1ebc Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 16 Nov 2022 12:48:13 +0530 Subject: [PATCH] Some basic TUI widgets ported to Go --- gen-go-code.py | 25 ++++++++ tools/cli/markup/prettify.go | 5 +- tools/tui/progress-bar.go | 61 ++++++++++++++++++ tools/tui/spinners.go | 27 ++++++++ tools/utils/humanize/bytes.go | 61 ++++++++++++++++++ tools/utils/humanize/times.go | 117 ++++++++++++++++++++++++++++++++++ 6 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 tools/tui/progress-bar.go create mode 100644 tools/tui/spinners.go create mode 100644 tools/utils/humanize/bytes.go create mode 100644 tools/utils/humanize/times.go diff --git a/gen-go-code.py b/gen-go-code.py index 35209e692..d69bb4f62 100755 --- a/gen-go-code.py +++ b/gen-go-code.py @@ -12,6 +12,7 @@ from typing import Dict, Iterator, List, Set, Tuple, Union import kitty.constants as kc from kittens.tui.operations import Mode +from kittens.tui.spinners import spinners from kitty.cli import ( CompletionSpec, GoOption, go_options_for_seq, parse_option_spec, serialize_as_go_string, @@ -252,6 +253,28 @@ def go_code_for_remote_command(name: str, cmd: RemoteCommand, template: str) -> # Constants {{{ + +def generate_spinners() -> str: + ans = ['package tui', 'import "time"', 'func NewSpinner(name string) *Spinner {', 'var ans *Spinner', 'switch name {'] + a = ans.append + for name, spinner in spinners.items(): + a(f'case "{serialize_as_go_string(name)}":') + a('ans = &Spinner{') + a(f'Name: "{serialize_as_go_string(name)}",') + a(f'interval: {spinner["interval"]},') + frames = ', '.join(f'"{serialize_as_go_string(x)}"' for x in spinner['frames']) + a(f'frames: []string{{{frames}}},') + a('}') + a('}') + a('if ans != nil {') + a('ans.interval *= time.Millisecond') + a('ans.current_frame = -1') + a('ans.last_change_at = time.Now().Add(-ans.interval)') + a('}') + a('return ans}') + return '\n'.join(ans) + + def generate_color_names() -> str: return 'package style\n\nvar ColorNames = map[string]RGBA{' + '\n'.join( f'\t"{name}": RGBA{{ Red:{val.red}, Green:{val.green}, Blue:{val.blue} }},' @@ -448,6 +471,8 @@ def main() -> None: f.write(generate_color_names()) with replace_if_needed('tools/tui/readline/actions_generated.go') as f: f.write(generate_readline_actions()) + with replace_if_needed('tools/tui/spinners_generated.go') as f: + f.write(generate_spinners()) update_completion() update_at_commands() print(json.dumps(changed, indent=2)) diff --git a/tools/cli/markup/prettify.go b/tools/cli/markup/prettify.go index 558be793b..66a0639d2 100644 --- a/tools/cli/markup/prettify.go +++ b/tools/cli/markup/prettify.go @@ -18,8 +18,8 @@ var _ = fmt.Print type Context struct { fmt_ctx style.Context - Cyan, Green, Blue, BrightRed, Yellow, Italic, Bold, Title, Exe, Opt, Emph, Err, Code func(args ...interface{}) string - Url func(string, string) string + Cyan, Green, Blue, BrightRed, Yellow, Italic, Bold, Dim, Title, Exe, Opt, Emph, Err, Code func(args ...interface{}) string + Url func(string, string) string } var ( @@ -38,6 +38,7 @@ func New(allow_escape_codes bool) *Context { ans.Yellow = fmt_ctx.SprintFunc("fg=bright-yellow") ans.Italic = fmt_ctx.SprintFunc("italic") ans.Bold = fmt_ctx.SprintFunc("bold") + ans.Dim = fmt_ctx.SprintFunc("dim") ans.Title = fmt_ctx.SprintFunc("bold fg=blue") ans.Exe = fmt_ctx.SprintFunc("bold fg=bright-yellow") ans.Opt = ans.Green diff --git a/tools/tui/progress-bar.go b/tools/tui/progress-bar.go new file mode 100644 index 000000000..3d18f0628 --- /dev/null +++ b/tools/tui/progress-bar.go @@ -0,0 +1,61 @@ +// License: GPLv3 Copyright: 2022, Kovid Goyal, + +package tui + +import ( + "fmt" + "kitty/tools/cli/markup" + "strings" +) + +var _ = fmt.Print + +func RepeatChar(char string, count int) string { + if count <= 5 { + return strings.Repeat(char, count) + } + return fmt.Sprintf("%s\x1b[%db", char, count-1) +} + +func RenderProgressBar(frac float64, width int) string { + fc := markup.New(true) + if frac >= 1 { + return fc.Green(RepeatChar("🬋", width)) + } + if frac <= 0 { + return fc.Dim(RepeatChar("🬋", width)) + } + w := frac * float64(width) + fl := int(w) + overhang := w - float64(fl) + filled := RepeatChar("🬋", fl) + needs_break := false + if overhang < 0.2 { + needs_break = true + } else if overhang < 0.8 { + filled += "🬃" + fl += 1 + } else { + if fl < width-1 { + filled += "🬋" + fl += 1 + needs_break = true + } else { + filled += "🬃" + fl += 1 + } + } + ans := fc.Blue(filled) + unfilled := "" + if width > fl && needs_break { + unfilled = "🬇" + } + filler := width - fl - len(unfilled) + if filler > 0 { + unfilled += RepeatChar("🬋", filler) + } + if unfilled != "" { + ans += fc.Dim(unfilled) + } + return ans +} diff --git a/tools/tui/spinners.go b/tools/tui/spinners.go new file mode 100644 index 000000000..9dbb3032f --- /dev/null +++ b/tools/tui/spinners.go @@ -0,0 +1,27 @@ +// License: GPLv3 Copyright: 2022, Kovid Goyal, + +package tui + +import ( + "fmt" + "time" +) + +var _ = fmt.Print + +type Spinner struct { + Name string + interval time.Duration + frames []string + current_frame int + last_change_at time.Time +} + +func (self *Spinner) Tick() string { + now := time.Now() + if now.Sub(self.last_change_at) >= self.interval { + self.last_change_at = now + self.current_frame = (self.current_frame + 1) % len(self.frames) + } + return self.frames[self.current_frame] +} diff --git a/tools/utils/humanize/bytes.go b/tools/utils/humanize/bytes.go new file mode 100644 index 000000000..6bf483ea7 --- /dev/null +++ b/tools/utils/humanize/bytes.go @@ -0,0 +1,61 @@ +package humanize + +import ( + "fmt" + "math" +) + +// IEC Sizes. +// kibis of bits +const ( + Byte = 1 << (iota * 10) + KiByte + MiByte + GiByte + TiByte + PiByte + EiByte +) + +// SI Sizes. +const ( + IByte = 1 + KByte = IByte * 1000 + MByte = KByte * 1000 + GByte = MByte * 1000 + TByte = GByte * 1000 + PByte = TByte * 1000 + EByte = PByte * 1000 +) + +func logn(n, b float64) float64 { + return math.Log(n) / math.Log(b) +} + +func humanize_bytes(s uint64, base float64, sizes []string, sep string) string { + if s < 10 { + return fmt.Sprintf("%d%sB", s, sep) + } + e := math.Floor(logn(float64(s), base)) + suffix := sizes[int(e)] + val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10 + f := "%.0f%s%s" + if val < 10 { + f = "%.1f%s%s" + } + return fmt.Sprintf(f, val, sep, suffix) +} + +// Bytes produces a human readable representation of an SI size. +// Bytes(82854982) -> 83 MB +func Bytes(s uint64) string { + sizes := []string{"B", "kB", "MB", "GB", "TB", "PB", "EB"} + return humanize_bytes(s, 1000, sizes, " ") +} + +// IBytes produces a human readable representation of an IEC size. +// IBytes(82854982) -> 79 MiB +func IBytes(s uint64) string { + sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"} + return humanize_bytes(s, 1024, sizes, " ") +} diff --git a/tools/utils/humanize/times.go b/tools/utils/humanize/times.go new file mode 100644 index 000000000..dd3fbf5ef --- /dev/null +++ b/tools/utils/humanize/times.go @@ -0,0 +1,117 @@ +package humanize + +import ( + "fmt" + "math" + "sort" + "time" +) + +// Seconds-based time units +const ( + Day = 24 * time.Hour + Week = 7 * Day + Month = 30 * Day + Year = 12 * Month + LongTime = 37 * Year +) + +// Time formats a time into a relative string. +// +// Time(someT) -> "3 weeks ago" +func Time(then time.Time) string { + return RelTime(then, time.Now(), "ago", "from now") +} + +// A RelTimeMagnitude struct contains a relative time point at which +// the relative format of time will switch to a new format string. A +// slice of these in ascending order by their "D" field is passed to +// CustomRelTime to format durations. +// +// The Format field is a string that may contain a "%s" which will be +// replaced with the appropriate signed label (e.g. "ago" or "from +// now") and a "%d" that will be replaced by the quantity. +// +// The DivBy field is the amount of time the time difference must be +// divided by in order to display correctly. +// +// e.g. if D is 2*time.Minute and you want to display "%d minutes %s" +// DivBy should be time.Minute so whatever the duration is will be +// expressed in minutes. +type RelTimeMagnitude struct { + D time.Duration + Format string + DivBy time.Duration +} + +var defaultMagnitudes = []RelTimeMagnitude{ + {time.Second, "now", time.Second}, + {2 * time.Second, "1 second %s", 1}, + {time.Minute, "%d seconds %s", time.Second}, + {2 * time.Minute, "1 minute %s", 1}, + {time.Hour, "%d minutes %s", time.Minute}, + {2 * time.Hour, "1 hour %s", 1}, + {Day, "%d hours %s", time.Hour}, + {2 * Day, "1 day %s", 1}, + {Week, "%d days %s", Day}, + {2 * Week, "1 week %s", 1}, + {Month, "%d weeks %s", Week}, + {2 * Month, "1 month %s", 1}, + {Year, "%d months %s", Month}, + {18 * Month, "1 year %s", 1}, + {2 * Year, "2 years %s", 1}, + {LongTime, "%d years %s", Year}, + {math.MaxInt64, "a long while %s", 1}, +} + +// RelTime formats a time into a relative string. +// +// It takes two times and two labels. In addition to the generic time +// delta string (e.g. 5 minutes), the labels are used applied so that +// the label corresponding to the smaller time is applied. +// +// RelTime(timeInPast, timeInFuture, "earlier", "later") -> "3 weeks earlier" +func RelTime(a, b time.Time, albl, blbl string) string { + return CustomRelTime(a, b, albl, blbl, defaultMagnitudes) +} + +// CustomRelTime formats a time into a relative string. +// +// It takes two times two labels and a table of relative time formats. +// In addition to the generic time delta string (e.g. 5 minutes), the +// labels are used applied so that the label corresponding to the +// smaller time is applied. +func CustomRelTime(a, b time.Time, albl, blbl string, magnitudes []RelTimeMagnitude) string { + lbl := albl + diff := b.Sub(a) + + if a.After(b) { + lbl = blbl + diff = a.Sub(b) + } + + n := sort.Search(len(magnitudes), func(i int) bool { + return magnitudes[i].D > diff + }) + + if n >= len(magnitudes) { + n = len(magnitudes) - 1 + } + mag := magnitudes[n] + args := []interface{}{} + escaped := false + for _, ch := range mag.Format { + if escaped { + switch ch { + case 's': + args = append(args, lbl) + case 'd': + args = append(args, diff/mag.DivBy) + } + escaped = false + } else { + escaped = ch == '%' + } + } + return fmt.Sprintf(mag.Format, args...) +}