Schalalalong (Linux-Magazin, Oktober 2020)

Drei Akkorde kann ja jeder klimpern, aber was ich an Musikern wirklich bewundere, ist, dass sie ihre oft umfangreichen Songtexte alle auswendig dahersingen können. Neulich fiel mir auf, dass ich den Text von "Ich möcht so gern Dave Dudley hörn" der deutschen Band Truck Stop noch nie ganz verstanden hatte, und das Lied ist immerhin 32 Jahre alt. Nach "'N richtig schönen Country-Song" kommt eine Zeile, die ich im Kopf immer mit "nanana-eiiohwei" ergänzt hatte, aber wirklich erst jetzt durch Einsehen des Songtextes auf dem Internet herausfand, dass der Mann "... doch AFN ist weit" singt! AFN, der Ami-Sender in Deutschland, den wir als Kinder hörten! Unglaublich.

Als portables Hilfsmittel, um Songtexte auswendig zu lernen, zeigt das Kommandozeilen-Tool in Go in dieser Ausgabe eine Liste von als .yaml-Dateien hinterlegten Songtexten zur Auswahl an. Wählt der User ein Lied mit "Enter" aus, kann er sich ebenfalls mit "Enter" Zeile für Zeile durch den Text klicken, und im Kopf jedes Mal zu versuchen, den kommenden Satz vorherzusagen.

Retro-Look

Das Tool läuft auf der Kommandozeile, sodass gestresste Sysadmins auch schnell eine Runde spielen können, während in einem anderen Fenster ein langwieriges Kommando läuft. Zwar erinnert die Ästhetik an die 80er-Jahre mit MS-DOS, aber genau wie 80er-Autos wie Scirocco oder Datsun kommt alles wieder. Zum Einsatz kommt wie schon in vorigen Ausgaben das Paket termui, das auf Curses aufsetzt und identisch auf Linux und dem Mac läuft. Nach dem Programmstart liest das Tool alle im Verzeichnis data liegenden .yaml-Dateien ein, die wie Listing 1 gezeigt Felder für artist, song und text definieren. Letzteres ist ein mit dem Pipe-Zeichen eingeleitetes Multi-Line-Feld, das solange fortfährt, bis die Texteinrückung zurückfährt oder die Datei endet.

Listing 1: zztop.yaml

    01 artist: ZZ-Top
    02 song: Sharp Dressed Man
    03 text: |
    04   Clean shirt, new shoes
    05   And I don't know where I am goin' to
    06   Silk suit, black tie,
    07   I don't need a reason why
    08   They come runnin' just as fast as they can
    09   'Cause every girl crazy 'bout a sharp dressed man
    10   ...

Diese Dateien erstellt der User mit Texten der Lyrics-Datenbanken, die bei der Google-Suche eines Titels hochkommen. Die Terminal-UI des kompilierten Programms lyrics zeigt zunächst eine Liste von Titeln und deren Interpreten an (Abbildung 1). Mit den Cursor-Tasten fährt der User dann die Liste ab, und drückt Enter, um den selektierten Titel zu öffnen. Vim-Enthusiasten dürfen auch mit k und k rauf- und runterfahren.

Abbildung 1: Die Listbox des Hauptmenüs stellt Songs zur Auswahl bereit.

Abbildung 2: Das ebenfalls als Listbox implementierte Widget "ltext" gibt schrittweise den Songtext preis.

Zeile für Zeile

Nach der Auswahl wechselt die UI in den Liedtextmodus, zeigt die erste Songzeile an und schreitet bei jedem weiteren Druck der Enter-Taste um eine Zeile weiter (Abbildung 2). Wird der User des Liedes überdrüssig, wechselt die Esc-Taste wieder ins Hauptmenü von Abbildung 1. Das gleiche passiert nach dem Drücken der Taste Enter nach der letzten Liedzeile.

Die Ansichten der zwei verschiedenen Modi definiert Listing 2 als zwei Listboxen der Termui-Widget-Sammlung, die sich mit SetRect() genau dasselbe Rechteck innerhalb des Terminal-Fensters reservieren. Aufgabe der UI ist es später, den aktuellen Modus zu erkennen und die richtige Listbox auf den Schirm zu bringen. Die Abmessungen des aktiven Terminals bestimmt die Funktion TerminalDimensions() in Zeile 29, und die daraus gewonnenen Werte für Breite und Höhe, w und h, nutzt die UI um sich auf ganzer Linie auszubreiten.

