about summary refs log tree commit diff
diff options
context:
space:
mode:
authorProfpatsch <mail@profpatsch.de>2021-04-23T16·35+0200
committerProfpatsch <mail@profpatsch.de>2021-04-23T18·30+0000
commit22a8bf93f7802e1281b41338408844cf453410ac (patch)
tree7d9b2c40848d0474c730d19cb6543abd190676c5
parent952d5480bcd29ba28991503c25863c736e9ff85b (diff)
feat(users/Profpatsch/struct-edit): initial version r/2538
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 <mail@profpatsch.de>
-rw-r--r--users/Profpatsch/struct-edit/default.nix13
-rw-r--r--users/Profpatsch/struct-edit/main.go357
2 files changed, 370 insertions, 0 deletions
diff --git a/users/Profpatsch/struct-edit/default.nix b/users/Profpatsch/struct-edit/default.nix
new file mode 100644
index 000000000000..970cdd4d028b
--- /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 000000000000..3f786372eb0b
--- /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("<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,
+			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)
+	}
+}