Skip to content

Commit

Permalink
Introduce pkg/etk, a new TUI framework.
Browse files Browse the repository at this point in the history
The framework has the following interesting features:

- It has a first-class Elvish binding with the same expressive power as the Go
  version of the framework.

- It has an immediate-mode API, meaning that a component is represented by a
  function that gets invoked every time the state changes.

For more on the motivation and tradeoffs of the design, see the slidedeck in
website/slides/draft-etk.md.

This package will eventually replace pkg/cli/tk as Elvish's internal TUI
framework, and Elvish's TUI will be rewritten to use it.
  • Loading branch information
xiaq committed Sep 11, 2024
1 parent 29dae56 commit e80acb1
Show file tree
Hide file tree
Showing 46 changed files with 3,612 additions and 7 deletions.
70 changes: 70 additions & 0 deletions apps.elv
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
use etk

# mkdir prompt - what can be done today:
var w
set w = (edit:new-codearea [&prompt=(styled 'mkdir:' inverse)' ' &on-submit={ mkdir (edit:get-state $w)[buffer][content] }])
edit:push-addon $w

# mkdir prompt - slightly cleaned up version
edit:push-addon (etk:new-codearea [&prompt='mkdir: ' &on-submit={|s| mkdir $s[buffer][content] }])

# mkdir prompt - state management version
var dirname
edit:push-addon {
etk:textbox [&prompt='mkdir: ' &on-submit={ mkdir $dirname }] ^
[&buffer=[&content=(bind dirname)]]
}

# Temperature conversion
var c = ''
etk:run-app {
var f = (/ (- $c 32) 1.8)
etk:vbox [&children=[
(etk:textbox [&prompt='input: '] [&buffer=[&content=(bind c)]])
(etk:label $c' ℉ = '$f' ℃')
]]
}

# Elvish configuration helper
var tasks = [
[&name='Use readline binding'
&detail='Readline binding enables keys like Ctrl-N, Ctrl-F'
&eval-code=''
&rc-code='use readline-binding']

[&name='Install Carapace'
&detail='Carapace provides completions.'
&eval-code='brew install carapace'
&rc-code='eval (carapace init elvish)']
]

fn execute-task {|task|
eval $task[eval-code]
eval $task[rc-code]
echo $task[rc-code] >> $runtime:rc-file
}

var i = (num 0)
etk:run-app {
etk:hbox [&children=[
(etk:list [&items=$tasks &display={|t| put $t[name]} &on-submit=$execute-task~] ^
[&selected=(bind i)])
(etk:label $tasks[i][detail])
]]
}

# Markdown-driven presentation
var filename = 'a.md'
var @slides = (slurp < $filename |
re:split '\n {0,3}((?:-[ \t]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})\n' (one))

var i = (num 0)
etk:run-app {
etk:vbox [
&binding=[&Left={|_| set i = (- $i 1) } &Right={|_| set i = (+ $i 1) }]
&children=[
(etk:label $slides[i])
(etk:label (+ 1 $i)/(count $slides))
]
]
}
13 changes: 13 additions & 0 deletions pkg/edit/custom_widget.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package edit

import (
"src.elv.sh/pkg/cli"
"src.elv.sh/pkg/eval"
)

