From 22a8bf93f7802e1281b41338408844cf453410ac Mon Sep 17 00:00:00 2001 From: Profpatsch Date: Fri, 23 Apr 2021 18:35:28 +0200 Subject: feat(users/Profpatsch/struct-edit): initial version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A take at a TUI-based structural editor, which should eventually read a type definition of a structure and some values, and build a GUI to edit it. So far you can only pipe it some restricted json (lists, strings and floats) and “navigate” through the structure with the arrow keys. Change-Id: I7c8546459ff86c766fc03723f732c7d9f863ceaa Reviewed-on: https://cl.tvl.fyi/c/depot/+/2862 Tested-by: BuildkiteCI Reviewed-by: Profpatsch --- users/Profpatsch/struct-edit/default.nix | 13 ++ users/Profpatsch/struct-edit/main.go | 357 +++++++++++++++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 users/Profpatsch/struct-edit/default.nix create mode 100644 users/Profpatsch/struct-edit/main.go diff --git a/users/Profpatsch/struct-edit/default.nix b/users/Profpatsch/struct-edit/default.nix new file mode 100644 index 0000000000..970cdd4d02 --- /dev/null +++ b/users/Profpatsch/struct-edit/default.nix @@ -0,0 +1,13 @@ +{ depot, ... }: +depot.nix.buildGo.program { + name = "struct-edit"; + srcs = [ + ./main.go + ]; + deps = [ + depot.third_party.gopkgs."github.com".charmbracelet.bubbletea + depot.third_party.gopkgs."github.com".charmbracelet.lipgloss + depot.third_party.gopkgs."github.com".muesli.termenv + depot.third_party.gopkgs."github.com".mattn.go-isatty + ]; +} diff --git a/users/Profpatsch/struct-edit/main.go b/users/Profpatsch/struct-edit/main.go new file mode 100644 index 0000000000..3f786372eb --- /dev/null +++ b/users/Profpatsch/struct-edit/main.go @@ -0,0 +1,357 @@ +package main + +import ( + json "encoding/json" + "fmt" + "log" + "os" + "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. +// selectedIndex is the currently selected item. (TODO: save per level) +type model struct { + path []index + selectedIndex 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 + // documentation (TODO) + doc string + // the actual value; + // determined by the tag + // tagString -> string + // tagFloat -> float64 + // tagList -> []val + val interface{} +} + +type tag string + +const ( + tagString tag = "string" + tagFloat tag = "float" + tagList tag = "list" +) + +// 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 _, v := range v.val.([]val) { + vs = append(vs, v.Render()) + } + s += strings.Join(vs, ", ") + s += " ]" + default: + s += fmt.Sprintf("", 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, + doc: "", + val: i, + } + case float64: + v = val{ + tag: tagFloat, + doc: "", + val: i, + } + case []interface{}: + ls := []val{} + for _, i := range i { + ls = append(ls, makeVal(i)) + } + v = val{ + tag: tagList, + 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 { + switch v.tag { + case tagList: + return index(uint(0)) + default: + return index(nil) + } +} + +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) { + switch v.tag { + case tagString: + fallthrough + case tagFloat: + e = []enumerate{enumerate{i: index(nil), v: v}} + case tagList: + for i, v := range v.val.([]val) { + e = append(e, enumerate{i: index(uint(i)), v: v}) + } + default: + log.Fatalf("unknown val tag %s, %v", 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. +func walk(data val, path []index) (val, error) { + 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 data.tag { + case tagString: + return data, errf("string", data.val, i) + case tagFloat: + return data, errf("float", data.val, i) + case tagList: + switch p := p.(type) { + case uint: + list := data.val.([]val) + if int(p) >= len(list) || p < 0 { + return data, fmt.Errorf("index out of bounds " + atPath(i)) + } + data = list[p] + default: + return data, fmt.Errorf("not a list index " + atPath(i)) + } + default: + return data, errf(string(data.tag), data.val, i) + } + } + return data, nil +} + +// descend into the selected index. Assumes that the index is valid. +// Will not descend into scalars. +func (m model) descend() (model, error) { + newPath := append(m.path, m.selectedIndex) + lower, err := walk(m.data, newPath) + // only descend if we *can* (TODO: can we distinguish bad errors from scalar?) + if err == nil { + m.path = newPath + m.selectedIndex = lower.pos1() + } + 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] + upper, err := walk(m.data, m.path) + m.selectedIndex = upper.pos1() + return m, err + } + return m, nil +} + +/// go to the next item, or wraparound +func (min model) next() (m model, err error) { + m = min + var this val + this, err = walk(m.data, m.path) + enumL := this.enumerate() + setNext := false + for _, enum := range enumL { + if setNext { + m.selectedIndex = enum.i + setNext = false + break + } + if enum.i == m.selectedIndex { + setNext = true + } + } + // wraparound + if setNext { + m.selectedIndex = enumL[0].i + } + return +} + +/// go to the previous item, or wraparound +func (min model) prev() (m model, err error) { + m = min + var this val + this, err = walk(m.data, m.path) + enumL := this.enumerate() + // last element, wraparound + prevIndex := enumL[len(enumL)-1].i + for _, enum := range enumL { + if enum.i == m.selectedIndex { + m.selectedIndex = 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, + selectedIndex: val.pos1(), + } +} + +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.ascend() + + case "down": + m, err = m.descend() + + case "right": + m, err = m.next() + + case "left": + m, err = m.prev() + + // 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 += "\n" + for _, enum := range cur.enumerate() { + is := renderIndex(enum.i) + if is != "" { + s += is + " " + } + if enum.i == m.selectedIndex { + 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) + } +} -- cgit 1.4.1