Listing 2: lyrics.go

    01 package main
    02 
    03 import (
    04   ui "github.com/gizak/termui/v3"
    05   "github.com/gizak/termui/v3/widgets"
    06   "sort"
    07   "path"
    08   "os"
    09 )
    10 
    11 func main() {
    12   homedir, err := os.UserHomeDir()
    13   if err != nil {
    14       panic(err)
    15   }
    16   songdir := path.Join(homedir, ".lyrics")
    17   lyrics, err := songFinder(songdir)
    18   if err != nil {
    19     panic(err)
    20   }
    21 
    22   if err := ui.Init(); err != nil {
    23     panic(err)
    24   }
    25   defer ui.Close()
    26 
    27   // Listbox displaying songs
    28   lb := widgets.NewList()
    29   items := []string{}
    30   for k := range lyrics {
    31     items = append(items, k)
    32   }
    33   sort.Strings(items)
    34   lb.Title = "Pick a song"
    35   w, h := ui.TerminalDimensions()
    36   lb.SetRect(0, 0, w, h)
    37   lb.Rows = items
    38   lb.SelectedRow = 0
    39   lb.SelectedRowStyle = ui.NewStyle(ui.ColorGreen)
    40 
    41   // Listbox displaying lyrics
    42   ltext := widgets.NewList()
    43   ltextLines := []string{}
    44   ltext.Rows = ltextLines
    45   ltext.SetRect(0, 0, w, h)
    46   ltext.Title = "Text"
    47   ltext.TextStyle.Fg = ui.ColorGreen
    48   ltext.SelectedRowStyle = ui.NewStyle(ui.ColorRed)
    49 
    50   handleUI(lb, ltext, lyrics)
    51 }

Als erste Aktion ruft Listing 2 in Zeile 11 die Funktion songFinder() auf, die die Yaml-Dateien einsammelt und als Datenstruktur in lyrics zurückgibt. Erst danach initialisiert sie die UI mit ui.Init() und bestimmt mit dem nachfolgenden defer-Statement, dass Go den Zirkus wieder einpackt, wenn das Hauptprogramm endet. Das ist wichtig, denn in ein im Grafikmodus verbleibendes Terminal könnte der User nach Programmschluss nichts mehr eintippen.

Strukturen organisieren

Bei der Datenstruktur lyrics handelt es sich um eine Map in Go, die die Yaml-Daten einzelner Songs referenziert, und zwar über einen String-Schlüssel aus einer Kombination aus Interpret und Titel. Sowohl die Datenstruktur einzelner Songs als auch die Map aller Songs definiert später erst Listing 3, aber da alle drei Listings dasselbe Paket definieren, dürfen sie untereinander auf ihre Konstrukte zugreifen.

Die Liste der Einträge im Hauptmenü baut die For-Schleife ab Zeile 24 in einem Array-Slice von Strings zusammen, die es aus den Schlüsseln der Lyrics-Map generiert. Diese liegen unsortiert vor, also bringt die Funktion sort.Strings() aus der Standardbibliothek die Stringliste in Zeile 27 in eine alphabetische Reihenfolge. Es fällt auf, dass Go ein Array-Slice von Strings In-Place sortiert, also tatsächlich den Eingabe-Array modifiziert und nicht etwa einen neuen, sortierten, produziert.

Listing 2 bleibt nun nur noch, bunte Farben für aktive und passive Listbox-Einträge zu definieren und die weitere Verarbeitung von UI-Eingaben und -Darstellung an die Funktion handleUI() aus Listing 4 abzugeben. Kehrt diese zurück, hat der User q gedrückt, und wünscht die Singstunde zu beenden. Das Hauptprogramm langt so am Ende des Codes an, baut wegen der vorher definierten defer-Anweisung die UI ab und beendet sich.

Musik als Yaml

Das Aufstöbern von Yaml-Dateien mit Musiktexten übernimmt die Funktion songFinder() aus Listing 3. Die Konvertierung von Yaml-Daten in Go-Strukturen unterstützt Go von Haus aus, und verlangt vom Programmierer lediglich, Go-Strukturen wie zum Beispiel Lyrics ab Zeile 12 mit Hinweisen zum Yaml-Format zu dekorieren, falls die Schlüssel im Yaml-Text von den Attributnamen der Go-Struktur abweichen. So definiert Lyrics ab Zeile 12 jeweils in umgekehrten Anführungszeichen, dass Schlüssel in Yaml konventionsgemäß mit Kleinbuchstaben beginnen, während öffentlich zugängliche Felder in Go-Strukturen groß geschrieben werden. Die erste Zeile der Lyrics-Struktur definiert so zum Beispiel mit Song string `yaml:"song"`, dass das Feld Song vom Typ String ist, aber in Yaml statt Song nun song heißt.