func initCustomWidgetAPI(app cli.App, nb eval.NsBuilder) {
nb.AddGoFns(map[string]any{
"push-addon": app.PushAddon,
"pop-addon": app.PopAddon,
})
}
1 change: 1 addition & 0 deletions pkg/edit/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ func NewEditor(tty cli.TTY, ev *eval.Evaler, st storedefs.Store) *Editor {
initMiscBuiltins(ed, nb)
initStateAPI(ed.app, nb)
initStoreAPI(ed.app, nb, hs)
initCustomWidgetAPI(ed.app, nb)

ed.ns = nb.Ns()
initElvishState(ev, ed.ns)
Expand Down
168 changes: 168 additions & 0 deletions pkg/etk/binding.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package etk

/*
// Ns provides the etk: module, an Elvish binding for this TUI framework.
var Ns = eval.BuildNsNamed("etk").
AddVars(map[string]vars.Var{
"codearea": vars.NewReadOnly(CodeArea),
"listbox": vars.NewReadOnly(ListBox),
}).
AddGoFns(map[string]any{
"comp": comp,
"vbox": vbox,
"handle": handle,
"adapt-to-widget": adaptToWidget,
"text-buffer": func(content string, dot int) tk.CodeBuffer {
return tk.CodeBuffer{Content: content, Dot: dot}
},
}).Ns()
func comp(fm *eval.Frame, fn eval.Callable) Comp {
return func(c Context) (View, React) {
subcompViews := map[string]View{}
subcompReacts := map[string]React{}
var this = eval.BuildNs().AddVars(map[string]vars.Var{
"state": stateSubTreeVar(c),
"subcomp": vars.FromGet(func() any {
m := vals.EmptyMap
for k, v := range subcomps {
m = m.Assoc(k, v)
}
return m
}),
}).AddGoFns(map[string]any{
"state": func(name string, _eq string, init any) {
State(c, name, init)
},
"subcomp": func(name string, f Comp, setStatesMap vals.Map) (Scene, error) {
setStates, err := convertSetStates(setStatesMap)
if err != nil {
return Scene{}, err
}
el := c.Subcomp(name, WithStates(f, setStates...))
subcomps[name] = el
return el, nil
},
}).Ns()
p1, getOut, err := eval.ValueCapturePort()
if err != nil {
return errElement(err)
}
err = fm.Evaler.Call(fn, eval.CallCfg{Args: []any{this}},
eval.EvalCfg{Ports: []*eval.Port{nil, p1, nil}})
if err != nil {
return errElement(err)
}
outs := getOut()
if len(outs) != 1 {
return errElement(fmt.Errorf("should only have one output"))
}
el, ok := outs[0].(Scene)
if !ok {
return errElement(fmt.Errorf("output should be element"))
}
return el
}
}
type stateSubTreeVar Context
func (v stateSubTreeVar) Get() any {
return getPath(*v.state, v.path)
}
func (v stateSubTreeVar) Set(val any) error {
valMap, ok := val.(vals.Map)
if !ok {
return fmt.Errorf("must be map")
}
*v.state = assocPath(*v.state, v.path, valMap)
return nil
}
func convertSetStates(m vals.Map) ([]any, error) {
var setStates []any
for it := m.Iterator(); it.HasElem(); it.Next() {
k, v := it.Elem()
name, ok := k.(string)
if !ok {
return nil, fmt.Errorf("key should be string")
}
setStates = append(setStates, name, v)
}
return setStates, nil
}
func vbox(fm *eval.Frame, rowsList vals.List, propsMap vals.Map) Scene {
var rows []View
for it := rowsList.Iterator(); it.HasElem(); it.Next() {
elem, ok := it.Elem().(Scene)
if !ok {
return errElement(fmt.Errorf("vbox needs elements"))
}
rows = append(rows, elem.View)
}
focusAny, ok := propsMap.Index("focus")
if !ok {
return errElement(fmt.Errorf("vbox needs focus"))
}
focus, ok := focusAny.(int)
if !ok {
return errElement(fmt.Errorf("vbox needs int focus"))
}
handlerAny, ok := propsMap.Index("handler")
if !ok {
return errElement(fmt.Errorf("vbox needs handler"))
}
handler, ok := handlerAny.(eval.Callable)
if !ok {
return errElement(fmt.Errorf("vbox needs callable handler"))
}
return VBoxView{Rows: rows, Focus: focus}.WithHandler(func(e term.Event) Action {
s := ""
if ke, ok := e.(term.KeyEvent); ok {
s = ui.Key(ke).String()
}
p1, getOut, err := eval.ValueCapturePort()
if err != nil {
// How do I indicate error here 😨
Notify(ui.T(fmt.Sprintf("value capture port error: %s", err)))
return Errored
}
err = fm.Evaler.Call(handler, eval.CallCfg{Args: []any{e, s}},
eval.EvalCfg{Ports: []*eval.Port{nil, p1, nil}})
if err != nil {
// How do I indicate error here 😨
var sb strings.Builder
diag.ShowError(&sb, err)
Notify(ui.T("handler exception"))
Notify(ui.ParseSGREscapedText(sb.String()))
return Errored
}
for _, out := range getOut() {
if action, ok := out.(Action); ok {
return action
}
}
// TODO: Error when there's no than one Action output
return Errored
})
}
func errElement(err error) Scene {
return Text(ui.T(err.Error(), ui.FgRed)).WithHandler(func(term.Event) Action { return Unused })
}
func handle(el Scene, ev term.Event) Action {
return el.React(ev)
}
func adaptToWidget(f func(Context) Scene) tk.Widget {
return AdaptToWidget(f)
}
*/
Loading

0 comments on commit e80acb1

Please sign in to comment.