Slide 1

Slide 1 text

TwinUI Combining a full-screen text UI with a Web UI in a single Go application

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

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 ?

Slide 4

Slide 4 text

Use case

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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)

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

Use case: why have 2 UIs ?

Slide 10

Slide 10 text

Technical choices

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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 !

Slide 14

Slide 14 text

TView

Slide 15

Slide 15 text

TView: dependencies

Slide 16

Slide 16 text

TView: demo

Slide 17

Slide 17 text

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.

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

TView: all components

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

Coding both UIs

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

Coding both UIs: web - arc.gohtml Choose Your Own Adventure: {{.Title}}

{{ .Title }}

{{ range .Story }}

{{ . }}

{{ end }} {{ if .Options }} {{ end }} This is a demo of an exercise from the free course Gophercises. Check it out if you are interested in learning / practicing Go.

Slide 25

Slide 25 text

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 }

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

Coding both UIs: web - result

Slide 28

Slide 28 text

Coding both UIs: Text UI - wireframe

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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 }

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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 }

Slide 34

Slide 34 text

Coding both UIs: Text UI - result

Slide 35

Slide 35 text

Composing Twin UIs

Slide 36

Slide 36 text

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 !

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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 }

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

https://github.com/fgm/twinui

Slide 44

Slide 44 text

? QUESTIONS github.com/fgm/twinui

Slide 45

Slide 45 text

malt-academy.com