Listing 3: find.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05   "gopkg.in/yaml.v2"
    06   "io/ioutil"
    07   "os"
    08   "path/filepath"
    09   "regexp"
    10 )
    11 
    12 type Lyrics struct {
    13   Song   string `yaml:"song"`
    14   Artist string `yaml:"artist"`
    15   Text   string `yaml:text`
    16 }
    17 
    18 func songFinder(dir string) (map[string]Lyrics, error) {
    19   lyrics := map[string]Lyrics{}
    20 
    21   err := filepath.Walk(dir,
    22     func(path string, info os.FileInfo, err error) error {
    23       ext := filepath.Ext(path)
    24       rx := regexp.MustCompile(".ya?ml")
    25       if !rx.Match([]byte(ext)) {
    26         return nil
    27       }
    28       song, err := parseSongFile(path)
    29       if err != nil {
    30         panic("Invalid song file: " + path)
    31       }
    32       key := fmt.Sprintf("%s|%s", song.Artist, song.Song)
    33       lyrics[key] = song
    34       return nil
    35     })
    36   return lyrics, err
    37 }
    38 
    39 func parseSongFile(path string) (Lyrics, error) {
    40   l := Lyrics{}
    41 
    42   d, err := ioutil.ReadFile(path)
    43   if err != nil {
    44     return l, err
    45   }
    46   err = yaml.Unmarshal([]byte(d), &l)
    47   if err != nil {
    48     return l, err
    49   }
    50   return l, nil
    51 }

Mit dieser Dekoration wandelt die Funktion Unmarshal() in Zeile 46 von Listing 3 die Yaml-Daten mühelos ins interne Lyrics-Format um, ohne dass der Programmier etwas dazutun müsste. So bleibt songFinder() nur, mit filepath.Walk() alle .yaml-(oder .yml per Regex)-Dateien unterhalb des vorgegebenen Verzeichnisses aufzuschnappen, für jede gefundene Datei parseSongFile() aufzurufen und die Daten in Zeile 33 unter dem Interpreten-Titel-Schlüssel in die Map lyrics einzufüttern. Die Funktion songFinder() liefert per Go-Konventionen das Ergebnis als Variable, sowie einen im Erfolgsfall auf nil gesetzten Error-Code zurück, den sich das Hauptprogramm genau ansieht.

Aktionen durch Events

Die Verwaltung der aus zwei übereinander liegenden, sich gegenseitig verdeckenden Listboxen bestehenden Terminal-UI rechtfertigt eine eigene Funktion handleUI() in Listing 4. Um festzustellen, welche Listbox sichtbar ist, unterhält die Funktion die Variable inFocus und setzt sie entweder auf die Listbox des Hauptmenüs (lb) oder die des Textfensters ltext. Wie in grafischen Oberflächen üblich, startet Zeile 19 eine Endlosschleife, deren einzige Aktion eine select-Anweisung ist, die aus dem Kanal uiEvents events entgegennimmt, die die Termui-Bibliothek dort verbreitet. Tippt der User q ein, kommt durch den Kanal ein Event geschossen, dessen ID-Feld auf "q" gesetzt ist, und der case-Handler in Zeile 23 löst ein return aus, was handleUI() beendet, und demensprechend auch das aufrufende Hauptprogramm.

Aktionen mit Cursor-Tasten spielen nur eine Rolle, falls inFocus anzeigt, dass das Hauptmenü aktiv ist, und die Zeilen 27 und 32 rufen in diesen Fällen die Funktionen ScrollDown() bzw. ScrollUp() des Listbox-Widgets auf, gefolgt vom Befehl ui.Render(lb), der das Widget neu zeichnet, damit der User die Änderung auch optisch mitbekommt.

Liefert der Kanal uiEvents ein Ereignis der Taste Enter, hängt die weitere Verarbeitung davon ab, welcher Modus gerade aktiv ist. Ist der User im Hauptmenü, steht inFocus auf lb, und Zeile 37 holt mit dem numerischen Index des selektierten Listboxeintrags dessen Textdarstellung, also Interpret mit Titel, aus der Listbox. Anschließend setzt der if-Block die Variable inFocus auf ltext, macht also das Songtext-Fenster aktiv. Ein in Zeile 41 neu definierter Scanner schnappt sich den Textstring des Liedtextes aus der Lyrics-Struktur und wird bei zukünftigen Aufrufen seiner Methode Scan() jeweils die nächste Stringzeile zurückliefern. Den Wechsel vom Hauptmenü in den Songtextmodus leitet Zeile 43 schließlich auch für den User sichtbar ein, indem es ui.Render() die Text-Listbox mitgibt.

