Upgrade to Pro — share decks privately, control downloads, hide ads and more …

A full-screen text UI in Go

A full-screen text UI in Go

Wondering how to create a Go application combining a full-screen text UI (TUI) with a web UI ? This is actually fairly easy, as this presentation shows using rivo/tview and gorilla/mux.

The matching code is on sur https://github.com/fgm/twinui

The site of my Go book is https://osinet.fr/go/en

Frédéric G. MARAND

July 03, 2020
Tweet

More Decks by Frédéric G. MARAND

Other Decks in Programming

Transcript

  1. 3 2 TABLE OF CONTENTS Use case Why have 2

    UIs ? Technical choices Which modules ? Why ? TView How does TView work ? 1 5 4 Code How does one code this UI ? Combinaison How to merge the 2 UIs ?
  2. Use case: why have 2 UIs ? • Web UIs

    are what information/content consumers expect • The command line is the developers bread and butter
  3. Use case: why have 2 UIs ? • To run

    a program with a graphical UI (IDE…) • To access a program options • To assemble a command pipeline (shell…) • To run vim/emacs, atop/htop ... • To manage a remote instance over SSH • To control a running program (docker)
  4. Use case: why have 2 UIs ? • Our example:

    a simple “choose your own adventure” program • The original version: a tutorial by Ion Calhoun on his Gophercises training site
  5. Technical choices: web - for this demo • Why use

    any given web framework ? • Use none: optimal for micro-services, rarely so for other cases ◦ Dynamic paths ◦ Argument validators / loaders ◦ Method filtering ◦ Path groups mounting • Gorilla/mux: ◦ minimal ◦ maximal popularity ◦ defines the original middleware format for Go ◦ used for this example, trivial to convert to your own framework of choice
  6. Technical choices: web - for an actual project • Gin

    https://gin-gonic.com/ ◦ specifically optimized for APIs ◦ simple, fast • Beego https://beego.me/ ◦ inspired by Django, ◦ still simple, good tools (ORM, runner) • Buffalo https://gobuffalo.io/fr/ ◦ for more traditional web projects you would do in Laravel of Symfony ◦ feature-rich, aggregating third party components ◦ Pop ORM, Plush templates, I18n… ◦ Documentation available in English, French, Italian, Spanish • Revel http://revel.github.io/ ◦ What Buffalo does, but doing it since 2013
  7. Technical choices: text UI • Pre-history: https://github.com/gbin/goncurses • nsf/termbox-go :

    ncurses rethought in Go. Very popular. Abandoned. • gdamore/tcell : the pretender. Fast, Unicode, colors, mouse. • gizak/termui : ascii graphics for dashboards like expvarmon • JoelOtter/termloop : optimized for full-screen text games • jroimartin/gocui : basic text-mode windows • rivo/tview : widgets, windows, application, layout (flex!), 256 colors, mouse, sound, Unicode, based on tcell. • VladimirMarkelov/clui: TurboVision TNG :-O tview FTW !
  8. TView: code structure 1. Create Widgets: Button, Text, List …

    2. Configure them ◦ Either using their Box embedded field: SetTitle, SetBackgroundColor ... ◦ Or using their specific methods: Checkbox.SetChecked, Table.InsertRow … 3. Add event handlers on widgets implementing FormItem ◦ Either adding children: List.AddItem(pri, sec, run, func()) *List ◦ Or directly: List.SetSelectedFunc(func(int, string, string, rune) *List) 4. Create an application with NewApplication 5. Configure its top-level widget with Application.SetRoot 6. Add a top-level event handler with Application.SetInputCapture 7. Start the event loop with Application.Run.
  9. TView: Hello, world ! package main import "github.com/rivo/tview" func main()

    { tv := tview.NewButton("Hello, world!") tview.NewApplication().SetRoot(tv, true).Run() }
  10. TView: custom components • Creating custom components means implementing TView.Primitive

    ◦ Blur, Draw, Focus, GetFocusable, GetRect, InputHandler, MouseHandler, SetRect ◦ Either directly, or by composing a Box like most existing components do • Or more simply: ◦ Overload the Draw method on an existing component ◦ Hook into draw steps with Application.Draw: SetBeforeDrawFunc(), SetAfterDrawFunc() ◦ Catch mouse/keyboard events ▪ Globally: Application.SetInputCapture() ▪ On a Primitive: Box.SetInputCapture()
  11. Coding both UIs: web Layout: • arc.gohtml • gopher.json •

    main.go • README.md • story.go • style.css • web.go In a single main package: • Template • Data • App. entry point • Documentation • Data model • Styles • Web handlers
  12. Coding both UIs: web - main.go package main import (

    "flag" "html/template" "log" "net/http" "os" "strconv" "github.com/gorilla/mux" ) func main() { path := flag.String("story", "gopher.json", "The name of the file containing the data") port := flag.Int("port", 8080, "TCP port") flag.Parse() story, err := loadStory(*path) if err != nil { log.Fatalln(err) } //...suite... tpl, err := template.ParseFiles("arc.gohtml") if err != nil { log.Println("Failed parsing arc template", err) os.Exit(2) } r := mux.NewRouter() r.HandleFunc("/style.css", styleHandler) r.HandleFunc("/arc/{arc}", func(w http.ResponseWriter, r *http.Request) { arcHandler(w, r, story, tpl) }) r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/arc/intro", http.StatusMovedPermanently) }) _ = http.ListenAndServe(":"+strconv.Itoa(*port), r) }
  13. Coding both UIs: web - arc.gohtml <!DOCTYPE html> <html> <head>

    <meta charset="utf-8"> <title>Choose Your Own Adventure: {{.Title}}</title> <link rel="stylesheet" href="/style.css"/> </head> <body> <section class="page"> <h1>{{ .Title }}</h1> {{ range .Story }} <p>{{ . }}</p> {{ end }} {{ if .Options }} <ul> {{ range .Options }} <li><a href="/arc/{{ .ArcName }}"> {{ .Text }}</a></li> {{ end }} </ul> {{ end }} </section> <footer> This is a demo of an exercise from the free course <a href="https://gophercises.com"> Gophercises</a>. Check it out if you are interested in learning / practicing Go. </footer> </body> </html>
  14. Coding both UIs: web - story.go package main import (

    "encoding/json" "os" ) // Option represents a choice at the end of an arc. type Option struct { Text string ArcName string `json:"arc"` } // Arc represents an individual narrative structure. type Arc struct { Title string Story []string Options []Option } // Story is the graph of arcs. type Story map[string]Arc func loadStory(path string) (Story, error) { story := Story{} file, err := os.Open(path) if err != nil { return story, err } decoder := json.NewDecoder(file) err = decoder.Decode(&story) return story, err }
  15. Coding both UIs: web - web.go func arcHandler(w http.ResponseWriter, r

    *http.Request, story Story, tpl *template.Template) { name, ok := mux.Vars(r)["arc"] if !ok { log.Println("arcHandler called without an arc") http.Redirect(w, r, "/", http.StatusMovedPermanently) return } arc, ok := story[name] if !ok { log.Printf("Incorrect arc requested: %s\n", name) http.NotFound(w, r) return } err := tpl.Execute(w, arc) if err != nil { log.Printf("Arc template %#v: %v\n", arc, err) w.WriteHeader(http.StatusInternalServerError) return } } func styleHandler(w http.ResponseWriter, r *http.Request) { file, err := os.Open("style.css") if err != nil { log.Println(err) http.NotFound(w, r) return } defer file.Close() w.Header().Set("Content-Type", "text/css") _, err = io.Copy(w, file) if err != nil { log.Println("Failed sending CSS", err) w.WriteHeader( http.StatusInternalServerError) } }
  16. Coding both UIs: Text UI - layout Layout: • ../gophercises_cyoa/gopher.json

    • main.go • model.go • ui.go A single main package: • Data • App. entry point • Model • UI components
  17. Coding both UIs: Text UI - main.go func initUI(story *Story)

    (*tview.Application, *View) { view := NewView(story) app := tview.NewApplication() app.SetRoot(view.Grid, true). EnableMouse(true). SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyEsc: app.Stop() case tcell.KeyRune: u := view.URLFromKey(event) switch { case u == `quit`: app.Stop() case u != ``: view.Handle(u) } } return nil }) return app, view } func initModel(path string) (*Story, error) { story := make(Story) err := story.Load(path) if err != nil { return nil, fmt.Errorf(`loading story: %w`, err) } return &story, nil } func main() { path := flag.String(`story`, `./gophercises_cyoa/gopher.json`, ` "The name of the file containing the data`) flag.Parse() story, err := initModel(*path) if err != nil { log.Fatalf("Starting model: %v\n", err) } app, view := initUI(story) view.Handle(`intro`) if err := app.Run(); err != nil { log.Fatalf("Running app: %v\n", err) } }
  18. Coding both UIs: Text UI - model.go package main import

    ( "encoding/json" "fmt" "os" ) // Option represents a choice at the end of an arc. type Option struct { Label string `json:"text"` URL string `json:"arc"` } // Arc represents an individual narrative structure. type Arc struct { Title string `json:"title"` Body []string `json:"story"` Options []Option `json:"options"` } // Story is the graph of arcs. type Story map[string]Arc // Load fetches the story data from disk. func (s *Story) Load(path string) error { file, err := os.Open(path) if err != nil { return fmt.Errorf(`opening %s: %w`, path, err) } decoder := json.NewDecoder(file) err = decoder.Decode(s) return err } // Arc obtains an arc from its URL. func (s *Story) Arc(url string) *Arc { a, ok := (*s)[url] if !ok { return nil } return &a }
  19. Coding both UIs: Text UI - ui.go package main import

    ( "fmt" "log" "github.com/gdamore/tcell" "github.com/rivo/tview" ) // View holds the structure of the application View: type View struct { // Heading is the top line. Heading *tview.TextView // Body is the main frame. Body *tview.TextView // Actions contains the action menu. Actions *tview.List // Grid is the container wrapping Heading, Body, Actions. *tview.Grid // Story is the model from which the View reads data. *Story } func (v View) Handle(url string) { arc := v.Story.Arc(url) if arc == nil { log.Printf("Path not found: %s\n", url) return } fmt.Fprint(v.Heading.Clear(), arc.Title) b := v.Body.Clear() for _, row := range arc.Body { fmt.Fprintln(b, row + "\n") } v.Actions.Clear() if len(arc.Options) == 0 { arc.Options = []Option{{ Label: `Leave story`, URL: `quit`, }} } for k, item := range arc.Options { v.Actions.InsertItem(k, item.Label, item.URL, rune('a' + k), nil) } }
  20. Coding both UIs: Text UI - ui.go (2) func textView(title

    string) *tview.TextView { tv := tview.NewTextView(). SetTextAlign(tview.AlignLeft). SetTextColor(tcell.ColorBlack) tv.SetBackgroundColor(tcell.ColorWhite). SetBorderColor(tcell.ColorLightGray). SetBorder(true) tv.SetTitle(` ` + title + ` `). SetTitleColor(tcell.ColorSlateGray). SetTitleAlign(tview.AlignLeft) return tv } func list(title string) *tview.List { l := tview.NewList(). SetMainTextColor(tcell.ColorBlack). ShowSecondaryText(false). SetShortcutColor(tcell.ColorDarkGreen) l.SetBackgroundColor(tcell.ColorWhite). SetBorderColor(tcell.ColorLightGray). SetBorder(true). SetTitle(` ` + title + ` `). SetTitleColor(tcell.ColorSlateGray). SetTitleAlign(tview.AlignLeft) return l } // NewView builds an initialized View. func NewView(story *Story) *View { v := &View{ Heading: textView("Scene"), Body: textView("Description").SetScrollable(true), Actions: list("Choose wisely"), Grid: tview.NewGrid(), Story: story, } v.Grid. SetRows(3, 0, 5). // 1-row title, 3-row actions. Add 2 for their own borders. SetBorders(false). // Use the view borders instead. AddItem(v.Heading, 0, 0, 1, 1, 0, 0, false). AddItem(v.Body, 1, 0, 1, 1, 0, 0, false). AddItem(v.Actions, 2, 0, 1, 1, 0, 0, true) return v }
  21. TwinUI: composing 2 UIs • Currently 2 main packages, each

    in its own directory • Problem: each one has its own data model • Problem: each one ends with a blocking function call ◦ Web: _ = http.ListenAndServe(":"+strconv.Itoa(*port), r) ◦ TView: _ = app.Run() • Refactoring !
  22. TwinUI: composing 2 UIs Layout: • main.go • model/ ◦

    gopher.json ◦ model.go • tview/ ◦ ui.go • web/ ◦ arc.gohtml ◦ style.css ◦ web.go 4 packages (...only do this at scale!…) • App. entry point • Data source ◦ Data ◦ Data access • Interactor: terminal ◦ Text components • Interactor: web ◦ Template ◦ Styles ◦ Web components
  23. TwinUI: composing 2 UIs You probably think this is beginning

    to sound familiar… At scale, generalizing this layout is part of what is often called “hexagonal architecture” Image: Netflix tech blog https://netflixtechblog.com/ready-for-chang es-with-hexagonal-architecture-b315ec967 749
  24. TwinUI: the secret is in the main package func main()

    { path := flag.String(`story`, `./model/gopher.json`, `The name of the data file`) port := flag.Int("port", 8080, "The TCP port on which to listen") flag.Parse() // Initialize model: without data, we can't proceed. story, err := initModel(*path) if err != nil { log.Fatalf("Starting model: %v\n", err) } defer story.Close() // Initialize the twin UIs. app := initTextUI(story) router := initWebUI(story) ....
  25. TwinUI: the secret is in the main package …. //

    Run the twin UIs, exiting the app whenever either of them exits. done := make(chan bool) go func() { if err := app.Run(); err != nil { log.Fatalf("Running text app: %v\n", err) } done <- true }() go func() { if err := http.ListenAndServe(":"+strconv.Itoa(*port), router); err != nil { log.Fatalf("Running web app: %v\n", err) } done <- true }() <-done }
  26. TwinUI: one last thing... • What about errors ? log

    uses os.Stderr, hence the terminal... ◦ So if an error happens while we use the text UI...
  27. TwinUI: one last thing... • tview.TermView implements io.Writer: • use

    it for logs: log.SetOutput(&logger{app: app, Writer: view.Body})