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
				
			
		
							
								
								
									
										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…
	
	Add table
		Add a link
		
	
		Reference in a new issue