feat(users/Profpatsch/struct-edit): initial version

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>
This commit is contained in:
Profpatsch 2021-04-23 18:35:28 +02:00
parent 952d5480bc
commit 22a8bf93f7
2 changed files with 370 additions and 0 deletions

View file

@ -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
];
}

View file

@ -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 its 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: cant 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)
}
}