diff --git a/apps.elv b/apps.elv new file mode 100644 index 000000000..feb4b146f --- /dev/null +++ b/apps.elv @@ -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)) + ] + ] +} diff --git a/pkg/edit/custom_widget.go b/pkg/edit/custom_widget.go new file mode 100644 index 000000000..cc6ad5927 --- /dev/null +++ b/pkg/edit/custom_widget.go @@ -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, + }) +} diff --git a/pkg/edit/editor.go b/pkg/edit/editor.go index 08288112c..baa80e4ab 100644 --- a/pkg/edit/editor.go +++ b/pkg/edit/editor.go @@ -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) diff --git a/pkg/etk/binding.go b/pkg/etk/binding.go new file mode 100644 index 000000000..6ba36e5b7 --- /dev/null +++ b/pkg/etk/binding.go @@ -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) +} +*/ diff --git a/pkg/etk/codearea.go b/pkg/etk/codearea.go new file mode 100644 index 000000000..fe6548321 --- /dev/null +++ b/pkg/etk/codearea.go @@ -0,0 +1,199 @@ +package etk + +import ( + "strings" + "unicode" + "unicode/utf8" + + "src.elv.sh/pkg/cli/term" + "src.elv.sh/pkg/parse" + "src.elv.sh/pkg/ui" +) + +// TODO: Rename to TextArea +func CodeArea(c Context) (View, React) { + quotePasteVar := State(c, "quote-paste", false) + + pastingVar := State(c, "pasting", false) + pasteBufferVar := State(c, "paste-buffer", &strings.Builder{}) + innerView, innerReact := codeAreaWithAbbr(c) + bufferVar := BindState[CodeBuffer](c, "buffer") + + return innerView, c.WithBinding("codearea", func(event term.Event) Reaction { + switch event := event.(type) { + case term.PasteSetting: + startPaste := bool(event) + // TODO: + // resetInserts() + if startPaste { + pastingVar.Set(true) + } else { + text := pasteBufferVar.Get().String() + pasteBufferVar.Set(new(strings.Builder)) + pastingVar.Set(false) + + if quotePasteVar.Get() { + text = parse.Quote(text) + } + bufferVar.Swap(insertAtDot(text)) + } + return Consumed + case term.KeyEvent: + key := ui.Key(event) + if pastingVar.Get() { + if isFuncKey(key) { + // TODO: Notify the user of the error, or insert the + // original character as is. + } else { + pasteBufferVar.Get().WriteRune(key.Rune) + } + return Consumed + } + } + return innerReact(event) + }) +} + +func codeAreaWithAbbr(c Context) (View, React) { + abbrVar := State(c, "abbr", func(func(a, f string)) {}) + cmdAbbrVar := State(c, "cmd-abbr", func(func(a, f string)) {}) + smallWordAbbr := State(c, "small-word-abbr", func(func(a, f string)) {}) + + streakVar := State(c, "streak", "") + innerView, innerReact := codeAreaCore(c) + bufferVar := BindState[CodeBuffer](c, "buffer") + return innerView, func(event term.Event) Reaction { + if keyEvent, ok := event.(term.KeyEvent); ok { + bufferBefore := bufferVar.Get() + reaction := innerReact(event) + if reaction != Consumed { + return reaction + } + buffer := bufferVar.Get() + if inserted, ok := isLiteralInsert(keyEvent, bufferBefore, buffer); ok { + streak := streakVar.Get() + inserted + if newBuffer, ok := expandSimpleAbbr(abbrVar.Get(), buffer, streak); ok { + bufferVar.Set(newBuffer) + streakVar.Set("") + return Consumed + } + if newBuffer, ok := expandCmdAbbr(cmdAbbrVar.Get(), buffer, streak); ok { + bufferVar.Set(newBuffer) + streakVar.Set("") + return Consumed + } + if newBuffer, ok := expandSmallWordAbbr(smallWordAbbr.Get(), buffer, streak, keyEvent.Rune, categorizeSmallWord); ok { + bufferVar.Set(newBuffer) + streakVar.Set("") + return Consumed + } + streakVar.Set(streak) + } else { + streakVar.Set("") + } + return Consumed + } else { + return innerReact(event) + } + } +} + +func isLiteralInsert(event term.KeyEvent, before, after CodeBuffer) (string, bool) { + key := ui.Key(event) + if isFuncKey(key) { + return "", false + } else { + text := string(key.Rune) + if after == insertAtDot(text)(before) { + return text, true + } else { + return "", false + } + } +} + +func codeAreaCore(c Context) (View, React) { + promptVar := State(c, "prompt", ui.T("")) + rpromptVar := State(c, "rprompt", ui.T("")) + bufferVar := State(c, "buffer", CodeBuffer{}) + pendingVar := State(c, "pending", PendingCode{}) + highlighterVar := State(c, "highlighter", + func(code string) (ui.Text, []ui.Text) { return ui.T(code), nil }) + + buffer := bufferVar.Get() + code, pFrom, pTo := patchPending(buffer, pendingVar.Get()) + styledCode, tips := highlighterVar.Get()(code.Content) + if pFrom < pTo { + // Apply stylingForPending to [pFrom, pTo) + parts := styledCode.Partition(pFrom, pTo) + pending := ui.StyleText(parts[1], stylingForPending) + styledCode = ui.Concat(parts[0], pending, parts[2]) + } + + view := &codeAreaView{ + promptVar.Get(), rpromptVar.Get(), + styledCode, bufferVar.Get().Dot, tips, + } + return view, func(event term.Event) Reaction { + if event, ok := event.(term.KeyEvent); ok { + key := ui.Key(event) + // Implement the absolute essential functionalities here. Others + // can be added via keybindings. + switch key { + case ui.K(ui.Backspace), ui.K('H', ui.Ctrl): + bufferVar.Swap(backspace) + return Consumed + case ui.K(ui.Enter): + return Finish + default: + if !isFuncKey(key) && unicode.IsGraphic(key.Rune) { + bufferVar.Swap(insertAtDot(string(key.Rune))) + return Consumed + } + } + } + return Unused + } +} + +// CodeBuffer represents the buffer of the CodeArea widget. +type CodeBuffer struct { + // Content of the buffer. + Content string + // Position of the dot (more commonly known as the cursor), as a byte index + // into Content. + Dot int +} + +func insertAtDot(text string) func(CodeBuffer) CodeBuffer { + return func(buf CodeBuffer) CodeBuffer { + return CodeBuffer{ + Content: buf.Content[:buf.Dot] + text + buf.Content[buf.Dot:], + Dot: buf.Dot + len(text), + } + } +} + +func backspace(buf CodeBuffer) CodeBuffer { + _, chop := utf8.DecodeLastRuneInString(buf.Content[:buf.Dot]) + return CodeBuffer{ + Content: buf.Content[:buf.Dot-chop] + buf.Content[buf.Dot:], + Dot: buf.Dot - chop, + } +} + +// PendingCode represents pending code, such as during completion. +type PendingCode struct { + // Beginning index of the text area that the pending code replaces, as a + // byte index into RawState.Code. + From int + // End index of the text area that the pending code replaces, as a byte + // index into RawState.Code. + To int + // The content of the pending code. + Content string +} + +func isFuncKey(key ui.Key) bool { + return key.Mod != 0 || key.Rune < 0 +} diff --git a/pkg/etk/codearea_abbr.go b/pkg/etk/codearea_abbr.go new file mode 100644 index 000000000..13d9cb2ab --- /dev/null +++ b/pkg/etk/codearea_abbr.go @@ -0,0 +1,140 @@ +package etk + +import ( + "regexp" + "strings" + "unicode" + "unicode/utf8" +) + +// Tries to expand a simple abbreviation. This function assumes the state mutex is held. +func expandSimpleAbbr(simpleAbbr func(func(a, f string)), buf CodeBuffer, streak string) (CodeBuffer, bool) { + var abbr, full string + // Find the longest matching abbreviation. + simpleAbbr(func(a, f string) { + if strings.HasSuffix(streak, a) && len(a) > len(abbr) { + abbr, full = a, f + } + }) + if len(abbr) > 0 { + return CodeBuffer{ + Content: buf.Content[:buf.Dot-len(abbr)] + full + buf.Content[buf.Dot:], + Dot: buf.Dot - len(abbr) + len(full), + }, true + } + return CodeBuffer{}, false +} + +var commandRegex = regexp.MustCompile(`(?:^|[^^]\n|\||;|{\s|\()\s*([\p{L}\p{M}\p{N}!%+,\-./:@\\_<>*]+)(\s)$`) + +// Tries to expand a command abbreviation. This function assumes the state mutex +// is held. +// +// We use a regex rather than parse.Parse() because dealing with the latter +// requires a lot of code. A simple regex is far simpler and good enough for +// this use case. The regex essentially matches commands at the start of the +// line (with potential leading whitespace) and similarly after the opening +// brace of a lambda or pipeline char. +// +// This only handles bareword commands. +func expandCmdAbbr(cmdAbbr func(func(a, f string)), buf CodeBuffer, streak string) (CodeBuffer, bool) { + if buf.Dot < len(buf.Content) { + // Command abbreviations are only expanded when inserting at the end of the buffer. + return CodeBuffer{}, false + } + + // See if there is something that looks like a bareword at the end of the buffer. + matches := commandRegex.FindStringSubmatch(buf.Content) + if len(matches) == 0 { + return CodeBuffer{}, false + } + + // Find an abbreviation matching the command. + command, whitespace := matches[1], matches[2] + var expansion string + cmdAbbr(func(a, e string) { + if a == command { + expansion = e + } + }) + if expansion == "" { + return CodeBuffer{}, false + } + + // We found a matching abbreviation -- replace it with its expansion. + newContent := buf.Content[:buf.Dot-len(command)-1] + expansion + whitespace + return CodeBuffer{ + Content: newContent, + Dot: len(newContent), + }, true +} + +// Try to expand a small word abbreviation. This function assumes the state mutex is held. +func expandSmallWordAbbr(smallWordAbbr func(func(a, f string)), buf CodeBuffer, streak string, trigger rune, categorizer func(rune) int) (CodeBuffer, bool) { + if buf.Dot < len(buf.Content) { + // Word abbreviations are only expanded when inserting at the end of the buffer. + return CodeBuffer{}, false + } + triggerLen := len(string(trigger)) + if triggerLen >= len(streak) { + // Only the trigger has been inserted, or a simple abbreviation was just + // expanded. In either case, there is nothing to expand. + return CodeBuffer{}, false + } + // The trigger is only used to determine word boundary; when considering + // what to expand, we only consider the part that was inserted before it. + inserts := streak[:len(streak)-triggerLen] + + var abbr, full string + // Find the longest matching abbreviation. + smallWordAbbr(func(a, f string) { + if len(a) <= len(abbr) { + // This abbreviation can't be the longest. + return + } + if !strings.HasSuffix(inserts, a) { + // This abbreviation was not inserted. + return + } + // Verify the trigger rune creates a word boundary. + r, _ := utf8.DecodeLastRuneInString(a) + if categorizer(trigger) == categorizer(r) { + return + } + // Verify the rune preceding the abbreviation, if any, creates a word + // boundary. + if len(buf.Content) > len(a)+triggerLen { + r1, _ := utf8.DecodeLastRuneInString(buf.Content[:len(buf.Content)-len(a)-triggerLen]) + r2, _ := utf8.DecodeRuneInString(a) + if categorizer(r1) == categorizer(r2) { + return + } + } + abbr, full = a, f + }) + if len(abbr) > 0 { + return CodeBuffer{ + Content: buf.Content[:buf.Dot-len(abbr)-triggerLen] + full + string(trigger), + Dot: buf.Dot - len(abbr) + len(full), + }, true + } + return CodeBuffer{}, false +} + +// isAlnum determines if the rune is an alphanumeric character. +func isAlnum(r rune) bool { + return unicode.IsLetter(r) || unicode.IsNumber(r) +} + +// categorizeSmallWord determines if the rune is whitespace, alphanum, or +// something else. +func categorizeSmallWord(r rune) int { + switch { + case unicode.IsSpace(r): + return 0 + case isAlnum(r): + return 1 + default: + return 2 + } +} diff --git a/pkg/etk/codearea_test.elvts b/pkg/etk/codearea_test.elvts new file mode 100644 index 000000000..197ba150b --- /dev/null +++ b/pkg/etk/codearea_test.elvts @@ -0,0 +1,502 @@ +//each:code-area-fixture + +///////////// +# rendering # +///////////// + +## prompt ## +~> setup [&prompt=(styled '~> ' bold)] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚~> β”‚ +β”‚*** Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## rprompt ## +~> setup [&rprompt=(styled "RP" inverse)] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ RPβ”‚ +β”‚ Μ…Μ‚ ##β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## buffer, dot at beginning ## +~> setup [&buffer=[&content=code &dot=0]] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚code β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## buffer, dot in the middle ## +~> setup [&buffer=[&content=code &dot=2]] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚code β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## buffer, dot at the end ## +~> setup [&buffer=[&content=code &dot=4]] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚code β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## prompt, buffer and rprompt ## +~> setup [&prompt=(styled '~> ') &rprompt=(styled 'RP') + &buffer=[&content=code &dot=4]] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚~> code RPβ”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## rprompt hidden due to lack of space ## +~> use str +~> setup [&rprompt=(styled 'I am a long rprompt' inverse)] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ I am a long rpromptβ”‚ +β”‚ Μ…Μ‚ ###################β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +~> send (str:repeat x 30) + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## code highlighting ## +~> setup [&buffer=[&content=code &dot=4] + &highlighter={|code| styled $code bold; put $nil}] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚code β”‚ +β”‚**** Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## tips from highlighter ## +~> setup [&buffer=[&content=code &dot=4] + &highlighter={|code| styled $code; put [(styled 'a tip' green)]}] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚code β”‚ +β”‚ Μ…Μ‚ β”‚ +β”‚a tip β”‚ +β”‚GGGGG β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## pending text at dot ## +~> setup [&buffer=[&content=code &dot=4] &pending=[&from=4 &to=4 &content=XY]] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚codeXY β”‚ +β”‚ _Μ…Μ‚_ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## pending text replacing text starting from the dot ## +~> setup [&buffer=[&content=code &dot=2] &pending=[&from=2 &to=4 &content=XY]] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚coXY β”‚ +β”‚ _Μ…Μ‚_ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## pending text to the left of the dot ## +~> setup [&buffer=[&content=code &dot=4] &pending=[&from=1 &to=3 &content=XY]] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚cXYe β”‚ +β”‚ __ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## pending text to the left of the dot ## +~> setup [&buffer=[&content=code &dot=1] &pending=[&from=2 &to=3 &content=XY]] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚coXYe β”‚ +β”‚ Μ…Μ‚__ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## ignore invalid pending text - from after to ## +~> setup [&buffer=[&content=code &dot=4] &pending=[&from=2 &to=1 &content=XY]] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚code β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## ignore invalid pending text - out of range ## +~> setup [&buffer=[&content=code &dot=4] &pending=[&from=5 &to=6 &content=XY]] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚code β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## when height is not enough, start from dot line, expand upwards and then downwards ## +~> setup [&buffer=[&content="a\nb\nc\nd" &dot=3]] +~> render &height=1 +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚b β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +~> render &height=2 +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚a β”‚ +β”‚ β”‚ +β”‚b β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +~> render &height=3 +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚a β”‚ +β”‚ β”‚ +β”‚b β”‚ +β”‚ Μ…Μ‚ β”‚ +β”‚c β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +//////////////////////// +# basic event handling # +//////////////////////// + +## simple inserts ## +~> send 'code' + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚code β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +~> send [Backspace] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚cod β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## uncode inserts ## +~> send δ½ ε₯½ + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚δ½ ε₯½ β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## backspace at end of buffer ## +~> send code + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚code β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +~> send [Backspace] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚cod β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## backspace at middle of buffer ## +~> setup [&buffer=[&content=code &dot=2]] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚code β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +~> send [Backspace] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚cde β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## backspace at beginning of buffer ## +~> setup [&buffer=[&content=code &dot=0]] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚code β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +~> send [Backspace] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚code β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## backspace deletes multi-byte codepoint ## +~> send δ½ ε₯½ + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚δ½ ε₯½ β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +~> send [Backspace] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚δ½  β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## Ctrl-H is equivalent to Backspace ## +// Regression test for https://b.elv.sh/1178 +~> send code + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚code β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +~> send [Ctrl-H] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚cod β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +/////////////////// +# bracketed paste # +/////////////////// + +~> send [start-paste a b] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +~> send [end-paste] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ab β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## quoted ## +~> setup ["e-paste=$true] + send [start-paste] "it's" [end-paste] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚'it''s' β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## function keys are discarded ## +~> send [start-paste a F1 b end-paste] + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ab β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## keybinding doesn't apply to bracketed pastes ## +// TODO + +//////////////// +# abbreviation # +//////////////// +//each:abbr-table-in-global + +## simple abbreviation ## +~> setup [&abbr=(abbr-table [&dn=/dev/null])] + send 'echo > d' + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚echo > d β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +~> send n + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚echo > /dev/null β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## simple abbreviation prefers longest match ## +~> setup [&abbr=(abbr-table [&n=null &dn=/dev/null])] + send 'echo > dn' + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚echo > /dev/null β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## simple abbreviation requires uninterrupted streak ## +~> setup [&abbr=(abbr-table [&dnl=/dev/null])] + send 'echo > dn' [Backspace] 'nl' + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚echo > dnl β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## simple abbreviation doesn't care about word boundaries ## +~> setup [&abbr=(abbr-table [&dn=/dev/null])] + send 'echo > xdn' + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚echo > x/dev/null β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## simple abbreviation expands anywhere ## +~> setup [&abbr=(abbr-table [&dn=/dev/null]) + &buffer=[&content=xy &dot=0]] + send dn + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚/dev/nullxy β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## small word abbreviation, triggered by space ## +~> setup [&small-word-abbr=(abbr-table [&dn=/dev/null])] + send 'echo > dn' + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚echo > dn β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +~> send ' ' + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚echo > /dev/null β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## small word abbreviation, triggered by punctuation ## +~> setup [&small-word-abbr=(abbr-table [&dn=/dev/null])] + send 'echo > dn;' + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚echo > /dev/null; β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## small word abbreviation requires full word match ## +~> setup [&small-word-abbr=(abbr-table [&dn=/dev/null])] + send 'echo > xdn ' + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚echo > xdn β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +~> setup [&small-word-abbr=(abbr-table [&dn=/dev/null])] + send 'echo > dnx' + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚echo > dnx β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + + +## small word abbreviation requires uninterrupted streak ## +~> setup [&small-word-abbr=(abbr-table [&dnl=/dev/null])] + send 'echo > dn' [Backspace] 'nl ' + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚echo > dnl β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## small word abbreviation only expands at end of buffer ## +~> setup [&small-word-abbr=(abbr-table [&dn=/dev/null]) + &buffer=[&content=code &dot=0]] + send 'dn ' + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚dn code β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## small word abbreviation prefers the longest ## +~> setup [&small-word-abbr=(abbr-table [&'|x'='| more' &'||x'='| less'])] + send 'echo ||x ' + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚echo | less β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## command abbreviation, start of buffer ## +~> setup [&cmd-abbr=(abbr-table [&eh=echo])] + send 'eh ' + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚echo β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## command abbreviation, after pipe ## +~> setup [&cmd-abbr=(abbr-table [&eh=echo])] + send 'x | eh ' + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚x | echo β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## command abbreviation, after newline ## +~> setup [&cmd-abbr=(abbr-table [&eh=echo]) + &buffer=[&content="x\n" &dot=2]] + send 'eh ' + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚x β”‚ +β”‚ β”‚ +β”‚echo β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## command abbreviation requires command position ## +~> setup [&cmd-abbr=(abbr-table [&eh=echo])] + send 'x eh ' + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚x eh β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## command abbreviation doesn't require uninterrupted streak ## +~> setup [&cmd-abbr=(abbr-table [&eco=echo])] + send 'ec' [Backspace] 'co ' + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚echo β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## command abbreviation only expands at end of buffer ## +~> setup [&cmd-abbr=(abbr-table [&eh=echo]) + &buffer=[&content=code &dot=0]] + send 'eh ' + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚eh code β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## external mutation interrupts streak ## +// TODO + +////////////////////////// +# general event handling # +////////////////////////// + +## unhandled key event ## +~> send &show-reaction [F1] +Unused + +## unhandled other event ## +~> send &show-reaction [mouse-dummy] +Unused + +## keybinding ## +// TODO + +## submission ## +~> send code +~> send &show-reaction [Enter] +Finish diff --git a/pkg/etk/codearea_view.go b/pkg/etk/codearea_view.go new file mode 100644 index 000000000..e8633b2ab --- /dev/null +++ b/pkg/etk/codearea_view.go @@ -0,0 +1,102 @@ +package etk + +import ( + "src.elv.sh/pkg/cli/term" + "src.elv.sh/pkg/ui" + "src.elv.sh/pkg/wcwidth" +) + +// View model, calculated from State and used for rendering. +type codeAreaView struct { + prompt ui.Text + rprompt ui.Text + code ui.Text + dot int + tips []ui.Text +} + +var stylingForPending = ui.Underlined + +func patchPending(c CodeBuffer, p PendingCode) (CodeBuffer, int, int) { + if p.From > p.To || p.From < 0 || p.To > len(c.Content) { + // Invalid Pending. + return c, 0, 0 + } + if p.From == p.To && p.Content == "" { + return c, 0, 0 + } + newContent := c.Content[:p.From] + p.Content + c.Content[p.To:] + newDot := 0 + switch { + case c.Dot < p.From: + // Dot is before the replaced region. Keep it. + newDot = c.Dot + case c.Dot >= p.From && c.Dot < p.To: + // Dot is within the replaced region. Place the dot at the end. + newDot = p.From + len(p.Content) + case c.Dot >= p.To: + // Dot is after the replaced region. Maintain the relative position of + // the dot. + newDot = c.Dot - (p.To - p.From) + len(p.Content) + } + return CodeBuffer{Content: newContent, Dot: newDot}, p.From, p.From + len(p.Content) +} + +func (v *codeAreaView) Render(width, height int) *term.Buffer { + bb := term.NewBufferBuilder(width) + bb.EagerWrap = true + + bb.WriteStyled(v.prompt) + if len(bb.Lines) == 1 && bb.Col*2 < bb.Width { + bb.Indent = bb.Col + } + + parts := v.code.Partition(v.dot) + bb. + WriteStyled(parts[0]). + SetDotHere(). + WriteStyled(parts[1]) + + bb.EagerWrap = false + bb.Indent = 0 + + // Handle rprompts with newlines. + if rpromptWidth := styledWcswidth(v.rprompt); rpromptWidth > 0 { + padding := bb.Width - bb.Col - rpromptWidth + if padding >= 1 { + bb.WriteSpaces(padding) + bb.WriteStyled(v.rprompt) + } + } + + for _, tip := range v.tips { + bb.Newline() + bb.WriteStyled(tip) + } + + b := bb.Buffer() + truncateToHeight(b, height) + return b +} + +func truncateToHeight(b *term.Buffer, maxHeight int) { + switch { + case len(b.Lines) <= maxHeight: + // We can show all line; do nothing. + case b.Dot.Line < maxHeight: + // We can show all lines before the cursor, and as many lines after the + // cursor as we can, adding up to maxHeight. + b.TrimToLines(0, maxHeight) + default: + // We can show maxHeight lines before and including the cursor line. + b.TrimToLines(b.Dot.Line-maxHeight+1, b.Dot.Line+1) + } +} + +func styledWcswidth(t ui.Text) int { + w := 0 + for _, seg := range t { + w += wcwidth.Of(seg.Text) + } + return w +} diff --git a/pkg/etk/combobox.go b/pkg/etk/combobox.go new file mode 100644 index 000000000..c77f9678c --- /dev/null +++ b/pkg/etk/combobox.go @@ -0,0 +1,34 @@ +package etk + +import ( + "src.elv.sh/pkg/cli/term" +) + +func ComboBox(c Context) (View, React) { + filterView, filterReact := c.Subcomp("filter", CodeArea) + filterBufferVar := BindState[CodeBuffer](c, "filter", "buffer") + listView, listReact := c.Subcomp("list", ListBox) + listItemsVar := BindState[Items](c, "list", "items") + listSelectedVar := BindState[int](c, "list", "selected") + + genListVar := State(c, "gen-list", func(string) (Items, int) { + return nil, -1 + }) + lastFilterContentVar := State(c, "-last-filter-content", "") + + return VBox(filterView, listView).WithFocus(0), + c.WithBinding("combobox", func(ev term.Event) Reaction { + if reaction := filterReact(ev); reaction != Unused { + filterContent := filterBufferVar.Get().Content + if filterContent != lastFilterContentVar.Get() { + lastFilterContentVar.Set(filterContent) + items, selected := genListVar.Get()(filterContent) + listItemsVar.Set(items) + listSelectedVar.Set(selected) + } + return reaction + } else { + return listReact(ev) + } + }) +} diff --git a/pkg/etk/etk.go b/pkg/etk/etk.go new file mode 100644 index 000000000..4d2a248c2 --- /dev/null +++ b/pkg/etk/etk.go @@ -0,0 +1,313 @@ +// Package etk implements an [immediate mode] TUI framework with managed states. +// +// Each component in the TUI is implemented by a [Comp]: a function taking a +// [Context] and returning a [View] and a [React]: +// +// - The [Context] provides access to states associated with the component and +// supports creating sub-components. +// +// - The [View] is a snapshot of the UI that reflects the current state. +// +// - The [React] is a function that will be called to react to an event. +// +// Whenever there is an update in the states, the function is called again to +// generate a pair of [View] and [React]. +// +// The state is organized into a tree, with individual state variables as leaf +// nodes and components as inner nodes. The [Context] provides access to the +// current level and all descendant levels, allowing a component to manipulate +// not just its own state, but also that of any descendant. This is the only way +// of passing information between components: if a component has any +// customizable property, it is modelled as a state that its parent can modify. +// +// # Design notes +// +// Immediate mode is an alternative to the more common [retained mode] style of +// graphics API. Some GUI frameworks using this style are [Dear ImGui] and [Gio +// UI]. [React], [SwiftUI] and [Jetpack Compose] also provide immediate mode +// APIs above an underlying [retained mode] API. +// +// Immediate mode libraries differ a lot in how component structure and state +// are managed. Etk is used to implement Elvish's terminal UI, so the choices +// made by etk is driven largely by how easy it is to create an Elvish binding +// for the framework that is maximally programmable: +// +// - The open nature of the state tree makes it easy to inspect and mutate the +// terminal UI as it is running. +// +// - The managed nature of the state tree gives us concurrency safety and +// undo/redo almost for free. +// +// - The use of [vals.Map] to back the state tree sacrifices type safety in +// the Go version of the framework, but makes Elvish integration much +// easier. +// +// [immediate mode]: https://en.wikipedia.org/wiki/Immediate_mode_(computer_graphics) +// [retained mode]: https://en.wikipedia.org/wiki/Retained_mode +// [Dear ImGui]: https://github.com/ocornut/imgui +// [Gio UI]: https://gioui.org +// [React]: https://react.dev +// [SwiftUI]: https://developer.apple.com/xcode/swiftui/ +// [Jetpack Compose]: https://developer.android.com/compose +// +//go:generate stringer -type=Reaction -output=zstring.go +package etk + +import ( + "io" + "reflect" + "slices" + "strings" + + "src.elv.sh/pkg/cli" + "src.elv.sh/pkg/cli/term" + "src.elv.sh/pkg/cli/tk" + "src.elv.sh/pkg/eval" + "src.elv.sh/pkg/eval/vals" + "src.elv.sh/pkg/must" + "src.elv.sh/pkg/ui" +) + +// TODO: Automatically remove state that hasn't been referenced. + +// For debugging - remove later ❗️ +var Notify func(ui.Text) + +// Comp is the type for a component. It is called every time the state changes +// to generate a new [Scene]. +type Comp func(Context) (View, React) + +// WithStates returns a variation of the component that overrides the initial +// value of some state variables. The variadic arguments must come in (key, +// value) pairs, and the keys must be strings. +func WithStates(f Comp, setStates ...any) Comp { + return func(c Context) (View, React) { + for i := 0; i < len(setStates); i += 2 { + key, value := setStates[i].(string), setStates[i+1] + // TODO: This is not optimal; we shouldn't have to start from the root + // every time. + if stateVar := BindState[any](c, strings.Split(key, "/")...); stateVar.GetAny() == nil { + stateVar.Set(value) + } + } + return f(c) + } +} + +func WithBeforeHook(f Comp, before func(Context)) Comp { + return func(c Context) (View, React) { + before(c) + return f(c) + } +} + +// Scene is a snapshot of the UI that reflects a fixed state. +// +// This type is isomorphic to [tk.Widget], but the latter represents a +// long-living object with a mutable state instead. The difference is best seen +// in how [tk.Widget.Handle] and [Scene.Handler] behaves: +// +// - Calling the Handle method of a [tk.Widget] causes its internal state to +// be mutated. The next call to its Render method will reflect the changed +// state. +// +// - Calling the Handler field of a Scene causes a state managed elsewhere to +// be mutated. The next call to the Render method of the Layout field still +// reflects the previous state. Instead, a new Scene needs to be generated +// at this point to reflect the changed state. + +type View tk.Renderer + +type React func(term.Event) Reaction + +// TODO: Maybe make an interface instead? +type Reaction uint32 + +const ( + Unused Reaction = iota + Consumed + Finish + FinishEOF +) + +type Binding func(ev term.Event, c Context, tag string, r React) Reaction + +// Context provides access to the state tree at the current level and all +// descendant levels. +type Context struct { + state *vals.Map + binding Binding + fm *eval.Frame + path []string +} + +func (c Context) descPath(path ...string) []string { + return slices.Concat(c.path, path) +} + +// Subcomp does the following: +// +// - Create a map state variable with the given name +// +// - Create a Comp state variable with the given name plus "-comp", using f as +// the initial value +// +// It then invokes the component with the map as the context. +func (c Context) Subcomp(name string, f Comp) (View, React) { + State(c, name, vals.EmptyMap) + compVar := State(c, name+"-comp", f) + return compVar.Get()(Context{c.state, c.binding, c.fm, c.descPath(name)}) +} + +// TODO: How to make this mandatory? +func (c Context) WithBinding(tag string, f React) React { + return func(ev term.Event) Reaction { + if c.binding != nil { + return c.binding(ev, c, tag, f) + } + return f(ev) + } +} + +// Need to support two things: +// +// - Bind a variable to a path, without initializing it +// - And initialize it +// +// TODO: If the variable has been stored with an incompatible type, augment the +// component layout with an error message + +// State returns a state variable with the given name under the current level, +// initializing it to a given value if it doesn't exist yet. +func State[T any](c Context, name string, initial T) StateVar[T] { + sv := BindState[T](c, name) + // TODO: Detect when the value exists but is of the wrong type, and log it + // as an error somewhere (where?) + if sv.GetAny() == nil { + sv.Set(initial) + } + return sv +} + +// BindState returns a state variable with the given path from the current +// level. It doesn't initialize the variable. +// +// This should only be used if the variable is initialized elsewhere, most +// typically for accessing the state of a subcomponent after the subcomponent +// has been called. +func BindState[T any](c Context, path ...string) StateVar[T] { + return StateVar[T]{c.state, c.fm, c.descPath(path...)} +} + +// StateVar provides access to a state variable, a node in the state tree. +type StateVar[T any] struct { + state *vals.Map + fm *eval.Frame + path []string +} + +// TODO: Make access concurrency-correct with a pair of mutexes and an epoch +// TODO: Clean up StateVar's interface + +func (sv StateVar[T]) Get() T { + val := getPath(*sv.state, sv.path) + // TODO: Handle error properly + return must.OK1(ScanToGo[T](val, sv.fm)) +} + +// A variant of vals.ScanToGo, with additional support for adapting an Elvish +// function to a Go function. +func ScanToGo[T any](val any, fm *eval.Frame) (T, error) { + var dst T + err := vals.ScanToGo(val, &dst) + if err == nil { + return dst, nil + } + dstType := reflect.TypeFor[T]() + if fn, ok := val.(eval.Callable); ok && dstType.Kind() == reflect.Func { + // Adapt an Elvish function to a Go function + return reflect.MakeFunc(dstType, func(args []reflect.Value) []reflect.Value { + // TODO: Handle errors properly + // TODO: Add intermediate "internal" entry to the traceback + outs := must.OK1(fm.CaptureOutput(func(fm *eval.Frame) error { + return fn.Call(fm, each(args, reflect.Value.Interface), eval.NoOpts) + })) + goOuts := make([]reflect.Value, dstType.NumOut()) + if len(outs) != len(goOuts) { + panic("wrong number of outputs") + } + for i, out := range outs { + goOutPtr := reflect.New(dstType.Out(i)) + must.OK(vals.ScanToGo(out, goOutPtr.Interface())) + goOuts[i] = reflect.Indirect(goOutPtr) + } + return goOuts + }).Interface().(T), nil + } + return zero[T](), err +} + +func (sv StateVar[T]) GetAny() any { return getPath(*sv.state, sv.path) } + +func (sv StateVar[T]) Set(t T) { *sv.state = assocPath(*sv.state, sv.path, t) } +func (sv StateVar[T]) Swap(f func(T) T) { sv.Set(f(sv.Get())) } + +func getPath(m vals.Map, path []string) any { + if len(path) == 0 { + return m + } + for len(path) > 1 { + v, _ := m.Index(path[0]) + m = v.(vals.Map) + path = path[1:] + } + v, _ := m.Index(path[0]) + return v +} + +func assocPath(m vals.Map, path []string, newVal any) vals.Map { + if len(path) == 0 { + return newVal.(vals.Map) + } + + if len(path) == 1 { + return m.Assoc(path[0], newVal) + } + v, _ := m.Index(path[0]) + return m.Assoc(path[0], assocPath(v.(vals.Map), path[1:], newVal)) +} + +func Run(tty cli.TTY, b Binding, fm *eval.Frame, f Comp) (vals.Map, error) { + restore, err := tty.Setup() + if err != nil { + return nil, err + } + defer restore() + + state := vals.EmptyMap + view, react := f(Context{&state, b, fm, nil}) + + for { + h, w := tty.Size() + buf := view.Render(w, h) + tty.UpdateBuffer(nil, buf, true /*false*/) + event, err := tty.ReadEvent() + if err != nil { + return nil, err + } + reaction := react(event) + if reaction == Finish || reaction == FinishEOF { + h, w := tty.Size() + buf := view.Render(w, h) + // TODO: This is quite subtle + buf.Extend(term.NewBufferBuilder(w).Buffer(), true) + tty.UpdateBuffer(nil, buf, false) + if reaction == FinishEOF { + return state, io.EOF + } else { + return state, nil + } + } + view, react = f(Context{&state, b, fm, nil}) + } +} diff --git a/pkg/etk/etktest/etktest.go b/pkg/etk/etktest/etktest.go new file mode 100644 index 000000000..c251edb90 --- /dev/null +++ b/pkg/etk/etktest/etktest.go @@ -0,0 +1,201 @@ +// Package etktest provides facilities for testing Etk components. +package etktest + +import ( + "fmt" + "strings" + + "src.elv.sh/pkg/cli/term" + "src.elv.sh/pkg/etk" + "src.elv.sh/pkg/eval" + "src.elv.sh/pkg/eval/vals" + "src.elv.sh/pkg/must" + "src.elv.sh/pkg/ui" + "src.elv.sh/pkg/wcwidth" +) + +type sendOpts struct{ ShowReaction bool } + +func (*sendOpts) SetDefaultOptions() {} + +type renderOpts struct{ Width, Height int } + +func (opts *renderOpts) SetDefaultOptions() { + opts.Width = 40 + opts.Height = 10 +} + +func Setup(ev *eval.Evaler, f etk.Comp) { + w := etk.Stateful(ev.CallFrame("etktest"), f) + ev.ExtendGlobal(eval.BuildNs().AddGoFns(map[string]any{ + "setup": func(m vals.Map) { + w = etk.Stateful(ev.CallFrame("etktest"), + etk.WithStates(f, must.OK1(convertSetStates(m))...)) + }, + "send": func(fm *eval.Frame, opts sendOpts, args ...any) error { + events, err := parseEvents(args) + if err != nil { + return err + } + for _, ev := range events { + reaction := w.Handle(ev) + if opts.ShowReaction { + fmt.Fprintln(fm.ByteOutput(), reaction) + } + } + return nil + }, + "render": func(fm *eval.Frame, opts renderOpts) error { + out := fm.ByteOutput() + buf := w.Render(opts.Width, opts.Height) + sd, err := bufferToStyleDown(buf, globalStylesheet) + if err != nil { + return err + } + _, err = out.WriteString(sd) + return err + }, + "refresh": w.Refresh, + }).Ns()) +} + +func MakeFixture(f etk.Comp) func(*eval.Evaler) { + return func(ev *eval.Evaler) { Setup(ev, f) } +} + +func parseEvents(args []any) ([]term.Event, error) { + var events []term.Event + for _, arg := range args { + switch arg := arg.(type) { + case string: + for _, r := range arg { + events = append(events, term.KeyEvent{Rune: r}) + } + case vals.List: + for it := arg.Iterator(); it.HasElem(); it.Next() { + elem := it.Elem() + switch elem := elem.(type) { + case string: + switch elem { + case "start-paste": + events = append(events, term.PasteSetting(true)) + case "end-paste": + events = append(events, term.PasteSetting(false)) + case "mouse-dummy": + events = append(events, term.MouseEvent{}) + default: + key, err := ui.ParseKey(elem) + if err != nil { + return nil, err + } + events = append(events, term.KeyEvent(key)) + } + default: + return nil, fmt.Errorf("element of list argument must be string, got %s", vals.ReprPlain(elem)) + } + } + default: + return nil, fmt.Errorf("argument must be string or list, got %s", vals.ReprPlain(arg)) + } + } + return events, nil +} + +// TODO: This duplicates part of styledown pkg. +var builtinStyleDownChars = map[ui.Style]rune{ + {}: ' ', + {Bold: true}: '*', + {Underlined: true}: '_', + {Inverse: true}: '#', + {Fg: ui.Red}: 'R', + {Fg: ui.Green}: 'G', +} + +// TODO: This duplicates much of (*term.Buffer).TTYString. +func bufferToStyleDown(b *term.Buffer, ss stylesheet) (string, error) { + var sb strings.Builder + // Top border + sb.WriteString("β”Œ" + strings.Repeat("─", b.Width) + "┐\n") + for i, line := range b.Lines { + // Write the content line. + sb.WriteRune('β”‚') + usedWidth := 0 + for _, cell := range line { + sb.WriteString(cell.Text) + usedWidth += wcwidth.Of(cell.Text) + } + var rightPadding string + if usedWidth < b.Width { + rightPadding = strings.Repeat(" ", b.Width-usedWidth) + sb.WriteString(rightPadding) + } + sb.WriteString("β”‚\n") + + // Write the style line. + // TODO: I shouldn't have to keep track of the column number manually + sb.WriteRune('β”‚') + col := 0 + for _, cell := range line { + style := ui.StyleFromSGR(cell.Style) + var styleChar rune + if char, ok := builtinStyleDownChars[style]; ok { + styleChar = char + } else if char, ok := ss.charForStyle[style]; ok { + styleChar = char + } else { + return "", fmt.Errorf("no char for style: %v", style) + } + styleStr := string(styleChar) + if i == b.Dot.Line && col == b.Dot.Col { + styleStr += "\u0305\u0302" // combining overline + combining circumflex + } + sb.WriteString(strings.Repeat(styleStr, wcwidth.Of(cell.Text))) + col += wcwidth.Of(cell.Text) + } + if i == b.Dot.Line && col <= b.Dot.Col { + sb.WriteString(strings.Repeat(" ", b.Dot.Col-col+1)) + sb.WriteString("\u0305\u0302") + sb.WriteString(strings.Repeat(" ", b.Width-b.Dot.Col-1)) + } else { + sb.WriteString(rightPadding) + } + sb.WriteString("β”‚\n") + } + // Bottom border + sb.WriteString("β””" + strings.Repeat("─", b.Width) + "β”˜\n") + + return sb.String(), nil +} + +var globalStylesheet = newStylesheet(map[rune]string{ + 'r': "red", +}) + +type stylesheet struct { + stringStyling map[rune]string + charForStyle map[ui.Style]rune +} + +func newStylesheet(stringStyling map[rune]string) stylesheet { + charForStyle := make(map[ui.Style]rune) + for r, s := range stringStyling { + var st ui.Style + ui.ApplyStyling(st, ui.ParseStyling(s)) + charForStyle[st] = r + } + return stylesheet{stringStyling, charForStyle} +} + +// Same as convertSetStates from pkg/etk, copied to avoid the need to export it. +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 +} diff --git a/pkg/etk/examples/buffer.go b/pkg/etk/examples/buffer.go new file mode 100644 index 000000000..3eea39c86 --- /dev/null +++ b/pkg/etk/examples/buffer.go @@ -0,0 +1,33 @@ +package main + +import ( + "unicode/utf8" + + "src.elv.sh/pkg/etk" + "src.elv.sh/pkg/strutil" +) + +func makeMove(m func(string, int) int) func(etk.CodeBuffer) etk.CodeBuffer { + return func(buf etk.CodeBuffer) etk.CodeBuffer { + buf.Dot = m(buf.Content, buf.Dot) + return buf + } +} + +func moveDotLeft(buffer string, dot int) int { + _, w := utf8.DecodeLastRuneInString(buffer[:dot]) + return dot - w +} + +func moveDotRight(buffer string, dot int) int { + _, w := utf8.DecodeRuneInString(buffer[dot:]) + return dot + w +} + +func moveDotSOL(buffer string, dot int) int { + return strutil.FindLastSOL(buffer[:dot]) +} + +func moveDotEOL(buffer string, dot int) int { + return strutil.FindFirstEOL(buffer[dot:]) + dot +} diff --git a/pkg/etk/examples/counter.go b/pkg/etk/examples/counter.go new file mode 100644 index 000000000..f7356f0f6 --- /dev/null +++ b/pkg/etk/examples/counter.go @@ -0,0 +1,38 @@ +package main + +import ( + "strconv" + + "src.elv.sh/pkg/cli/term" + "src.elv.sh/pkg/etk" + "src.elv.sh/pkg/ui" +) + +func Counter(c etk.Context) (etk.View, etk.React) { + valueVar := etk.State(c, "value", 0) + buttonView, buttonReact := c.Subcomp("button", etk.WithStates(Button, + "label", "Count", + "submit", func() etk.Reaction { + valueVar.Swap(func(i int) int { return i + 1 }) + return etk.Consumed + }, + )) + + return etk.HBoxFlex( + etk.Text(ui.T(strconv.Itoa(valueVar.Get()))), + buttonView, + ).WithFocus(1).WithGap(1), + buttonReact +} + +func Button(c etk.Context) (etk.View, etk.React) { + labelVar := etk.State(c, "label", "button") + submitVar := etk.State(c, "submit", func() etk.Reaction { return etk.Unused }) + return etk.Text(ui.T("[ "+labelVar.Get()+" ]", ui.Inverse)), + c.WithBinding("button", func(ev term.Event) etk.Reaction { + if ev == term.K(' ') || ev == term.K(ui.Enter) { + return submitVar.Get()() + } + return etk.Unused + }) +} diff --git a/pkg/etk/examples/flight.go b/pkg/etk/examples/flight.go new file mode 100644 index 000000000..fa59e6998 --- /dev/null +++ b/pkg/etk/examples/flight.go @@ -0,0 +1,45 @@ +package main + +import ( + "src.elv.sh/pkg/cli/term" + "src.elv.sh/pkg/etk" + "src.elv.sh/pkg/ui" +) + +func Flight(c etk.Context) (etk.View, etk.React) { + // TODO: Horizontal + typeView, typeReact := c.Subcomp("type", + etk.WithStates(etk.ListBox, "items", etk.StringItems("one-way", "return"))) + outboundView, outboundReact := c.Subcomp("outbound", + etk.WithStates(etk.CodeArea, "prompt", ui.T("outbound: "))) + // TODO: Disable inbound for one-way + inboundView, inboundReact := c.Subcomp("inbound", + etk.WithStates(etk.CodeArea, "prompt", ui.T("inbound: "))) + bookView, bookReact := c.Subcomp("book", + etk.WithStates(Button, "label", "Book", "submit", func() { + })) + + focusVar := etk.State(c, "focus", 0) + focus := focusVar.Get() + return etk.VBox( + typeView, outboundView, inboundView, bookView, + ).WithFocus(focus), + func(ev term.Event) etk.Reaction { + reaction := []etk.React{ + typeReact, outboundReact, inboundReact, bookReact, + }[focus](ev) + if reaction == etk.Unused { + switch ev { + case term.K(ui.Down), term.K(ui.Tab): + if focus < 3 { + focusVar.Set(focus + 1) + } + case term.K(ui.Up), term.K(ui.Tab, ui.Shift): + if focus > 0 { + focusVar.Set(focus - 1) + } + } + } + return reaction + } +} diff --git a/pkg/etk/examples/hiernav.go b/pkg/etk/examples/hiernav.go new file mode 100644 index 000000000..a766d85a4 --- /dev/null +++ b/pkg/etk/examples/hiernav.go @@ -0,0 +1,152 @@ +package main + +import ( + "fmt" + "slices" + "sort" + + "src.elv.sh/pkg/cli/term" + "src.elv.sh/pkg/etk" + "src.elv.sh/pkg/ui" +) + +func HierNav(c etk.Context) (etk.View, etk.React) { + dataVar := etk.State(c, "data", map[string]any{}) + data := dataVar.Get() + + pathVar := etk.State(c, "path", []string{}) + path := pathVar.Get() + + var parent etk.View + if len(path) > 0 { + parent = hierNavPanel(c, data, path[:len(path)-1]) + } else { + parent = etk.Empty + } + + var ( + currentView etk.View + currentReact etk.React + preview etk.View + react func(term.Event) etk.Reaction + ) + switch value := access(data, path).(type) { + case map[string]any: + // TODO: Don't recalculate? + items := makeHierItems(value) + currentView, currentReact = c.Subcomp(pathToName(path), etk.WithStates(etk.ListBox, "items", items)) + selectedVar := etk.BindState[int](c, pathToName(path), "selected") + previewPath := slices.Concat(path, []string{items[selectedVar.Get()].key}) + preview = hierNavPanel(c, data, previewPath) + react = func(e term.Event) etk.Reaction { + switch e { + case term.K(ui.Left): + if len(path) > 0 { + pathVar.Set(path[:len(path)-1]) + return etk.Consumed + } + return etk.Unused + case term.K(ui.Right): + pathVar.Set(previewPath) + return etk.Consumed + default: + return currentReact(e) + } + } + case string: + currentView = etk.Text(ui.T(value)) + currentReact = func(term.Event) etk.Reaction { return etk.Unused } + preview = etk.Empty + react = func(term.Event) etk.Reaction { return etk.Unused } + } + + return etk.VBox( + etk.Text(ui.T(fmt.Sprintf("path = %s", path))), + etk.HBox(parent, currentView, preview).WithFocus(1), + ), react +} + +func hierNavPanel(b etk.Context, data map[string]any, path []string) etk.View { + switch value := access(data, path).(type) { + case map[string]any: + items := makeHierItems(value) + view, _ := b.Subcomp(pathToName(path), etk.WithStates(etk.ListBox, "items", items)) + return view + case string: + return etk.Text(ui.T(value)) + default: + panic("unreachable") + } +} + +func access(data map[string]any, path []string) any { + for len(path) > 0 { + if subData, ok := data[path[0]]; ok { + path = path[1:] + switch subData := subData.(type) { + case map[string]any: + data = subData + case string: + if len(path) == 0 { + return subData + } + return "not found" + default: + panic("unreachable") + } + } else { + return "not found" + } + } + return data +} + +func pathToName(path []string) string { return fmt.Sprint(path) } + +type hierItem struct { + key string + value any +} + +type hierItems []hierItem + +func makeHierItems(value map[string]any) hierItems { + var items hierItems + for k, v := range value { + items = append(items, hierItem{k, v}) + } + sort.Slice(items, func(i, j int) bool { return items[i].key < items[j].key }) + return items +} + +func (hi hierItems) Len() int { return len(hi) } + +func (hi hierItems) Show(i int) ui.Text { + switch hi[i].value.(type) { + case map[string]any: + return ui.T(hi[i].key, ui.FgGreen, ui.Bold) + default: + return ui.T(hi[i].key) + } +} + +var hierData = map[string]any{ + "bin": map[string]any{ + "cat": "Concatenate files", + "elvish": "Elvish shell", + "zsh": "The Z shell", + }, + "home": map[string]any{ + "elf": map[string]any{ + "bin": map[string]any{ + "elvish": "Local Elvish build", + "foo": "bar", + }, + "README": "this is the elf user's home directory.", + }, + "root": map[string]any{ + "README": "this is the root user's home directory.", + }, + }, + "README": "this is the root.", +} diff --git a/pkg/etk/examples/life.go b/pkg/etk/examples/life.go new file mode 100644 index 000000000..62d795efe --- /dev/null +++ b/pkg/etk/examples/life.go @@ -0,0 +1,112 @@ +package main + +import ( + "fmt" + "strings" + + "src.elv.sh/pkg/cli/term" + "src.elv.sh/pkg/etk" + "src.elv.sh/pkg/ui" +) + +func Life(b etk.Context) (etk.View, etk.React) { + name := etk.State(b, "name", "game of life").Get() + historyVar := etk.State(b, "history", []Board{blinker}) + history := historyVar.Get() + stepVar := etk.State(b, "step", 0) + step := stepVar.Get() + + return etk.VBox( + etk.Text(ui.T(fmt.Sprintf("%s: %d / %d", name, step+1, len(history)))), + etk.Text(ui.T(showBoard(history[step]))), + ).WithFocus(0), func(e term.Event) etk.Reaction { + switch e { + case term.K(ui.Left): + if step > 0 { + stepVar.Set(step - 1) + return etk.Consumed + } + case term.K(ui.Right): + if step == len(history)-1 { + historyVar.Set(append(history, nextBoard(history[len(history)-1]))) + } + stepVar.Set(step + 1) + return etk.Consumed + } + return etk.Unused + } +} + +type Board [][]bool + +func showBoard(b Board) string { + var sb strings.Builder + for i, row := range b { + if i > 0 { + sb.WriteByte('\n') + } + for _, cell := range row { + if cell { + sb.WriteRune('β–ˆ') + } else { + sb.WriteRune(' ') + } + } + } + return sb.String() +} + +func nextBoard(b Board) Board { + newb := make(Board, len(b)) + for i := range newb { + newb[i] = make([]bool, len(b[0])) + } + get := func(i, j int) int { + if 0 <= i && i < len(b) && 0 <= j && j < len(b[0]) && b[i][j] { + return 1 + } + return 0 + } + for i := range b { + for j := range b[i] { + liveNeighbors := get(i-1, j-1) + get(i-1, j) + get(i-1, j+1) + get(i, j-1) + get(i, j+1) + get(i+1, j-1) + get(i+1, j) + get(i+1, j+1) + if b[i][j] { + newb[i][j] = liveNeighbors == 2 || liveNeighbors == 3 + } else { + newb[i][j] = liveNeighbors == 3 + } + } + } + return newb +} + +var blinker = Board{ + {false, false, false, false, false}, + {false, false, false, false, false}, + {false, true, true, true, false}, + {false, false, false, false, false}, + {false, false, false, false, false}, +} + +const truex = true + +var pentadecathlon = Board{ + {false, false, false, false, false, false, false, false, false, false, false}, + {false, false, false, false, false, false, false, false, false, false, false}, + {false, false, false, false, false, false, false, false, false, false, false}, + {false, false, false, false, false, false, false, false, false, false, false}, + {false, false, false, false, false, truex, false, false, false, false, false}, + {false, false, false, false, truex, false, truex, false, false, false, false}, + {false, false, false, truex, false, false, false, truex, false, false, false}, + {false, false, false, truex, false, false, false, truex, false, false, false}, + {false, false, false, truex, false, false, false, truex, false, false, false}, + {false, false, false, truex, false, false, false, truex, false, false, false}, + {false, false, false, truex, false, false, false, truex, false, false, false}, + {false, false, false, truex, false, false, false, truex, false, false, false}, + {false, false, false, false, truex, false, truex, false, false, false, false}, + {false, false, false, false, false, truex, false, false, false, false, false}, + {false, false, false, false, false, false, false, false, false, false, false}, + {false, false, false, false, false, false, false, false, false, false, false}, + {false, false, false, false, false, false, false, false, false, false, false}, + {false, false, false, false, false, false, false, false, false, false, false}, +} diff --git a/pkg/etk/examples/main.go b/pkg/etk/examples/main.go new file mode 100644 index 000000000..98e22a839 --- /dev/null +++ b/pkg/etk/examples/main.go @@ -0,0 +1,81 @@ +// Example terminal apps implemented using the Etk framework. +package main + +import ( + "flag" + "fmt" + "os" + + "src.elv.sh/pkg/cli" + "src.elv.sh/pkg/cli/term" + "src.elv.sh/pkg/etk" + "src.elv.sh/pkg/eval" + "src.elv.sh/pkg/must" + "src.elv.sh/pkg/ui" +) + +var example = flag.String("example", "", "the example to run") + +func main() { + flag.Parse() + + etk.Notify = func(ui.Text) {} + var f etk.Comp + switch *example { + // 7GUIs + case "counter": + f = Counter + case "temperature": + f = Temperature + case "flight": + f = Flight + + case "codearea": + f = etk.WithStates(etk.CodeArea, + "prompt", ui.T("~> "), + "abbr", func(y func(a, f string)) { y("foo", "lorem") }) + case "wizard": + f = Wizard + case "todo": + f = Todo + case "preso": + content := must.OK1(os.ReadFile(flag.Args()[0])) + pages := parsePreso(string(content)) + f = etk.WithStates(Preso, "pages", pages) + case "hiernav": + f = etk.WithStates(HierNav, "data", hierData) + case "life": + f = etk.WithStates(Life, + "name", "pentadecathlon", + "history", []Board{pentadecathlon}) + default: + fmt.Println("unknown example:", *example) + return + } + etk.Run(cli.NewTTY(os.Stdin, os.Stdout), binding, eval.NewEvaler().CallFrame("etk"), + etk.WithStates(Wrapper, "inner-comp", f)) +} + +func binding(ev term.Event, c etk.Context, tag string, f etk.React) etk.Reaction { + if tag == "codearea" { + reaction := f(ev) + if reaction == etk.Unused { + bufferVar := etk.BindState[etk.CodeBuffer](c, "buffer") + switch ev { + case term.K(ui.Left): + bufferVar.Swap(makeMove(moveDotLeft)) + case term.K(ui.Right): + bufferVar.Swap(makeMove(moveDotRight)) + case term.K(ui.Home): + bufferVar.Swap(makeMove(moveDotSOL)) + case term.K(ui.End): + bufferVar.Swap(makeMove(moveDotEOL)) + default: + return etk.Unused + } + return etk.Consumed + } + return reaction + } + return f(ev) +} diff --git a/pkg/etk/examples/preso.go b/pkg/etk/examples/preso.go new file mode 100644 index 000000000..429a0de29 --- /dev/null +++ b/pkg/etk/examples/preso.go @@ -0,0 +1,55 @@ +package main + +import ( + "fmt" + "regexp" + + "src.elv.sh/pkg/cli/term" + "src.elv.sh/pkg/elvdoc" + "src.elv.sh/pkg/etk" + "src.elv.sh/pkg/md" + "src.elv.sh/pkg/ui" +) + +func Preso(b etk.Context) (etk.View, etk.React) { + currentVar := etk.State(b, "current", 0) + current := currentVar.Get() + pagesVar := etk.State(b, "pages", []ui.Text{ui.T("no content")}) + pages := pagesVar.Get() + + indicator := ui.T(fmt.Sprintf("%d / %d\n", current+1, len(pages))) + + return etk.VBox( + etk.Text(indicator), etk.Text(pages[current]), + ).WithFocus(0), func(e term.Event) etk.Reaction { + switch e { + case term.K(ui.Left): + if current > 0 { + currentVar.Set(current - 1) + return etk.Consumed + } + case term.K(ui.Right): + if current < len(pages)-1 { + currentVar.Set(current + 1) + return etk.Consumed + } + } + return etk.Unused + } +} + +var thematicBreakRegexp = regexp.MustCompile( + `(?m)^ {0,3}((?:-[ \t]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})$`) + +func parsePreso(src string) []ui.Text { + pageSrcs := thematicBreakRegexp.Split(src, -1) + pages := make([]ui.Text, len(pageSrcs)) + for i, pageSrc := range pageSrcs { + codec := md.TTYCodec{ + HighlightCodeBlock: elvdoc.HighlightCodeBlock, + } + md.Render(pageSrc, &codec) + pages[i] = codec.Text() + } + return pages +} diff --git a/pkg/etk/examples/tempconv-actual.elv b/pkg/etk/examples/tempconv-actual.elv new file mode 100644 index 000000000..5281d898c --- /dev/null +++ b/pkg/etk/examples/tempconv-actual.elv @@ -0,0 +1,48 @@ +use etk +use str + +var temp-conv = (etk:comp {|this:| + this:state focus = (num 0) + + etk:vbox [ + (this:subcomp celsius $etk:textbox [&prompt=(styled 'Celsius: ')]) + (this:subcomp fahrenheit $etk:textbox [&prompt=(styled 'Fahrenheit: ')]) + + (this:subcomp state-dump $etk:textbox [&prompt= + (pprint (dissoc $this:state state-dump) | + str:replace "\t" "" (slurp) | + put (styled "\n State dump: \n" inverse)(one))]) + ] [ + &focus=$this:state[focus] + &handler={|e es| + if (==s $es Tab) { + set this:state[focus] = (- 1 $this:state[focus]) + } else { + if (== $this:state[focus] 0) { + if (etk:handle $this:subcomp[celsius] $e) { + try { + var f = (+ 32 (* 9/5 $this:state[celsius][buffer][content]) | printf '%.2f' (one)) + set this:state[fahrenheit][buffer] = (etk:text-buffer $f (count $f)) + } catch { + } + } else { + # Can we do better than this? πŸ€” + put $false + } + } else { + if (etk:handle $this:subcomp[fahrenheit] $e) { + try { + var c = (* 5/9 (- $this:state[fahrenheit][buffer][content] 32) | printf '%.2f' (one)) + set this:state[celsius][buffer] = (etk:text-buffer $c (count $c)) + } catch { + } + } else { + put $false + } + } + } + } + ] +}) + +edit:push-addon (etk:adapt-to-widget $temp-conv) diff --git a/pkg/etk/examples/tempconv-wished.elv b/pkg/etk/examples/tempconv-wished.elv new file mode 100644 index 000000000..219a8f136 --- /dev/null +++ b/pkg/etk/examples/tempconv-wished.elv @@ -0,0 +1,44 @@ +use etk +use str + +var temp-conv-data = [ + &celsius=[ + &other=fahrenheit + &converter={|c| + 32 (* 9/5 $c)} + ] + &fahrenheit=[ + &other=celsius + &converter={|f| * 5/9 (- $f 32)} + ] +] + +# Need decorator here +fn temp-conv {|this:| [etk:comp] + this:subcomp celsius = (etk:textbox [&prompt=(styled 'Celsius: ')]) + this:subcomp fahrenheit = (etk:textbox [&prompt=(styled 'Fahrenheit: ')]) + this:state focus = celsius + pprint (dissoc $this:state state-dump) | + str:replace "\t" "" (slurp) | + put (styled "\n State dump: \n" inverse)(one) | + this:subcomp state-dump = (etk:textbox [&prompt=(one)]) + + put [ + # Need tag here + &layout=[%etk:vbox celsius fahrenheit state-dump] + &focus=$this:state[focus] + &handler={|e| [returnable] + val other = $temp-conv-data[$focus][other] + if (==s $e Tab) { + set this:state[focus] = $other + return + } + + if (not (this:propagate $focus $e)) { + etk:not-handled + return + } + } + ] +} + +edit:push-addon (etk:adapt-to-widget $temp-conv~) diff --git a/pkg/etk/examples/temperature.go b/pkg/etk/examples/temperature.go new file mode 100644 index 000000000..4af2ff24c --- /dev/null +++ b/pkg/etk/examples/temperature.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "strconv" + + "src.elv.sh/pkg/cli/term" + "src.elv.sh/pkg/cli/tk" + "src.elv.sh/pkg/etk" + "src.elv.sh/pkg/ui" +) + +func Temperature(c etk.Context) (etk.View, etk.React) { + celsiusView, celsiusReact := c.Subcomp("celsius", etk.WithStates(etk.CodeArea, "prompt", ui.T("Celsius: "))) + celsiusBufferVar := etk.BindState[tk.CodeBuffer](c, "celsius", "buffer") + + fahrenheitView, fahrenheitReact := c.Subcomp("fahrenheit", etk.WithStates(etk.CodeArea, "prompt", ui.T("Fahrenheit: "))) + fahrenheitBufferVar := etk.BindState[tk.CodeBuffer](c, "fahrenheit", "buffer") + + focusVar := etk.State(c, "focus", 0) + + return etk.VBox(celsiusView, fahrenheitView).WithFocus(focusVar.Get()), + func(e term.Event) etk.Reaction { + focus := focusVar.Get() + if e == term.K(ui.Tab) { + focusVar.Set(1 - focus) + return etk.Consumed + } + if focus == 0 { + if celsiusReact(e) == etk.Consumed { + if c, err := strconv.ParseFloat(celsiusBufferVar.Get().Content, 64); err == nil { + f := fmt.Sprintf("%.2f", c*9/5+32) + fahrenheitBufferVar.Set(tk.CodeBuffer{Content: f, Dot: len(f)}) + } + return etk.Consumed + } + } else { + if fahrenheitReact(e) == etk.Consumed { + if f, err := strconv.ParseFloat(fahrenheitBufferVar.Get().Content, 64); err == nil { + c := fmt.Sprintf("%.2f", (f-32)*5/9) + celsiusBufferVar.Set(tk.CodeBuffer{Content: c, Dot: len(c)}) + } + return etk.Consumed + } + } + return etk.Unused + } +} diff --git a/pkg/etk/examples/todo.go b/pkg/etk/examples/todo.go new file mode 100644 index 000000000..4da518834 --- /dev/null +++ b/pkg/etk/examples/todo.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + + "src.elv.sh/pkg/cli/term" + "src.elv.sh/pkg/cli/tk" + "src.elv.sh/pkg/etk" + "src.elv.sh/pkg/ui" +) + +type todoItem struct { + text string + done bool +} + +type todoItems []todoItem + +func (ti todoItems) Len() int { return len(ti) } +func (ti todoItems) Show(i int) ui.Text { + done := ' ' + if ti[i].done { + done = 'X' + } + return ui.T(fmt.Sprintf("[%c] %s", done, ti[i].text)) +} + +func Todo(c etk.Context) (etk.View, etk.React) { + // TODO: API to combine init and bind + listView, listReact := c.Subcomp("list", etk.WithStates(etk.ListBox, "items", todoItems{})) + itemsVar := etk.BindState[todoItems](c, "list", "items") + selectedVar := etk.BindState[int](c, "list", "selected") + + newItemView, newItemReact := c.Subcomp("new-item", etk.WithStates(etk.CodeArea, "prompt", ui.T("new item: "))) + bufferVar := etk.BindState[tk.CodeBuffer](c, "new-item", "buffer") + + focusVar := etk.State(c, "focus", 1) + focus := focusVar.Get() + + return etk.VBox(listView, newItemView).WithFocus(focus), func(e term.Event) etk.Reaction { + if e == term.K(ui.Tab) { + focusVar.Set(1 - focus) + return etk.Consumed + } + if focus == 0 { + reaction := listReact(e) + if reaction == etk.Unused { + switch e { + case term.K(ui.Down): + focusVar.Set(1) + return etk.Consumed + case term.K(' '): + item := &itemsVar.Get()[selectedVar.Get()] + item.done = !item.done + return etk.Consumed + } + } + return reaction + } else { + reaction := newItemReact(e) + if reaction == etk.Unused { + switch e { + case term.K(ui.Up): + focusVar.Set(0) + return etk.Consumed + case term.K(ui.Enter): + itemsVar.Set(append(itemsVar.Get(), todoItem{text: bufferVar.Get().Content})) + bufferVar.Set(tk.CodeBuffer{}) + return etk.Consumed + } + } + return reaction + } + } +} diff --git a/pkg/etk/examples/wizard.go b/pkg/etk/examples/wizard.go new file mode 100644 index 000000000..c42ea08fd --- /dev/null +++ b/pkg/etk/examples/wizard.go @@ -0,0 +1,41 @@ +package main + +import ( + "src.elv.sh/pkg/cli/term" + "src.elv.sh/pkg/etk" + "src.elv.sh/pkg/ui" +) + +type Task struct { + Name string + Description string + Code string +} + +type Tasks []Task + +func (ts Tasks) Show(i int) ui.Text { return ui.T(ts[i].Name) } + +func (ts Tasks) Len() int { return len(ts) } + +var tasks = Tasks{ + {"Set up carapace", "Carapace provides a lot of completions.", "sudo brew install carapace"}, + {"Use readline binding", "Keybindings like:\nCtrl-N to next line\nCtrl-P to previous line\nCtrl-F to next character\nCtrl-B to previous character", "use readline-binding"}, +} + +func Wizard(c etk.Context) (etk.View, etk.React) { + listView, listReact := c.Subcomp("list", etk.WithStates(etk.ListBox, "items", tasks)) + selectedVar := etk.BindState[int](c, "list", "selected") + selected := selectedVar.Get() + description := etk.Text(ui.T(tasks[selected].Description)) + code := etk.Text(ui.T("\n" + tasks[selected].Code)) + + return etk.VBox(etk.HBox(listView, description), code).WithFocus(0), + func(e term.Event) etk.Reaction { + if e == term.K(ui.Enter) { + // TODO: Show notification? + return etk.Consumed + } + return listReact(e) + } +} diff --git a/pkg/etk/examples/wrapper.go b/pkg/etk/examples/wrapper.go new file mode 100644 index 000000000..55e4b4656 --- /dev/null +++ b/pkg/etk/examples/wrapper.go @@ -0,0 +1,30 @@ +package main + +import ( + "strings" + + "src.elv.sh/pkg/cli/term" + "src.elv.sh/pkg/etk" + "src.elv.sh/pkg/eval/vals" + "src.elv.sh/pkg/ui" +) + +func Wrapper(c etk.Context) (etk.View, etk.React) { + innerView, innerReact := c.Subcomp("inner", nop) + innerStateVar := etk.BindState[vals.Map](c, "inner") + + stateText := ui.T(strings.ReplaceAll(vals.Repr(innerStateVar.Get(), 0), "\t", " ")) + return etk.VBox(innerView, etk.Text(stateText)).WithFocus(0), + func(e term.Event) etk.Reaction { + reaction := innerReact(e) + if reaction == etk.Unused && (e == term.K('[', ui.Ctrl)) { + _ = ui.Tab + return etk.Finish + } + return reaction + } +} + +func nop(c etk.Context) (etk.View, etk.React) { + return etk.Empty, func(term.Event) etk.Reaction { return etk.Unused } +} diff --git a/pkg/etk/listbox.go b/pkg/etk/listbox.go new file mode 100644 index 000000000..1f32289b2 --- /dev/null +++ b/pkg/etk/listbox.go @@ -0,0 +1,65 @@ +package etk + +import ( + "src.elv.sh/pkg/cli/term" + "src.elv.sh/pkg/ui" +) + +// Items is an interface for accessing multiple items. +type Items interface { + // Show renders the item at the given zero-based index. + Show(i int) ui.Text + // Len returns the number of items. + Len() int +} + +type stringItems []string + +func StringItems(items ...string) Items { return stringItems(items) } +func (si stringItems) Show(i int) ui.Text { return ui.T(si[i]) } +func (si stringItems) Len() int { return len(si) } + +func ListBox(c Context) (View, React) { + itemsVar := State(c, "items", Items(nil)) + selectedVar := State(c, "selected", 0) + + // This is not used anywhere, but declared here for ease of use from a + // keybinding. + _ = State(c, "submit", func(Items, int) {}) + + selected := selectedVar.Get() + focus := 0 + var spans []ui.Text + if items := itemsVar.Get(); items != nil { + for i := 0; i < items.Len(); i++ { + if i > 0 { + spans = append(spans, ui.T("\n")) + } + if i == selected { + focus = len(spans) + spans = append(spans, ui.StyleText(items.Show(i), ui.Inverse)) + } else { + spans = append(spans, items.Show(i)) + } + } + } + + return Text(spans...).WithDotBefore(focus), + c.WithBinding("listbox", func(e term.Event) Reaction { + selected := selectedVar.Get() + items := itemsVar.Get() + switch e { + case term.K(ui.Up): + if selected > 0 { + selectedVar.Set(selected - 1) + return Consumed + } + case term.K(ui.Down): + if selected < items.Len()-1 { + selectedVar.Set(selected + 1) + return Consumed + } + } + return Unused + }) +} diff --git a/pkg/etk/modes/filter_spec.go b/pkg/etk/modes/filter_spec.go new file mode 100644 index 000000000..9d8624ae6 --- /dev/null +++ b/pkg/etk/modes/filter_spec.go @@ -0,0 +1,23 @@ +package modes + +import ( + "strings" + + "src.elv.sh/pkg/ui" +) + +// FilterSpec specifies the configuration for the filter in listing modes. +type FilterSpec struct { + // Called with the filter text to get the filter predicate. If nil, the + // predicate performs substring match. + Maker func(string) func(string) bool + // Highlighter for the filter. If nil, the filter will not be highlighted. + Highlighter func(string) (ui.Text, []ui.Text) +} + +func (f FilterSpec) makePredicate(p string) func(string) bool { + if f.Maker == nil { + return func(s string) bool { return strings.Contains(s, p) } + } + return f.Maker(p) +} diff --git a/pkg/etk/modes/location.go b/pkg/etk/modes/location.go new file mode 100644 index 000000000..86a8740ff --- /dev/null +++ b/pkg/etk/modes/location.go @@ -0,0 +1,167 @@ +package modes + +import ( + "errors" + "fmt" + "math" + "path/filepath" + "regexp" + "strings" + + "src.elv.sh/pkg/etk" + "src.elv.sh/pkg/fsutil" + "src.elv.sh/pkg/store/storedefs" + "src.elv.sh/pkg/ui" +) + +// LocationCfg is the configuration to start the location history feature. +type LocationCfg struct { + // Store provides the directory history and the function to change directory. + Store LocationStore + // IteratePinned specifies pinned directories by calling the given function + // with all pinned directories. + IteratePinned func(func(string)) + // IterateHidden specifies hidden directories by calling the given function + // with all hidden directories. + IterateHidden func(func(string)) + // IterateWorksapce specifies workspace configuration. + IterateWorkspaces LocationWSIterator + // Configuration for the filter. + Filter FilterSpec +} + +// LocationStore defines the interface for interacting with the directory history. +type LocationStore interface { + Dirs(blacklist map[string]struct{}) ([]storedefs.Dir, error) + Chdir(dir string) error + Getwd() (string, error) +} + +// A special score for pinned directories. +var pinnedScore = math.Inf(1) + +var errNoDirectoryHistoryStore = errors.New("no directory history store") + +// NewLocation creates a new location mode. +func NewLocation(cfg LocationCfg) (etk.Comp, error) { + if cfg.Store == nil { + return nil, errNoDirectoryHistoryStore + } + + dirs := []storedefs.Dir{} + blacklist := map[string]struct{}{} + wsKind, wsRoot := "", "" + + if cfg.IteratePinned != nil { + cfg.IteratePinned(func(s string) { + blacklist[s] = struct{}{} + dirs = append(dirs, storedefs.Dir{Score: pinnedScore, Path: s}) + }) + } + if cfg.IterateHidden != nil { + cfg.IterateHidden(func(s string) { blacklist[s] = struct{}{} }) + } + wd, err := cfg.Store.Getwd() + if err == nil { + blacklist[wd] = struct{}{} + if cfg.IterateWorkspaces != nil { + wsKind, wsRoot = cfg.IterateWorkspaces.Parse(wd) + } + } + storedDirs, err := cfg.Store.Dirs(blacklist) + if err != nil { + return nil, fmt.Errorf("db error: %v", err) + } + for _, dir := range storedDirs { + if filepath.IsAbs(dir.Path) { + dirs = append(dirs, dir) + } else if wsKind != "" && hasPathPrefix(dir.Path, wsKind) { + dirs = append(dirs, dir) + } + } + + l := locationList{dirs} + + return etk.WithStates(etk.ComboBox, + "gen-list", func(p string) (etk.Items, int) { + return l.filter(cfg.Filter.makePredicate(p)), 0 + }, + "filter/prompt", modeLine(" LOCATION ", true), + "filter/highlight", cfg.Filter.Highlighter, + "list/submit", func(it etk.Items, i int) { + path := it.(locationList).dirs[i].Path + if strings.HasPrefix(path, wsKind) { + path = wsRoot + path[len(wsKind):] + } + err := cfg.Store.Chdir(path) + _ = err + /* + if err != nil { + app.Notify(ui.T(err.Error(), ui.FgRed)) + } + app.PopAddon() + */ + }, + ), nil +} + +func hasPathPrefix(path, prefix string) bool { + return path == prefix || + strings.HasPrefix(path, prefix+string(filepath.Separator)) +} + +// LocationWSIterator is a function that iterates all workspaces by calling +// the passed function with the name and pattern of each kind of workspace. +// Iteration should stop when the called function returns false. +type LocationWSIterator func(func(kind, pattern string) bool) + +// Parse returns whether the path matches any kind of workspace. If there is +// a match, it returns the kind of the workspace and the root. It there is no +// match, it returns "", "". +func (ws LocationWSIterator) Parse(path string) (kind, root string) { + var foundKind, foundRoot string + ws(func(kind, pattern string) bool { + if !strings.HasPrefix(pattern, "^") { + pattern = "^" + pattern + } + re, err := regexp.Compile(pattern) + if err != nil { + // TODO(xiaq): Surface the error. + return true + } + if root := re.FindString(path); root != "" { + foundKind, foundRoot = kind, root + return false + } + return true + }) + return foundKind, foundRoot +} + +type locationList struct { + dirs []storedefs.Dir +} + +func (l locationList) filter(p func(string) bool) locationList { + var filteredDirs []storedefs.Dir + for _, dir := range l.dirs { + if p(fsutil.TildeAbbr(dir.Path)) { + filteredDirs = append(filteredDirs, dir) + } + } + return locationList{filteredDirs} +} + +func (l locationList) Show(i int) ui.Text { + return ui.T(fmt.Sprintf("%s %s", + showScore(l.dirs[i].Score), fsutil.TildeAbbr(l.dirs[i].Path))) +} + +func (l locationList) Len() int { return len(l.dirs) } + +func showScore(f float64) string { + if f == pinnedScore { + return " *" + } + return fmt.Sprintf("%3.0f", f) +} diff --git a/pkg/etk/modes/utils.go b/pkg/etk/modes/utils.go new file mode 100644 index 000000000..2cc9c8235 --- /dev/null +++ b/pkg/etk/modes/utils.go @@ -0,0 +1,11 @@ +package modes + +import "src.elv.sh/pkg/ui" + +func modeLine(content string, space bool) ui.Text { + t := ui.T(content, ui.Bold, ui.FgWhite, ui.BgMagenta) + if space { + t = ui.Concat(t, ui.T(" ")) + } + return t +} diff --git a/pkg/etk/stateful.go b/pkg/etk/stateful.go new file mode 100644 index 000000000..42ba70ae3 --- /dev/null +++ b/pkg/etk/stateful.go @@ -0,0 +1,35 @@ +package etk + +import ( + "src.elv.sh/pkg/cli/term" + "src.elv.sh/pkg/eval" + "src.elv.sh/pkg/eval/vals" +) + +func Stateful(fm *eval.Frame, f Comp) *StatefulComp { + state := vals.EmptyMap + view, react := f(Context{&state, nil, fm, nil}) + return &StatefulComp{&state, fm, view, react, f} +} + +type StatefulComp struct { + state *vals.Map + fm *eval.Frame + view View + react React + f Comp +} + +func (w *StatefulComp) Render(width, height int) *term.Buffer { + return w.view.Render(width, height) +} + +func (w *StatefulComp) Handle(event term.Event) Reaction { + reaction := w.react(event) + w.Refresh() + return reaction +} + +func (w *StatefulComp) Refresh() { + w.view, w.react = w.f(Context{w.state, nil, w.fm, nil}) +} diff --git a/pkg/etk/transcripts_test.go b/pkg/etk/transcripts_test.go new file mode 100644 index 000000000..385216530 --- /dev/null +++ b/pkg/etk/transcripts_test.go @@ -0,0 +1,29 @@ +package etk_test + +import ( + "embed" + "testing" + + "src.elv.sh/pkg/etk" + "src.elv.sh/pkg/etk/etktest" + "src.elv.sh/pkg/eval/evaltest" + "src.elv.sh/pkg/eval/vals" +) + +//go:embed *.elvts +var transcripts embed.FS + +func TestTranscripts(t *testing.T) { + evaltest.TestTranscriptsInFS(t, transcripts, + "code-area-fixture", etktest.MakeFixture(etk.CodeArea), + "abbr-table-in-global", evaltest.GoFnInGlobal("abbr-table", + func(m vals.Map) func(f func(a, f string)) { + return func(f func(a, f string)) { + for it := m.Iterator(); it.HasElem(); it.Next() { + k, v := it.Elem() + f(vals.ToString(k), vals.ToString(v)) + } + } + }), + ) +} diff --git a/pkg/etk/utils.go b/pkg/etk/utils.go new file mode 100644 index 000000000..c72bc16c5 --- /dev/null +++ b/pkg/etk/utils.go @@ -0,0 +1,14 @@ +package etk + +func zero[T any]() T { + var v T + return v +} + +func each[X, Y any](xs []X, f func(X) Y) []Y { + ys := make([]Y, len(xs)) + for i, x := range xs { + ys[i] = f(x) + } + return ys +} diff --git a/pkg/etk/views.go b/pkg/etk/views.go new file mode 100644 index 000000000..aa5fb2b93 --- /dev/null +++ b/pkg/etk/views.go @@ -0,0 +1,116 @@ +package etk + +import ( + "src.elv.sh/pkg/cli/term" + "src.elv.sh/pkg/ui" +) + +var Empty = EmptyView{} + +type EmptyView struct{} + +func (e EmptyView) Render(width, height int) *term.Buffer { return term.NewBuffer(width) } + +type TextView struct { + Spans []ui.Text + DotBefore int +} + +func Text(spans ...ui.Text) TextView { return TextView{spans, 0} } + +func (t TextView) WithDotBefore(i int) TextView { return TextView{t.Spans, i} } + +func (t TextView) Render(width, height int) *term.Buffer { + bb := term.NewBufferBuilder(width) + for i, span := range t.Spans { + bb.WriteStyled(span) + if i+1 == t.DotBefore { + bb.SetDotHere() + } + } + buf := bb.Buffer() + // TODO: dot line + buf.TrimToLines(0, height) + return buf +} + +type VBoxView struct { + Rows []View + Focus int +} + +func VBox(rows ...View) VBoxView { return VBoxView{rows, 0} } + +func (v VBoxView) WithFocus(i int) VBoxView { return VBoxView{v.Rows, i} } + +func (v VBoxView) Render(width, height int) *term.Buffer { + if len(v.Rows) == 0 { + return term.NewBuffer(width) + } + buf := v.Rows[0].Render(width, height-len(v.Rows)-1) + for i := 1; i < len(v.Rows); i++ { + rowHeight := height - len(buf.Lines) - len(v.Rows) - i - 1 + if rowHeight <= 0 { + break + } + buf.Extend(v.Rows[i].Render(width, rowHeight), i == v.Focus) + } + return buf +} + +type HBoxView struct { + Cols []View + Focus int +} + +func HBox(cols ...View) HBoxView { return HBoxView{cols, 0} } + +func (h HBoxView) WithFocus(i int) HBoxView { return HBoxView{h.Cols, i} } + +func (h HBoxView) Render(width, height int) *term.Buffer { + if len(h.Cols) == 0 { + return term.NewBuffer(width) + } + colWidth := width / len(h.Cols) + + buf := h.Cols[0].Render(colWidth, height) + for i := 1; i < len(h.Cols); i++ { + // TODO: Focus + buf.ExtendRight(h.Cols[i].Render(colWidth, height)) + } + return buf +} + +type HBoxFlexView struct { + Cols []View + Focus int + Gap int +} + +func HBoxFlex(cols ...View) HBoxFlexView { return HBoxFlexView{cols, 0, 0} } + +func (h HBoxFlexView) WithFocus(i int) HBoxFlexView { return HBoxFlexView{h.Cols, i, h.Gap} } +func (h HBoxFlexView) WithGap(g int) HBoxFlexView { return HBoxFlexView{h.Cols, h.Focus, g} } + +func (h HBoxFlexView) Render(width, height int) *term.Buffer { + buf := term.NewBuffer(0) + if len(h.Cols) == 0 { + return buf + } + // TODO: Handle very narrow width + + for i, col := range h.Cols { + bufCol := col.Render(width-(h.Gap+1)*(len(h.Cols)-i-1), height) + actualWidth := term.CellsWidth(bufCol.Lines[0]) + for _, line := range bufCol.Lines[1:] { + actualWidth = max(actualWidth, term.CellsWidth(line)) + } + bufCol.Width = actualWidth + // TODO: Focus + if i > 0 { + buf.Width += h.Gap + } + buf.ExtendRight(bufCol) + } + return buf +} diff --git a/pkg/etk/zstring.go b/pkg/etk/zstring.go new file mode 100644 index 000000000..4842329ad --- /dev/null +++ b/pkg/etk/zstring.go @@ -0,0 +1,26 @@ +// Code generated by "stringer -type=Reaction -output=zstring.go"; DO NOT EDIT. + +package etk + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[Unused-0] + _ = x[Consumed-1] + _ = x[Finish-2] + _ = x[FinishEOF-3] +} + +const _Reaction_name = "UnusedConsumedFinishFinishEOF" + +var _Reaction_index = [...]uint8{0, 6, 14, 20, 29} + +func (i Reaction) String() string { + if i >= Reaction(len(_Reaction_index)-1) { + return "Reaction(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _Reaction_name[_Reaction_index[i]:_Reaction_index[i+1]] +} diff --git a/pkg/etkedit/edit.go b/pkg/etkedit/edit.go new file mode 100644 index 000000000..52b6448a6 --- /dev/null +++ b/pkg/etkedit/edit.go @@ -0,0 +1,106 @@ +package edit + +import ( + "sync" + + "src.elv.sh/pkg/cli" + "src.elv.sh/pkg/cli/term" + "src.elv.sh/pkg/edit/highlight" + "src.elv.sh/pkg/etk" + "src.elv.sh/pkg/eval" + "src.elv.sh/pkg/parse" + "src.elv.sh/pkg/ui" +) + +type Editor struct { + tty cli.TTY + ev *eval.Evaler + ns *eval.Ns + + mutex sync.RWMutex + promptFn func() ui.Text +} + +func NewEditor(tty cli.TTY, ev *eval.Evaler) *Editor { + ed := &Editor{tty: tty, ev: ev} + + nb := eval.BuildNsNamed("edit") + nb.AddVar("prompt", makeVar(ed, &ed.promptFn)) + + ed.ns = nb.Ns() + return ed +} + +func (ed *Editor) Comp() etk.Comp { + hl := highlight.NewHighlighter(highlight.Config{}) + return etk.WithBeforeHook( + etk.WithStates( + etk.CodeArea, + "highlighter", hl.Get), + func(c etk.Context) { + promptVar := etk.BindState[ui.Text](c, "prompt") + if ed.promptFn == nil { + promptVar.Set(ui.T("etkedit> ")) + } else { + promptVar.Set(ed.promptFn()) + } + }) +} + +func (ed *Editor) ReadCode() (string, error) { + ed.tty.ResetBuffer() // TODO: This was easy to miss + m, err := etk.Run(ed.tty, + func(ev term.Event, c etk.Context, tag string, f etk.React) etk.Reaction { + r := f(ev) + if r == etk.Unused { + switch ev { + case term.K('D', ui.Ctrl): + return etk.FinishEOF + } + } + return r + }, + ed.ev.CallFrame("edit"), + ed.Comp()) + if err != nil { + return "", err + } + buf, _ := m.Index("buffer") + return buf.(etk.CodeBuffer).Content, nil +} + +func (ed *Editor) RunAfterCommandHooks(src parse.Source, duration float64, err error) { + // TODO +} + +func (ed *Editor) Ns() *eval.Ns { return ed.ns } + +// Creates an editVar. This has to be a function because methods can't be +// polymorphic. +func makeVar[F any](ed *Editor, ptr *F) editVar[F] { + return editVar[F]{ptr, ed.ev, &ed.mutex} +} + +// Like [vars.PtrVar], but supports scanning Elvish functions as Go functions. +type editVar[F any] struct { + ptr *F + ev *eval.Evaler + mutex *sync.RWMutex +} + +func (v editVar[F]) Get() any { + v.mutex.RLock() + defer v.mutex.RUnlock() + return *v.ptr +} + +func (v editVar[F]) Set(val any) error { + v.mutex.Lock() + defer v.mutex.Unlock() + scanned, err := etk.ScanToGo[F](val, v.ev.CallFrame("edit")) + if err != nil { + return err + } + *v.ptr = scanned + return nil +} diff --git a/pkg/etkedit/edit_test.elvts b/pkg/etkedit/edit_test.elvts new file mode 100644 index 000000000..10d040221 --- /dev/null +++ b/pkg/etkedit/edit_test.elvts @@ -0,0 +1,22 @@ +//each:edit-fixture + +////////// +# prompt # +////////// + +~> set edit:prompt = { styled '>>> ' bold } + refresh + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚>>> β”‚ +β”‚**** Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +## outputting a string is fine ## +~> set edit:prompt = { print '>>> ' } + refresh + render +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚>>> β”‚ +β”‚ Μ…Μ‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ diff --git a/pkg/etkedit/transcripts_test.go b/pkg/etkedit/transcripts_test.go new file mode 100644 index 000000000..f22164540 --- /dev/null +++ b/pkg/etkedit/transcripts_test.go @@ -0,0 +1,36 @@ +package edit_test + +import ( + "embed" + "testing" + + "src.elv.sh/pkg/etk/etktest" + edit "src.elv.sh/pkg/etkedit" + "src.elv.sh/pkg/eval" + "src.elv.sh/pkg/eval/evaltest" +) + +//go:embed *.elvts +var transcripts embed.FS + +func TestTranscripts(t *testing.T) { + evaltest.TestTranscriptsInFS(t, transcripts, + "edit-fixture", func(ev *eval.Evaler) { + ed := edit.NewEditor(nil, ev) // TODO: This is fragile + ev.ExtendBuiltin(eval.BuildNs().AddNs("edit", ed)) + etktest.Setup(ev, ed.Comp()) + }) + /* + "code-area-fixture", etktest.MakeFixture(etk.CodeArea), + "abbr-table-in-global", evaltest.GoFnInGlobal("abbr-table", + func(m vals.Map) func(f func(a, f string)) { + return func(f func(a, f string)) { + for it := m.Iterator(); it.HasElem(); it.Next() { + k, v := it.Elem() + f(vals.ToString(k), vals.ToString(v)) + } + } + }), + */ + +} diff --git a/pkg/eval/eval.go b/pkg/eval/eval.go index ecb8435f5..dbb5d2963 100644 --- a/pkg/eval/eval.go +++ b/pkg/eval/eval.go @@ -374,6 +374,18 @@ func (ev *Evaler) Call(f Callable, callCfg CallCfg, evalCfg EvalCfg) error { return f.Call(fm, callCfg.Args, callCfg.Opts) } +// TODO: This was added to make etk work. The entire Evaler/Frame/Callable API +// needs reviewing. +func (ev *Evaler) CallFrame(from string) *Frame { + var evalCfg EvalCfg + evalCfg.fillDefaults() + if evalCfg.Global == nil { + evalCfg.Global = ev.Global() + } + fm, _ := ev.prepareFrame(parse.Source{Name: from}, evalCfg) + return fm +} + func (ev *Evaler) prepareFrame(src parse.Source, cfg EvalCfg) (*Frame, func()) { intCtx := cfg.Interrupts if intCtx == nil { diff --git a/pkg/eval/evaltest/test_transcript.go b/pkg/eval/evaltest/test_transcript.go index 982657394..40279cab6 100644 --- a/pkg/eval/evaltest/test_transcript.go +++ b/pkg/eval/evaltest/test_transcript.go @@ -407,3 +407,11 @@ func stripSGR(bs []byte) []byte { return sgrPattern.ReplaceAllLiteral(bs, n func stripSGRString(s string) string { return sgrPattern.ReplaceAllLiteralString(s, "") } func normalizeLineEnding(bs []byte) []byte { return bytes.ReplaceAll(bs, []byte("\r\n"), []byte("\n")) } + +// GoFnInGlobal returns a setup function that puts a Go-implemented function in +// the global scope, with the given name. +func GoFnInGlobal(name string, impl any) func(*eval.Evaler) { + return func(ev *eval.Evaler) { + ev.ExtendBuiltin(eval.BuildNs().AddGoFn(name, impl)) + } +} diff --git a/pkg/eval/vals/conversion.go b/pkg/eval/vals/conversion.go index 9c09e7f31..4c226cb1e 100644 --- a/pkg/eval/vals/conversion.go +++ b/pkg/eval/vals/conversion.go @@ -41,6 +41,10 @@ var ( errMustBeInteger = errors.New("must be integer") ) +// Scanner may be implemented by a pointer to provide custom behavior of +// [ScanToGo]. +type Scanner interface{ Scan(any, ScanOpt) error } + // ScanToGo converts an Elvish value to a Go value, and stores it in *ptr. It // panics if ptr is not a pointer. // @@ -54,6 +58,13 @@ var ( // The set of keys in src must match the set of keys in M exactly. This // behavior can be changed by using [ScanToGoOpts] instead. // +// - If ptr is a pointer to a slice (*[]T) and src can be iterated, it +// converts each individual element recursively, failing if the number of +// elements doesn't match or scanning of any element fails. +// +// - If ptr implements [Scanner], it uses the ScanFromElvish method to perform +// the conversion. +// // - In other cases, it tries to perform "*ptr = src" via reflection and // returns an error if the assignment can't be done. // @@ -117,6 +128,8 @@ func ScanToGoOpts(src, ptr any, opt ScanOpt) error { return convAndStore(elvToNum, src, ptr) case *rune: return convAndStore(elvToRune, src, ptr) + case Scanner: + return ptr.Scan(src, opt) default: dstType := reflect.TypeOf(ptr).Elem() // Attempt a simple assignment (*ptr = src) via reflection. @@ -134,12 +147,37 @@ func ScanToGoOpts(src, ptr any, opt ScanOpt) error { } } // Try to scan a field map. - if keys := getFieldMapKeysT(reflect.TypeOf(ptr).Elem()); keys != nil { + if keys := getFieldMapKeysT(dstType); keys != nil { if _, ok := src.(Map); ok || IsFieldMap(src) { return scanFieldMapFromMap(src, ptr, keys, opt) } } - // Return a suitable error. + // Try to scan a slice. + if dstType.Kind() == reflect.Slice && CanIterate(src) { + elemType := dstType.Elem() + dstLen := Len(src) + if dstLen < 0 { + dstLen = 0 + } + dst := reflect.MakeSlice(dstType, 0, dstLen) + var err error + Iterate(src, func(srcElem any) bool { + elemPtr := reflect.New(elemType) + err = ScanToGoOpts(srcElem, elemPtr.Interface(), opt) + if err != nil { + // TODO: Wrap err with more context information + return false + } + dst = reflect.Append(dst, reflect.Indirect(elemPtr)) + return true + }) + if err != nil { + return err + } + ValueOf(ptr).Elem().Set(dst) + return nil + } + // All attempts failed. Return a suitable error. var dstKind string if dstType.Kind() == reflect.Interface { dstKind = "!!" + dstType.String() diff --git a/pkg/eval/vals/conversion_test.elvts b/pkg/eval/vals/conversion_test.elvts new file mode 100644 index 000000000..35b28a455 --- /dev/null +++ b/pkg/eval/vals/conversion_test.elvts @@ -0,0 +1,6 @@ +# converting Elvish list to Go slice # +//scan-string-slice-in-global +~> scan-string-slice [foo bar] +scanned: []string{"foo", "bar"} +~> scan-string-slice foo +scanned: []string{"f", "o", "o"} diff --git a/pkg/eval/vals/transcripts_test.go b/pkg/eval/vals/transcripts_test.go new file mode 100644 index 000000000..232d4e9a8 --- /dev/null +++ b/pkg/eval/vals/transcripts_test.go @@ -0,0 +1,22 @@ +package vals_test + +import ( + "embed" + "fmt" + "testing" + + "src.elv.sh/pkg/eval" + "src.elv.sh/pkg/eval/evaltest" +) + +//go:embed *.elvts +var transcripts embed.FS + +func TestTranscripts(t *testing.T) { + evaltest.TestTranscriptsInFS(t, transcripts, + "scan-string-slice-in-global", evaltest.GoFnInGlobal("scan-string-slice", + func(fm *eval.Frame, s []string) { + fmt.Fprintf(fm.ByteOutput(), "scanned: %#v\n", s) + }), + ) +} diff --git a/pkg/mods/mods.go b/pkg/mods/mods.go index 5dee7b5de..8912ce306 100644 --- a/pkg/mods/mods.go +++ b/pkg/mods/mods.go @@ -35,6 +35,7 @@ func AddTo(ev *eval.Evaler) { ev.AddModule("doc", doc.Ns) ev.AddModule("os", os.Ns) ev.AddModule("md", md.Ns) + // ev.AddModule("etk", etk.Ns) if unix.ExposeUnixNs { ev.AddModule("unix", unix.Ns) } diff --git a/pkg/shell/interact.go b/pkg/shell/interact.go index 50bd31036..c5ee935cd 100644 --- a/pkg/shell/interact.go +++ b/pkg/shell/interact.go @@ -15,6 +15,8 @@ import ( "src.elv.sh/pkg/daemon/daemondefs" "src.elv.sh/pkg/diag" "src.elv.sh/pkg/edit" + "src.elv.sh/pkg/etk" + etkedit "src.elv.sh/pkg/etkedit" "src.elv.sh/pkg/eval" "src.elv.sh/pkg/mods/daemon" "src.elv.sh/pkg/mods/store" @@ -34,6 +36,7 @@ type interactCfg struct { ActivateDaemon daemondefs.ActivateFunc SpawnConfig *daemondefs.SpawnConfig + EtkEdit bool } // Interface satisfied by the line editor. Used for swapping out the editor with @@ -74,10 +77,17 @@ func interact(ev *eval.Evaler, fds [3]*os.File, cfg *interactCfg) { if sys.IsATTY(fds[0].Fd()) { restoreTTY := term.SetupForTUIOnce(fds[0], fds[1]) defer restoreTTY() - newed := edit.NewEditor(cli.NewTTY(fds[0], fds[2]), ev, daemonClient) - ev.ExtendBuiltin(eval.BuildNs().AddNs("edit", newed)) - ev.BgJobNotify = func(s string) { newed.Notify(ui.T(s)) } - ed = newed + if cfg.EtkEdit { + newed := etkedit.NewEditor(cli.NewTTY(fds[0], fds[2]), ev) + ev.ExtendBuiltin(eval.BuildNs().AddNs("edit", newed)) + ed = newed + } else { + newed := edit.NewEditor(cli.NewTTY(fds[0], fds[2]), ev, daemonClient) + etk.Notify = newed.Notify + ev.ExtendBuiltin(eval.BuildNs().AddNs("edit", newed)) + ev.BgJobNotify = func(s string) { newed.Notify(ui.T(s)) } + ed = newed + } } else { ed = newMinEditor(fds[0], fds[2]) } diff --git a/pkg/shell/shell.go b/pkg/shell/shell.go index 94b7eb092..dc880a2e7 100644 --- a/pkg/shell/shell.go +++ b/pkg/shell/shell.go @@ -50,6 +50,7 @@ type Program struct { rc string json *bool daemonPaths *prog.DaemonPaths + etkedit bool } func (p *Program) RegisterFlags(fs *prog.FlagSet) { @@ -67,6 +68,7 @@ func (p *Program) RegisterFlags(fs *prog.FlagSet) { "Don't read the RC file when running interactively") fs.StringVar(&p.rc, "rc", "", "Path to the RC file when running interactively") + fs.BoolVar(&p.etkedit, "etkedit", false, "use new etk-based editor") p.json = fs.JSON() if p.ActivateDaemon != nil { @@ -105,7 +107,10 @@ func (p *Program) Run(fds [3]*os.File, args []string) error { interact(ev, fds, &interactCfg{ RC: ev.EffectiveRcPath, - ActivateDaemon: p.ActivateDaemon, SpawnConfig: spawnCfg}) + ActivateDaemon: p.ActivateDaemon, + SpawnConfig: spawnCfg, + EtkEdit: p.etkedit, + }) return nil } diff --git a/pkg/ui/text.go b/pkg/ui/text.go index 9cf1bdd38..c74e4a34d 100644 --- a/pkg/ui/text.go +++ b/pkg/ui/text.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "src.elv.sh/pkg/eval/errs" "src.elv.sh/pkg/eval/vals" "src.elv.sh/pkg/wcwidth" ) @@ -254,3 +255,20 @@ func TextFromSegment(seg *Segment) Text { } return Text{seg} } + +func (pt *Text) Scan(v any, _ vals.ScanOpt) error { + switch v := v.(type) { + case string: + *pt = T(v) + return nil + case *Segment: + *pt = TextFromSegment(v) + return nil + case Text: + *pt = v + return nil + default: + return errs.BadValue{What: "value", + Valid: "string, styled segment or styled text", Actual: vals.Kind(v)} + } +} diff --git a/website/slides/draft-etk.md b/website/slides/draft-etk.md new file mode 100644 index 000000000..bc498b817 --- /dev/null +++ b/website/slides/draft-etk.md @@ -0,0 +1,290 @@ +# The design and implementation of the Etk TUI framework: a study of unusual tradeoffs + +Qi Xiao (xiaq) + +(Draft slides) + +*** + +# Motivation + +- Elvish's TUI consists of ~22k LoC (pkg/cli, pkg/edit) + + - Compared to ~18k LoC of the interpreter (pkg/parse, pkg/eval) + +- The good + + - Heavily modularized into a hierarchy of components + + - Simple, uniform `Widget` API + + - High test coverage + +- The bad: hard to develop and test in Go + + - Adding a simple functionality often requires "propagating" it through + multiple layers of internal APIs + + - Tests are verbose and time-consuming to create and update + +- The ugly: very hard to extend from Elvish code + + - Adhoc customization/extension points and bespoke bindings + + - Internal components cannot be reused from Elvish code + + - No API to write a component in Elvish + +*** + +# Immediate mode UI + +- Originally: for each frame, clear the screen and redraw everything + +- Immediate mode emulation over retained mode primitives + + - Components are "just functions" + + - React, SwiftUI, Jetpack Compose + +*** + +# Component is "just function" + +- Hide complexity + + - Bad + +- Encourages fine-grained componentization + + - Good + +*** + +# State management in immediate mode + +- Local variables don't survive the next call + +- Solution: Store it somewhere elseℒ️ + + - YOLO, just use global variables + + - Use global variables, but declared inside components (SwiftUI, Jetpack + Compose) + + - Use global variables, but hidden by the framework (React) + +- Etk's solution + + - Use global variables, organized into a tree + +*** + +# State tree in Etk + +- Backed by nested persistent maps + + - Immutable + + - But cheap to create variation of + + - Undo/redo comes (almost) for free + +*** + +# TUI primitives + +- Terminal is mostly text + +- In-band signals: escape codes + + - Combination and function keys + + - Text style + + - Cursor addressing + +- Out-of-band control: signals and `ioctl` + +- Unix's terminal API dates back to the 1960s (TTY = **t**ele**ty**pewritter) + + - Various unsuccessful reform attempts throughout history + +*** + +# Implementation + +- The Etk core is only ~X00 LoC, or ~Y00 effective LoC + + - Doesn't include terminal primitives or common components + +*** + +# An open system + +- Inspectable + +- Tinkerable + +- Not a sealed "product" + +*** + +# Unsafety + +- State bindings in Go are not type-safe + +- A conscious choice + + - Support the desired style + + - Any state could be changed by Elvish code to any value + +- Subcomponent and state names are just strings + + - Not ideal + +*** + +# Non-encapsulation + +- A component can mutate the state of any of its descendant + + - Even which subcomponent a component uses is a mutable state + +- Access to the root allows you to inspect and mutate any point of the state + tree + +- Again, a conscious choice + + - The entire state tree is exposed in the Elvish binding + + - Each TUI app gets an API for free + +*** + +# Sample code (Go) + +```go +// TODO: source of the Counter component +``` + +*** + +# Sample code (Elvish) + +```elvish +# TODO: source of the Counter component +``` + +*** + +# Keybinding + +- Different components use different keybindings + +- No problem, just declaring binding as a state: + + ```go + func MyComp(c etk.Context) etk.Scene { + bindingVar := etk.State(c, "binding", + func(term.Event) etk.Action { return etk.Unused }) + + return Scene{ + View: someView, + React: func(ev term.Event) etk.Action { + action := bindingVar.Get()(ev) + if action != etk.Unused { + return action + } + /* Custom logic */ + }, + } + } + ``` + +*** + +# Keybinding (cont.) + +- But multiple instances of the same component should share a keybinding + + - If I bind Left to move the cursor one character left, it + should apply to all CodeArea components + + - Some individual instances can have another overlay + +*** + +# Concurrency safety + +- Imperative style is very natural for expressing state changes + + - And not very good for concurrency safety + +- Guard each mutation with a mutex - simple + + - Concurrent writes are not properly isolated + +- RETVRN to Elm's purely functional style? + + - Function signatures become more complex + + - Event handler can change the state + + - Need to manually propagate state changes from subcomponents + + - Initialization can change the state too + +- If mutexes don't solve your problem, you aren't using enough mutexes + + - Two mutexes + +*** + +# Name safety revisited + +- Simple typos can't be prevented + + - Let's declare them as Go identifiers instead + + - But... + +- Compare this: + + ```go + // TODO + ``` + +- With this: + + ```go + // TODO + ``` + +- The former is less safe, but highlights the component and state hierarchy + very clearly + +- Declared keywords when? + +*** + +# Cost vs benefits + +- We give up + + - Type and name safety + + - Ability to enforce any invariant at all + +- We get + + - Concise code + + - First-class Elvish binding + + - Undo/redo for free + +- We still keep + + - Concurrency safety