Share
Embed
Interfaces texte plein écran en Go
Comment créer une application qui combine une interface utilisateur web et une interface utilisateur en mode texte plein écran ? C'est possible avec quelques précautions, et la présentation montre comment écrire une application plein écran avec rivo/tview et l'intégrer avec une interface web.
Le code est sur https://github.com/fgm/twinui
Le site de mon livre sur Go est https://osinet.fr/go
Frédéric G. MARAND
July 03, 2020
Transcript
45 slides
-
TwinUI Une interface texte plein écran et une interface Web
dans une même application Go -
3 2 TABLE OF CONTENTS Le besoin Pourquoi 2 interfaces
? Choix techniques Quels modules ? Pourquoi ? TView Comment TView est-il conçu? 1 5 4 Code Comment coder son UI texte ? Combinaison Comment assembler les 2 UIs ? -
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 -
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) -
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 -
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 -
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 -
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 ! -
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. -
TView: composants package main import "github.com/rivo/tview" func main() { tv
:= tview.NewButton("Hello, world!") tview.NewApplication().SetRoot(tv, true).Run() } -
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() -
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 -
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) } -
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> -
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 } -
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: %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) } } -
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 -
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: %v\n", err) } app, view := initUI(story) view.Handle(`intro`) if err := app.Run(); err != nil { log.Fatalf("Running app: %v\n", err) } } -
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 } -
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: %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) } } -
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 } -
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 ! -
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 -
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-chang es-with-hexagonal-architecture-b315ec967 749 -
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: %v\n", err) } defer story.Close() // Initialize the twin UIs. app := initTextUI(story) router := initWebUI(story) .... -
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: %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 } -
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 -
TwinUI: one last thing... • tview.TermView implémente io.Writer: • log.SetOutput(&logger{app:
app, Writer: view.Body})