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:
parent
952d5480bc
commit
22a8bf93f7
2 changed files with 370 additions and 0 deletions
13
users/Profpatsch/struct-edit/default.nix
Normal file
13
users/Profpatsch/struct-edit/default.nix
Normal 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
|
||||
];
|
||||
}
|
357
users/Profpatsch/struct-edit/main.go
Normal file
357
users/Profpatsch/struct-edit/main.go
Normal 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 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)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue