Comment créer une application combinant une interface Web et une interface plein écran texte (TUI) avec rivo/tview.
Le code est sur https://github.com/fgm/twinui
Le site du livre est https://osinet.fr/go
5. Le besoin: pourquoi 2 interfaces ?
● L’interface Web est la référence pour
les consommateurs
● La ligne de commande est le
quotidien des développeurs
6. Le besoin: pourquoi 2 interfaces ?
● Lancer un programme graphique (IDE…)
● Chercher les options d’un programme
● Assembler un pipeline pour un script
● Utiliser vim ou emacs, atop/htop ...
● Administrer une instance en SSH
● Contrôler un programme en cours (docker)
7. Le besoin: pourquoi 2 interfaces ?
● Exemple: “un livre dont vous êtes le
héros” aka “choose your own adventure”
● L’original: un projet de Ion Calhoun sur
son site Gophercises
11. Choix techniques: côté Web - pour cette démo
● Pourquoi un framework web plutôt qu’un autre ?
● Aucun: bien pour un micro-service, rarement au-delà
○ Chemins dynamiques
○ Chargeurs / validateurs d’arguments
○ Filtres par méthode
○ Montages de sous-groupes
● Gorilla/mux:
○ minimal
○ popularité maximale
○ modèle des middlewares
○ utilisé pour l’exemple, facile à convertir
12. Choix techniques: côté Web - pour un projet lourd
● Gin https://gin-gonic.com/
○ plutôt orienté APIs
○ rapide, simple
● Beego https://beego.me/
○ inspiration Django,
○ simple, bien outillé (ORM, ligne de commande)
● Buffalo https://gobuffalo.io/fr/
○ pour les projets Web classiques
○ riche: adoption de composants tiers
○ ORM Pop, moteur de templates Plush, I18n…
○ documentation en français
● Revel http://revel.github.io/
○ Positionnement Buffalo mais depuis 2013
13. Choix techniques: côté texte - l’offre
● Préhistoire: https://github.com/gbin/goncurses
● nsf/termbox-go : ncurses repensé en Go. Très populaire. Abandonné.
● gdamore/tcell : le successeur. Vitesse, Unicode, couleurs, souris.
● gizak/termui : graphique pour tableaux de bord comme expvarmon
● JoelOtter/termloop : spécialisé pour les jeux plein écran texte
● jroimartin/gocui : gestion de “fenêtres” texte basique
● rivo/tview : widgets, fenêtres, application, layout (flex!), 256 couleurs,
souris, son, Unicode, basé sur tcell.
● VladimirMarkelov/clui: TurboVision TNG :-O
tview FTW !
17. TView: organisation du code
● Créer des Widgets: Button, Text, List …
● Les configurer
○ Configurables par leur champ promu Box: SetTitle, SetBackgroundColor ...
○ Et aussi avec leurs propres méthodes: Checkbox.SetChecked, Table.InsertRow …
● Leur ajouter des handlers d’événement s’ils composent FormItem
○ Soit à l’ajout d’enfants: List.AddItem(pri, sec, run, func()) *List
○ Soit directement: List.SetSelectedFunc(func(int, string, string, rune) *List)
● Créer une application avec NewApplication
● Définir quel widget est la racine de l’arborescence avec SetRoot
● Lui ajouter un handler d’événement par SetInputCapture
● Lancer la boucle événementielle avec la méthode Run.
18. TView: composants
package main
import "github.com/rivo/tview"
func main() {
tv := tview.NewButton("Hello, world!")
tview.NewApplication().SetRoot(tv,
true).Run()
}
20. TView: composants personnalisés
● Créer ses composants = implémenter TView.Primitive
○ Blur, Draw, Focus, GetFocusable, GetRect, InputHandler, MouseHandler, SetRect
○ Soit directement, soit en composant un Box comme font les autres
● Plus simplement:
○ Surcharger le Draw d’un composant approprié
○ Intercepter les rafraîchissements par les hooks Application.Draw:
SetBeforeDrawFunc(), SetAfterDrawFunc()
○ Intercepter les événements
■ Globalement: Application.SetInputCapture()
■ Sur une Primitive: Box.SetInputCapture()
22. Coder ses UI: web
Layout:
● arc.gohtml
● gopher.json
● main.go
● README.md
● story.go
● style.css
● web.go
Un même package main:
● Template
● Données
● Point d’entrée de l’application
● Documentation
● Modèle de données
● Styles
● Handlers web
23. Coder ses UI: 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)
}
24. Coder ses UI: 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>
25. Coder ses UI: 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
}
26. Coder ses UI: 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: %sn", name)
http.NotFound(w, r)
return
}
err := tpl.Execute(w, arc)
if err != nil {
log.Printf("Arc template %#v: %vn", 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)
}
}
29. Coder ses UI: terminal - layout
Layout:
● ../gophercises_cyoa/gopher.json
● main.go
● model.go
● ui.go
Un même package main:
● Données
● Point d’entrée de l’application
● Accès aux données
● Composants d’interface
30. Coder ses UI: terminal - 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: %vn", err)
}
app, view := initUI(story)
view.Handle(`intro`)
if err := app.Run(); err != nil {
log.Fatalf("Running app: %vn", err)
}
}
31. Coder ses UI: terminal - 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
}
32. Coder ses UI: terminal - 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: %sn", 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)
}
}
33. Coder ses UI: terminal - 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
}
36. TwinUI: composer les 2 UIs
● 2 paquets main, chacun dans leur répertoire
● Problème: chacun a sa logique d’accès aux données
● Problème: chacun se termine par une fonction bloquante:
○ Web: _ = http.ListenAndServe(":"+strconv.Itoa(*port), r)
○ TView: _ = app.Run()
● Refactoring !
37. TwinUI: composer les 2 UIs
Layout:
● main.go
● model/
○ gopher.json
○ model.go
● tview/
○ ui.go
● web/
○ arc.gohtml
○ style.css
○ web.go
4 paquets (...mais…)
● Point d’entrée de l’application
● Source de données
○ Données
○ Accès aux données
● Interacteur: terminal
○ Composants texte
● Interacteur: web
○ Template
○ Styles
○ Composants web
38. TwinUI: composer les 2 UIs
Si ça vous rappelle quelque
chose, ce n’est pas un
hasard…
À l’échelle d’un projet réel, la
généralisation est souvent
représentée comme ceci et
appelée “architecture
hexagonale”.
Image: Blog Netflix
https://netflixtechblog.com/ready-for-
changes-with-hexagonal-architecture-
b315ec967749
39. TwinUI: le secret est dans le main
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: %vn", err) }
defer story.Close()
// Initialize the twin UIs.
app := initTextUI(story)
router := initWebUI(story)
....
40. TwinUI: le secret est dans le main
….
// 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: %vn", err)
}
done <- true
}()
go func() {
if err := http.ListenAndServe(":"+strconv.Itoa(*port), router); err !=
nil {
log.Fatalf("Running web app: %vn", err)
}
done <- true
}()
<-done
41. TwinUI: one last thing...
● Et les erreurs ? log utilise la sortie d’erreur, donc le terminal
○ Mais nous avons déjà une UI plein écran en cours
42. TwinUI: one last thing...
● tview.TermView implémente io.Writer:
● log.SetOutput(&logger{app: app, Writer: view.Body})