Listing 4: uihandler.go

    01 package main
    02 
    03 import (
    04   "bufio"
    05   ui "github.com/gizak/termui/v3"
    06   "github.com/gizak/termui/v3/widgets"
    07   "strings"
    08 )
    09 
    10 func handleUI(lb *widgets.List, ltext *widgets.List,
    11   lyrics map[string]Lyrics) {
    12 
    13   ui.Render(lb)
    14   inFocus := lb
    15 
    16   uiEvents := ui.PollEvents()
    17   var scanner *bufio.Scanner
    18 
    19   for {
    20     select {
    21     case e := <-uiEvents:
    22       switch e.ID {
    23       case "q", "<C-c>":
    24         return
    25       case "j", "<Down>":
    26         if inFocus == lb {
    27           lb.ScrollDown()
    28           ui.Render(lb)
    29         }
    30       case "k", "<Up>":
    31         if inFocus == lb {
    32           lb.ScrollUp()
    33           ui.Render(lb)
    34         }
    35       case "<Enter>":
    36         if inFocus == lb {
    37           sel := lb.Rows[lb.SelectedRow]
    38           ltext.Title = sel
    39           inFocus = ltext
    40           text := lyrics[sel].Text
    41           scanner = bufio.NewScanner(
    42             strings.NewReader(text))
    43           ui.Render(ltext)
    44         }
    45         if inFocus == ltext {
    46           morelines := false
    47           for scanner.Scan() {
    48             line := scanner.Text()
    49             if line == "" {
    50               continue
    51             }
    52             ltext.Rows = append(ltext.Rows, line)
    53             morelines = true
    54             ltext.ScrollDown()
    55             ui.Render(ltext)
    56             break
    57           }
    58           if !morelines {
    59             inFocus = lb
    60             ltext.Rows = ltext.Rows[:0]
    61             ui.Render(lb)
    62           }
    63         }
    64       case "<Escape>":
    65         inFocus = lb
    66         ltext.Rows = ltext.Rows[:0]
    67         ui.Render(lb)
    68       }
    69     }
    70   }
    71 }

Hat der User hingegen Enter im Songtext-Modus gedrückt, kommt der if-Block ab Zeile 45 zum Einsatz. Dort holt der Text-Scanner ab Zeile 47 die nächste Liedzeile aus dem Multi-Line-String der Yaml-Daten, verwirft etwaige Leerzeilen, und hängt neu gelesene an das Array-Slice der Listbox ltext an. Ein ScrollDown() markiert die neue Zeile in der Anzeige als selektiertes Element und färbt dessen Text rot ein. Sichtbar wird das Ganze wie immer erst nach einem ui.Render().

Falls der Song zu Ende ist, also keine weiteren Zeilen mehr vom Scanner kommen, tilgt der if-Block ab Zeile 58 die Textzeilen aus der Listbox ltext und setzt den Modus wieder aufs Hauptmenü, indem er inFocus auf lb setzt. Gleiches passiert, falls der User Esc gedrückt hat, dann schaltet der case-Block ab Zeile 64 in gleicher Weise ins Hauptmenü.

Schnell nachgebaut

Um das Binary lyrics zu erzeugen, das das Tool von A bis Z steuert, gilt es lediglich, alle drei Listings miteinander zu kompilieren:

    $ go mod init lyrics
    $ go build lyrics.go find.go uihandler.go

Der vorausgehende Aufruf von go mod initialisiert ein neues Go-Modul, das das nachfolgende go build dazu veranlasst, alle erforderlichen Pakete auf Github abzuholen und ebenfalls einzubinden. Nach dem erfolgreichem Build sucht ein Aufruf von lyrics nach einem Verzeichnis data, in dem .yaml-Dateien mit den Einträgen artist, title, und text liegen. Der Spaß kann beginnen.

AMERKUNG: Ich habe in dieser Ausgabe darauf verzichtet, die Listings zu schmälern, da das Layout in seiner gegenwärtigen Form daraus unlesbare Programmzeilen macht. Am besten also das breite Listingsformat einspaltig setzen.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2020/10/snapshot/

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc