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

Interfaces texte plein écran en Go

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
Tweet

More Decks by Frédéric G. MARAND

Other Decks in Programming

Transcript

  1. TwinUI
    Une interface texte plein écran
    et une interface Web
    dans une même application Go

    View full-size slide

  2. https://osinet.fr/go
    Ingénieur conseil
    en performance logicielle

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  7. Le besoin: pourquoi 2 interfaces ?
    https://gophercises.com/

    View full-size slide

  8. Le besoin: pourquoi 2 interfaces ?

    View full-size slide

  9. Choix
    techniques

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  13. TView: dépendances

    View full-size slide

  14. TView: démo

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  17. TView: les composants

    View full-size slide

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

    View full-size slide

  19. Coder ses
    deux UIs

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  22. Coder ses UI: 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 full-size slide

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

    View full-size slide

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

    View full-size slide

  25. Coder ses UI: web - rendu

    View full-size slide

  26. Coder ses UI: terminal - primitives

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  32. Coder ses UI: web - résultat

    View full-size slide

  33. Composer
    TwinUI

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  40. TwinUI: one last thing...
    ● tview.TermView implémente io.Writer:
    ● log.SetOutput(&logger{app: app, Writer: view.Body})

    View full-size slide

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

    View full-size slide

  42. malt-academy.com

    View full-size slide