$30 off During Our Annual Pro Sale. View Details »

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. TwinUI
    Combining a full-screen text UI
    with a Web UI
    in a single Go application

    View Slide

  2. https://osinet.fr/go/en
    Frédéric G. MARAND
    Consulting engineer
    Quality and performance

    View Slide

  3. 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 ?

    View Slide

  4. Use case

    View Slide

  5. Use case: why have 2 UIs ?
    ● Web UIs are what information/content
    consumers expect
    ● The command line is the developers
    bread and butter

    View Slide

  6. 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)

    View Slide

  7. 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

    View Slide

  8. Use case: why have 2 UIs ?
    https://gophercises.com/

    View Slide

  9. Use case: why have 2 UIs ?

    View Slide

  10. Technical
    choices

    View Slide

  11. 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

    View Slide

  12. 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

    View Slide

  13. 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 !

    View Slide

  14. TView

    View Slide

  15. TView: dependencies

    View Slide

  16. TView: demo

    View Slide

  17. 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.

    View Slide

  18. TView: Hello, world !
    package main
    import "github.com/rivo/tview"
    func main() {
    tv := tview.NewButton("Hello, world!")
    tview.NewApplication().SetRoot(tv, true).Run()
    }

    View Slide

  19. TView: all components

    View Slide

  20. 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()

    View Slide

  21. Coding
    both UIs

    View Slide

  22. 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

    View Slide

  23. 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)
    }

    View Slide

  24. Coding both UIs: web - arc.gohtml




    Choose Your Own Adventure: {{.Title}}




    {{ .Title }}
    {{ range .Story }}
    {{ . }}
    {{ end }}
    {{ if .Options }}

    {{ range .Options }}

    {{ .Text }}
    {{ end }}

    {{ end }}


    This is a demo of an exercise from the
    free course

    Gophercises. Check it out
    if you are interested in learning /
    practicing Go.



    View Slide

  25. 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
    }

    View Slide

  26. 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)
    }
    }

    View Slide

  27. Coding both UIs: web - result

    View Slide

  28. Coding both UIs: Text UI - wireframe

    View Slide

  29. 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

    View Slide

  30. 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)
    }
    }

    View Slide

  31. 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
    }

    View Slide

  32. 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)
    }
    }

    View Slide

  33. 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
    }

    View Slide

  34. Coding both UIs: Text UI - result

    View Slide

  35. Composing
    Twin UIs

    View Slide

  36. 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 !

    View Slide

  37. 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

    View Slide

  38. 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

    View Slide

  39. 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)
    ....

    View Slide

  40. 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
    }

    View Slide

  41. 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...

    View Slide

  42. TwinUI: one last thing...
    ● tview.TermView implements io.Writer:
    ● use it for logs: log.SetOutput(&logger{app: app, Writer: view.Body})

    View Slide

  43. https://github.com/fgm/twinui

    View Slide

  44. ?
    QUESTIONS
    github.com/fgm/twinui

    View Slide

  45. malt-academy.com

    View Slide