562236085b
The code in //users/wpcarro/tools/monzo_ynab/ynab/client.go was not valid Go and has been commented out. Change-Id: Icb4003607f30294dcbf60132eb7722702c7f0d84 Reviewed-on: https://cl.tvl.fyi/c/depot/+/4400 Tested-by: BuildkiteCI Reviewed-by: wpcarro <wpcarro@gmail.com> Reviewed-by: Profpatsch <mail@profpatsch.de>
431 lines
8.7 KiB
Go
431 lines
8.7 KiB
Go
package main
|
||
|
||
import (
|
||
json "encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"os"
|
||
"sort"
|
||
"strings"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
lipgloss "github.com/charmbracelet/lipgloss"
|
||
// termenv "github.com/muesli/termenv"
|
||
// isatty "github.com/mattn/go-isatty"
|
||
)
|
||
|
||
// Keeps the full data structure and a path that indexes our current position into it.
|
||
type model struct {
|
||
path []index
|
||
data val
|
||
}
|
||
|
||
// an index into a value, uint for lists and string for maps.
|
||
// nil for any scalar value.
|
||
// TODO: use an actual interface for these
|
||
type index interface{}
|
||
|
||
/// recursive value that we can represent.
|
||
type val struct {
|
||
// the “type” of value; see tag const belove
|
||
tag tag
|
||
// last known position of our cursor
|
||
last_index index
|
||
// documentation (TODO)
|
||
doc string
|
||
// the actual value;
|
||
// the actual structure is behind a pointer so we can replace the struct.
|
||
// determined by the tag
|
||
// tagString -> *string
|
||
// tagFloat -> *float64
|
||
// tagList -> *[]val
|
||
// tagMap -> *map[string]val
|
||
val interface{}
|
||
}
|
||
|
||
type tag string
|
||
|
||
const (
|
||
tagString tag = "string"
|
||
tagFloat tag = "float"
|
||
tagList tag = "list"
|
||
tagMap tag = "map"
|
||
)
|
||
|
||
// print a value, flat
|
||
func (v val) Render() string {
|
||
s := ""
|
||
switch v.tag {
|
||
case tagString:
|
||
s += *v.val.(*string)
|
||
case tagFloat:
|
||
s += fmt.Sprint(*v.val.(*float64))
|
||
case tagList:
|
||
s += "[ "
|
||
vs := []string{}
|
||
for _, enum := range v.enumerate() {
|
||
vs = append(vs, enum.v.Render())
|
||
}
|
||
s += strings.Join(vs, ", ")
|
||
s += " ]"
|
||
case tagMap:
|
||
s += "{ "
|
||
vs := []string{}
|
||
for _, enum := range v.enumerate() {
|
||
vs = append(vs, fmt.Sprintf("%s: %s", enum.i.(string), enum.v.Render()))
|
||
}
|
||
s += strings.Join(vs, ", ")
|
||
s += " }"
|
||
default:
|
||
s += fmt.Sprintf("<unknown: %v>", v)
|
||
}
|
||
return s
|
||
}
|
||
|
||
// render an index, depending on the type
|
||
func renderIndex(i index) (s string) {
|
||
switch i := i.(type) {
|
||
case nil:
|
||
s = ""
|
||
// list index
|
||
case uint:
|
||
s = "*"
|
||
// map index
|
||
case string:
|
||
s = i + ":"
|
||
}
|
||
return
|
||
}
|
||
|
||
// take an arbitrary (within restrictions) go value and construct a val from it
|
||
func makeVal(i interface{}) val {
|
||
var v val
|
||
switch i := i.(type) {
|
||
case string:
|
||
v = val{
|
||
tag: tagString,
|
||
last_index: index(nil),
|
||
doc: "",
|
||
val: &i,
|
||
}
|
||
case float64:
|
||
v = val{
|
||
tag: tagFloat,
|
||
last_index: index(nil),
|
||
doc: "",
|
||
val: &i,
|
||
}
|
||
case []interface{}:
|
||
ls := []val{}
|
||
for _, i := range i {
|
||
ls = append(ls, makeVal(i))
|
||
}
|
||
v = val{
|
||
tag: tagList,
|
||
last_index: pos1Inner(tagList, &ls),
|
||
doc: "",
|
||
val: &ls,
|
||
}
|
||
case map[string]interface{}:
|
||
ls := map[string]val{}
|
||
for k, i := range i {
|
||
ls[k] = makeVal(i)
|
||
}
|
||
v = val{
|
||
tag: tagMap,
|
||
last_index: pos1Inner(tagMap, &ls),
|
||
doc: "",
|
||
val: &ls,
|
||
}
|
||
default:
|
||
log.Fatalf("makeVal: cannot read json of type %T", i)
|
||
}
|
||
return v
|
||
}
|
||
|
||
// return an index that points at the first entry in val
|
||
func (v val) pos1() index {
|
||
return v.enumerate()[0].i
|
||
}
|
||
|
||
func pos1Inner(tag tag, v interface{}) index {
|
||
return enumerateInner(tag, v)[0].i
|
||
}
|
||
|
||
type enumerate struct {
|
||
i index
|
||
v val
|
||
}
|
||
|
||
// enumerate gives us a stable ordering of elements in this val.
|
||
// for scalars it’s just a nil index & the val itself.
|
||
// Guaranteed to always return at least one element.
|
||
func (v val) enumerate() (e []enumerate) {
|
||
e = enumerateInner(v.tag, v.val)
|
||
if e == nil {
|
||
e = append(e, enumerate{
|
||
i: nil,
|
||
v: v,
|
||
})
|
||
}
|
||
return
|
||
}
|
||
|
||
// like enumerate, but returns an empty slice for scalars without inner vals.
|
||
func enumerateInner(tag tag, v interface{}) (e []enumerate) {
|
||
switch tag {
|
||
case tagString:
|
||
fallthrough
|
||
case tagFloat:
|
||
e = nil
|
||
case tagList:
|
||
for i, v := range *v.(*[]val) {
|
||
e = append(e, enumerate{i: index(uint(i)), v: v})
|
||
}
|
||
case tagMap:
|
||
// map sorting order is not stable (actually randomized thank jabber)
|
||
// so let’s sort them
|
||
keys := []string{}
|
||
m := *v.(*map[string]val)
|
||
for k, _ := range m {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
for _, k := range keys {
|
||
e = append(e, enumerate{i: index(k), v: m[k]})
|
||
}
|
||
default:
|
||
log.Fatalf("unknown val tag %s, %v", tag, v)
|
||
}
|
||
return
|
||
}
|
||
|
||
func (m model) PathString() string {
|
||
s := "/ "
|
||
var is []string
|
||
for _, v := range m.path {
|
||
is = append(is, fmt.Sprintf("%v", v))
|
||
}
|
||
s += strings.Join(is, " / ")
|
||
return s
|
||
}
|
||
|
||
// walk the given path down in data, to get the value at that point.
|
||
// Assumes that all path indexes are valid indexes into data.
|
||
// Returns a pointer to the value at point, in order to be able to change it.
|
||
func walk(data *val, path []index) (*val, bool, error) {
|
||
res := data
|
||
atPath := func(index int) string {
|
||
return fmt.Sprintf("at path %v", path[:index+1])
|
||
}
|
||
errf := func(ty string, val interface{}, index int) error {
|
||
return fmt.Errorf("walk: can’t walk into %s %v %s", ty, val, atPath(index))
|
||
}
|
||
for i, p := range path {
|
||
switch res.tag {
|
||
case tagString:
|
||
return nil, true, nil
|
||
case tagFloat:
|
||
return nil, true, nil
|
||
case tagList:
|
||
switch p := p.(type) {
|
||
case uint:
|
||
list := *res.val.(*[]val)
|
||
if int(p) >= len(list) || p < 0 {
|
||
return nil, false, fmt.Errorf("index out of bounds %s", atPath(i))
|
||
}
|
||
res = &list[p]
|
||
default:
|
||
return nil, false, fmt.Errorf("not a list index %s", atPath(i))
|
||
}
|
||
case tagMap:
|
||
switch p := p.(type) {
|
||
case string:
|
||
m := *res.val.(*map[string]val)
|
||
if a, ok := m[p]; ok {
|
||
res = &a
|
||
} else {
|
||
return nil, false, fmt.Errorf("index %s not in map %s", p, atPath(i))
|
||
}
|
||
default:
|
||
return nil, false, fmt.Errorf("not a map index %v %s", p, atPath(i))
|
||
}
|
||
|
||
default:
|
||
return nil, false, errf(string(res.tag), res.val, i)
|
||
}
|
||
}
|
||
return res, false, nil
|
||
}
|
||
|
||
// descend into the selected index. Assumes that the index is valid.
|
||
// Will not descend into scalars.
|
||
func (m model) descend() (model, error) {
|
||
// TODO: two walks?!
|
||
this, _, err := walk(&m.data, m.path)
|
||
if err != nil {
|
||
return m, err
|
||
}
|
||
newPath := append(m.path, this.last_index)
|
||
_, bounce, err := walk(&m.data, newPath)
|
||
if err != nil {
|
||
return m, err
|
||
}
|
||
// only descend if we *can*
|
||
if !bounce {
|
||
m.path = newPath
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// ascend to one level up. stops at the root.
|
||
func (m model) ascend() (model, error) {
|
||
if len(m.path) > 0 {
|
||
m.path = m.path[:len(m.path)-1]
|
||
_, _, err := walk(&m.data, m.path)
|
||
return m, err
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
/// go to the next item, or wraparound
|
||
func (min model) next() (m model, err error) {
|
||
m = min
|
||
this, _, err := walk(&m.data, m.path)
|
||
if err != nil {
|
||
return
|
||
}
|
||
enumL := this.enumerate()
|
||
setNext := false
|
||
for _, enum := range enumL {
|
||
if setNext {
|
||
this.last_index = enum.i
|
||
setNext = false
|
||
break
|
||
}
|
||
if enum.i == this.last_index {
|
||
setNext = true
|
||
}
|
||
}
|
||
// wraparound
|
||
if setNext {
|
||
this.last_index = enumL[0].i
|
||
}
|
||
return
|
||
}
|
||
|
||
/// go to the previous item, or wraparound
|
||
func (min model) prev() (m model, err error) {
|
||
m = min
|
||
this, _, err := walk(&m.data, m.path)
|
||
if err != nil {
|
||
return
|
||
}
|
||
enumL := this.enumerate()
|
||
// last element, wraparound
|
||
prevIndex := enumL[len(enumL)-1].i
|
||
for _, enum := range enumL {
|
||
if enum.i == this.last_index {
|
||
this.last_index = prevIndex
|
||
break
|
||
}
|
||
prevIndex = enum.i
|
||
}
|
||
return
|
||
}
|
||
|
||
/// bubbletea implementations
|
||
|
||
func (m model) Init() tea.Cmd {
|
||
return nil
|
||
}
|
||
|
||
func initialModel(v interface{}) model {
|
||
val := makeVal(v)
|
||
return model{
|
||
path: []index{},
|
||
data: val,
|
||
}
|
||
}
|
||
|
||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
var err error
|
||
switch msg := msg.(type) {
|
||
case tea.KeyMsg:
|
||
switch msg.String() {
|
||
case "ctrl+c", "q":
|
||
return m, tea.Quit
|
||
|
||
case "up":
|
||
m, err = m.prev()
|
||
|
||
case "down":
|
||
m, err = m.next()
|
||
|
||
case "right":
|
||
m, err = m.descend()
|
||
|
||
case "left":
|
||
m, err = m.ascend()
|
||
|
||
// case "enter":
|
||
// _, ok := m.selected[m.cursor]
|
||
// if ok {
|
||
// delete(m.selected, m.cursor)
|
||
// } else {
|
||
// m.selected[m.cursor] = struct{}{}
|
||
// }
|
||
}
|
||
|
||
}
|
||
if err != nil {
|
||
log.Fatal(err)
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
var pathColor = lipgloss.NewStyle().
|
||
// light blue
|
||
Foreground(lipgloss.Color("12"))
|
||
|
||
var selectedColor = lipgloss.NewStyle().
|
||
Bold(true)
|
||
|
||
func (m model) View() string {
|
||
s := pathColor.Render(m.PathString())
|
||
cur, _, err := walk(&m.data, m.path)
|
||
if err != nil {
|
||
log.Fatal(err)
|
||
}
|
||
s += cur.doc + "\n"
|
||
s += "\n"
|
||
for _, enum := range cur.enumerate() {
|
||
is := renderIndex(enum.i)
|
||
if is != "" {
|
||
s += is + " "
|
||
}
|
||
if enum.i == cur.last_index {
|
||
s += selectedColor.Render(enum.v.Render())
|
||
} else {
|
||
s += enum.v.Render()
|
||
}
|
||
s += "\n"
|
||
}
|
||
|
||
// s += fmt.Sprintf("%v\n", m)
|
||
// s += fmt.Sprintf("%v\n", cur)
|
||
|
||
return s
|
||
}
|
||
|
||
func main() {
|
||
var input interface{}
|
||
err := json.NewDecoder(os.Stdin).Decode(&input)
|
||
if err != nil {
|
||
log.Fatal("json from stdin: ", err)
|
||
}
|
||
p := tea.NewProgram(initialModel(input))
|
||
if err := p.Start(); err != nil {
|
||
log.Fatal("bubbletea TUI error: ", err)
|
||
}
|
||
}
|