Entkalker (Linux-Magazin, November 2026)

Selten abgerufene Fakten trichtert sich Mike Schilli regelmäßig mit Hilfe einer Go-App ein, damit er sie auf Kommando abrufen kann.

In dem Buch "Algorithms to Live By" ([2]) las ich neulich, dass Vergesslichkeit keineswegs der Verkalkung mit zunehmendem Alter geschuldet sei, sondern damit zusammenhinge, dass das Gehirn im Laufe des Lebens so viele Daten angestaut hat, dass nur die zuletzt abgerufenen leicht zugänglich seien.

Na, wenn das so ist, dachte ich, dann hilft es wohl, Daten, die man immer wieder vergisst, wie zum Beispiel die Nummer des Mobiltelefons der Ehefrau oder bestimmte Passwörter, routinemäßig in einem Quiz abzurufen. Das spült die Antworten, der Theorie folgend, an den äußeren Rand des Gehirns, wo sie dann bei spontanem Abruf parat liegen. Gesagt, getan! Die in dieser Ausgabe vorgestellte Terminal-Applikation liest Fragen mit den dazugehörigen Antworten aus einer vom User gepflegten YAML-Datei, und präsentiert sie als Karteikärtchen. Gleichzeitig merkt sie sich, welche Karten sie wann präsentiert hat, und zeigt in einem Monatskalender an, wie oft dies in den vergangenen drei Monaten passiert ist.

Abbildung 1: Die TUI in Aktion, Frage ...

Abbildung 2: ... und auf Tastendruck die Antwort.

Zufällig aber zielorientiert

Sie arbeitet sich in zufälliger Reihenfolge durch den Stapel, stellt aber sicher, dass sie alle Karteikarten abarbeitet, bevor der Reigen von Neuem beginnt. Unten in der Statuszeile kann der User ablesen, wie viele Fragen schon abgearbeitet wurden, sowie eine kurze Anleitung, welche Tasten zur weiteren Bedienung zu drücken sind. Oben steht wie in Abbildung 1 gezeigt die Frage zu dem Fakt, den der User rezitiert, und zur Kontrolle auf Enter drückt, um die Antwort zu verifizieren (Abbildung 2). Ob die Antwort gilt, muss der User selbst entscheiden, wer mag, kann auch schummeln, aber der Lerneffekt bleibt aus.

Historische Abrufe speichert die App ebenfalls, die Kalender unten in Abbildung 1 zeigen mit grünen Markern, dass die Frage bereits am 17. Juni und am 4. Juli gestellt wurde. Nach dem Betätigen der Enter-Taste erscheint in Abbildung 2 auch ein Marker am 28. August, dem Tag, an dem das Manuskript dieser Ausgabe durch die Endkontrolle der Perlmeister-Studios ging.

Abbildung 3: Die YAML-Datei pflegt der User mit Fragen und Antworten.

Konfiguration auf Kommando

Die YAML-Datei cards.yaml (Abbildung 3) enthält die Fragen und Antworten für das Quiz, jeweils als Array-Element mit den Schlüsseln "Q" für die Frage ("Question") und "A" für die Antwort ("Answer"). Wahlweise akzeptiert die App mit der Kommandozeilenoption --cards auch den Pfad zu einer anderen YAML-Datei. Den Status, also den Stand der Dinge betreffend die in einer Runde bereits gezeigten Karten, sowie die historischen Daten, also die Tage, an denen eine Karte bereits gezeigt wurde, merkt sich das Programm in der Datei .memory.json (Abbildung 4) oder wahlweise einer Datei unter dem per --state hereingereichten Pfad. So lässt sich das Programm jederzeit unterbrechen, und das Quiz geht mit der nächsten Frage aus dem Set weiter, solange, bis alle Fragen beantwortet sind, dann geht's wieder von vorne los, mit einem neu gemischten Stapel.

Die Darstellung der Terminal-UI erfolgt mit dem Paket bubbletea, zusammen mit dem Styling-Paket lipgloss, beide brandaktuell in Version 2 vorliegend. Dabei fährt lipgloss mit besonderen Schmankerln auf, wie Textboxen mit Rahmen, dessen Farbe sich graduell ändert.

Listing 1: run.go

    01 package main
    02 import (
    03   "flag"
    04   "slices"
    05   "time"
    06   tea "charm.land/bubbletea/v2"
    07 )
    08 type model struct {
    09   allCards, cards []card
    10   state           *state
    11   statePath       string
    12   current         card
    13   cardFront       bool
    14   width, height   int
    15   err             error
    16 }
    17 func main() {
    18   cardsPath := flag.String("cards", "cards.yaml", "Q/A file")
    19   statePath := flag.String("state", ".memory.json", "state file")
    20   flag.Parse()
    21   if err := run(*cardsPath, *statePath); err != nil {
    22     panic(err)
    23   }
    24 }
    25 func run(cardsPath, statePath string) error {
    26   cards, err := loadCards(cardsPath)
    27   if err != nil {
    28     return err
    29   }
    30   allCards := slices.Clone(cards)
    31   p, err := loadState(statePath, &cards)
    32   if err != nil {
    33     return err
    34   }
    35   m := model{allCards: allCards, cards: cards, state: p,
    36     statePath: statePath, cardFront: true}
    37   if err := m.selectCard(); err != nil {
    38     return err
    39   }
    40   _, err = tea.NewProgram(m).Run()
    41   return err
    42 }
    43 func (m model) Init() tea.Cmd { return nil }
    44 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    45   switch msg := msg.(type) {
    46   case tea.WindowSizeMsg:
    47     m.width, m.height = msg.Width, msg.Height
    48   case tea.KeyPressMsg:
    49     switch msg.String() {
    50     case "q", "ctrl+c":
    51       return m, tea.Quit
    52     case "enter":
    53       if m.cardFront {
    54         m.err = m.reveal()
    55       } else {
    56         m.cardFront = true
    57         m.err = m.selectCard()
    58       }
    59     }
    60   }
    61   return m, nil
    62 }
    63 func (m *model) reveal() error {
    64   m.cardFront = false
    65   id := cardID(m.current)
    66   day := time.Now().Format("2006-01-02")
    67   m.state.Session[id] = true
    68   if m.state.Hist[id] == nil {
    69     m.state.Hist[id] = dateSet{}
    70   }
    71   m.state.Hist[id][day] = true
    72   return saveState(m.statePath, m.state)
    73 }
    74 func (m *model) selectCard() error {
    75   if len(m.cards) == 0 {
    76     m.state.Session = map[string]bool{}
    77     m.cards = slices.Clone(m.allCards)
    78     shuffle(m.cards)
    79   }
    80   last := len(m.cards) - 1
    81   m.current = m.cards[last]
    82   m.cards = m.cards[:last]
    83   return saveState(m.statePath, m.state)
    84 }

Listing 1 fragt hierzu in den Zeilen 18 und 19 mit dem flag-Paket die Kommandozeilenoptionen ab und ruft in Zeile 21 mit den beiden Pfaden die Funktion run() ab Zeile 25 auf, die den Ablauf der App von Anfang bis Ende steuert. Das Framework bubbletea zaubert die Terminal-UI auf den Schirm. Es arbeitet mit dem internen Datenmodell model (ab Zeile 8) und frischt die Anzeige später mit der Funktion View() aus Listing 3 auf.

Ereignisreicher Blubbertee

Das bubbletea-Framework ruft nach Run() in Zeile 40 nach Konvention zunächst die Funktion Init() (ab Zeile 43) auf, und immer wenn etwas passiert, wie zum Beispiel dass der User eine Taste drückt oder ein Timer abläuft, springt es Update() ab Zeile 44 an. Die in Terminal-UIs übliche Eventschleife versteckt bubbletea also vor der Applikation und springt stattdessen bei Events vordefinierte Funktionen an.

Fehler behandeln

Tritt in einer Terminal-App ein fataler Fehler auf, gilt es, diesen anzuzeigen und anschließend die grafische Oberfläche sauber zusammenzufalten. Das passiert normalerweise, indem Update() neben dem Model das Kommando tea.Quit an die versteckte Eventschleife zurückreicht. Tritt aber zum Beispiel im View()-Endpoint ein Fehler auf, sollte die App diesen im Model abspeichern und im zurückgereichten String ablegen, damit sie auf dem Bildschirm erscheint und den User alarmiert. Kommt die Funktion Update() dran, prüft sie, ob das Feld err im Model gesetzt ist, um daraufhin das Kommando tea.Quit zurückzureichen, auf dass die Eventschleife die Zelte abbricht und den Prozess beendet.

Verdecktes Blatt

Die App hantiert mit zwei Array-Slices, die die Quizkarten enthalten, cards für die im aktuellen Durchgang zu zeigenden Karten, und allCards mit den ebenfalls gut gemischten Karten aus der YAML-Datei. Zeigt die App eine Karte an, löscht sie sie aus dem ersten Stapel, damit es nicht zu Wiederholungen kommt. Da die App in einer Status-Datei Buch darüber führt, wann welche Karte gezeigt wurde und sich dort auch den Ablauf der letzten Serie merkt, kommen nach einem Neustart des Programms ebenfalls immer erst bislang unbearbeitete Karten an die Reihe, bevor der Reigen mit einer neuen Serie beginnt.

Den globalen Status merkt sich die App im Feld state des ab Zeile 8 definierten Datenmodells model. Das Modell ist integraler Bestandteil der Architektur, denn wenn der bubbletea-Kern die vordefinierten Endpunkte Init(), Update() und View() (Letzteres als Teil der UI in Listing 3) anspringt, erhalten diese das Modell als Teil ihrer Signatur. Die Eventschleife wartet auf Ereignisse und springt den Update()-Endpunkt an, falls der User eine Taste drückt ("Enter" für die Auflösung der Frage oder "Q" zum Programmabbruch) oder die Größe des Terminalfensters mit der Maus verstellt. Nachdem Update() das Ereignis verarbeitet hat, reicht es das eventuell aufgefrischte Modell samt einem Kommando an den bubbletea-Kern zurück. Ist letzteres nil, geht alles seinen behördlichen Gang weiter, signalisiert Update() aber mit tea.Quit, dass nun Schicht im Schacht ist, wird der Kern sich und das Programm ordnungsgemäß herunterfahren.

Abbildung 4: Die App merkt sich in einer JSON-Datei, wann welche Karteikarte präsentiert wurde.

Im Maschinenraum

Listing 2 führt über den aktuellen Stand des Memory-Spiels Buch und speichert sowohl den Stand der aktuellen Session als auch historische Daten in der Struktur state ab Zeile 16. Deren aktuellen Inhalt holt loadState() beim Programmstart aus der Datei .memory.json und speichert aufgefrischte Werte auch regelmäßig dort ab. Die Funktion os.WriteFile() schreibt den aktualisierten JSON-Inhalt jeweils vollständig in die Datei.

Jede Karteikarte bekommt über die Funktion cardID eine eindeutige Kennung, einen 64 Zeichen langen Hex-String, den die kryptografisch sichere Hash-Funktion Sum256() aus dem Paket crypto/sha256 aus der Kombination aus Frage- und Antworttext erzeugt. Im Feld Session der state-Struktur liegt nun eine Hash-Map, die diesen IDs jeweils den Wert true zuweist, falls die zugehörige Karte in der aktuellen Runde bereits gezeigt wurde. Wird das Spiel später neu gestartet, liest loadState() den alten Status ein und wirft mit DeleteFunc() aus dem Paket slices vorher schon präsentierte Karten aus dem Gesamtkorb der Fragen der Runde. So gewährleistet die App Kontinuität, auch wenn das Entkalkerprogramm zwischenzeitlich unterbrochen wurde.

Kämen die Fragen immer in der gleichen Reihenfolge, würden sich User schnell langweilen. Deshalb wirbelt die Funktion shuffle() ab Zeile 33 das Geschehen mit einem Pseudozufallsgenerator durcheinander. Zum Einsatz kommt hier die Funktion Shuffle() aus dem Standardpaket rand, die mit dem Fisher-Yates-Verfahren den Array-Slice cards sehr effektiv "in-place" mischt, also ohne Elemente temporär zu kopieren.

Listing 2: store.go

    01 package main
    02 import (
    03   "crypto/sha256"
    04   "encoding/hex"
    05   "encoding/json"
    06   "errors"
    07   "math/rand/v2"
    08   "os"
    09   "slices"
    10   "gopkg.in/yaml.v3"
    11 )
    12 type card struct {
    13   Q string `yaml:"Q"`
    14   A string `yaml:"A"`
    15 }
    16 type state struct {
    17   Session map[string]bool    `json:"session"`
    18   Hist    map[string]dateSet `json:"hist"`
    19 }
    20 type dateSet map[string]bool
    21 func loadCards(path string) ([]card, error) {
    22   data, err := os.ReadFile(path)
    23   if err != nil {
    24     return nil, err
    25   }
    26   var cards []card
    27   if err := yaml.Unmarshal(data, &cards); err != nil {
    28     return nil, err
    29   }
    30   shuffle(cards)
    31   return cards, nil
    32 }
    33 func shuffle(cards []card) {
    34   rand.Shuffle(len(cards), func(i, j int) { cards[i], cards[j] = cards[j], cards[i] })
    35 }
    36 func loadState(path string, cards *[]card) (*state, error) {
    37   p := &state{Session: map[string]bool{}, Hist: map[string]dateSet{}}
    38   data, err := os.ReadFile(path)
    39   if errors.Is(err, os.ErrNotExist) {
    40     return p, nil
    41   }
    42   if err != nil {
    43     return nil, err
    44   }
    45   if err := json.Unmarshal(data, p); err != nil {
    46     return nil, err
    47   }
    48   if cards != nil {
    49     *cards = slices.DeleteFunc(*cards, func(c card) bool {
    50       return p.Session[cardID(c)]
    51     })
    52   }
    53   return p, nil
    54 }
    55 func saveState(path string, p *state) error {
    56   data, err := json.MarshalIndent(p, "", "  ")
    57   if err != nil {
    58     return err
    59   }
    60   return os.WriteFile(path, append(data, '\n'), 0o600)
    61 }
    62 func cardID(c card) string {
    63   sum := sha256.Sum256([]byte(c.Q + "\x00" + c.A))
    64   return hex.EncodeToString(sum[:])
    65 }

Aufbrezel-Maxxing

So weit zum dynamischen Ablauf der App, nun zum Erscheinungsbild. Das Paket lipgloss bietet in Version 2 ganz erstaunliches Styling und Listing 3 zieht alle Register wie runde Rähmchen mit graduellen Farbübergängen, man glaubt gar nicht, was in einem Textterminal alles machbar ist. Mit charmtone holt der Code auch noch eine fein abgestimmte Farbpalette heran, mit Farben wie Cherry oder Guac (ähnlich dem aus der mexikanischen Küche bekannte grüne Avocadomus "Guacamole").

Listing 3: ui.go

    01 package main
    02 import (
    03   "fmt"
    04   "strings"
    05   "time"
    06   tea "charm.land/bubbletea/v2"
    07   lg "charm.land/lipgloss/v2"
    08   ch "github.com/charmbracelet/x/exp/charmtone"
    09   textcal "github.com/mschilli/go-textcal"
    10 )
    11 var (
    12   frame = lg.NewStyle().Border(lg.RoundedBorder()).
    13     BorderForegroundBlend(ch.Cherry, ch.Guac).
    14     Foreground(ch.Butter).Background(ch.Pepper)
    15   answerFrame = frame.Copy().Foreground(ch.Guac)
    16   statusStyle = lg.NewStyle().Background(ch.Charple).
    17       Foreground(ch.Butter).Padding(0, 1)
    18   markedStyle = lg.NewStyle().Background(ch.Guac).Foreground(ch.Iron).Bold(true)
    19   errorStyle  = frame.Copy().Foreground(ch.Cherry)
    20   screenStyle = lg.NewStyle().Background(ch.Pepper)
    21 )
    22 func (m model) View() tea.View {
    23   content := screenStyle.Width(m.width).Height(m.height).Render(m.view())
    24   return tea.View{Content: content, AltScreen: true, BackgroundColor: ch.Pepper}
    25 }
    26 func (m model) view() string {
    27   if m.err != nil {
    28     return errorStyle.Render("Error: " + m.err.Error() + "\n\nQ: quit")
    29   }
    30   content, action, style := m.current.Q, "reveal answer", frame
    31   label := statusStyle.Render("QUESTION")
    32   if !m.cardFront {
    33     content, action, style = m.current.A, "next question", answerFrame
    34     label = markedStyle.Render("ANSWER")
    35   }
    36   content = lg.JoinVertical(lg.Center, label, "", content)
    37   card := style.Width(m.width - 4).Height(m.height - 11).
    38     Align(lg.Center).AlignVertical(lg.Center).Render(content)
    39   bottom := calendars(m.state.Hist[cardID(m.current)])
    40   status := statusStyle.Render(
    41     fmt.Sprintf("%d/%d answered  •  Enter: %s  •  Q: quit",
    42       len(m.state.Session), len(m.allCards), action))
    43   return lg.JoinVertical(lg.Left, card, bottom, status)
    44 }
    45 func calendars(marked dateSet) string {
    46   const width, height = 23, 10
    47   cals := make([]string, 3)
    48   now := time.Now()
    49   style := frame.Width(width).Height(height)
    50   for i := range cals {
    51     cals[i] = style.Render(calendar(now.AddDate(0, i-2, 0), marked))
    52   }
    53   return lg.JoinHorizontal(lg.Top, cals...)
    54 }
    55 func calendar(month time.Time, marked dateSet) string {
    56   month = month.AddDate(0, 0, 1-month.Day())
    57   cal := textcal.New(month)
    58   for date := month; date.Month() == month.Month(); date = date.AddDate(0, 0, 1) {
    59     if marked[date.Format("2006-01-02")] {
    60       cal.UseFormatter(date.Day(), cal.ReverseFormatter())
    61     }
    62   }
    63   return strings.TrimRight(cal.String(), " \n")
    64 }

Die Funktion View() ab Zeile 22 ist ebenfalls ein wohldefinierter Einsprungspunkt des bubbletea-Kerns. Sie erzeugt gemäß den Richtlinien des Frameworks einen String mit dem aufgefrischten Bildschirminhalt der TUI. Dazu ruft sie die Hilfsfunktion view() (kleingeschrieben) ab Zeile 26 auf. Liegt zu diesem Zeitpunkt ein globaler Applikationsfehler vor, wie zum Beispiel eine korrupte YAML-Datei, die der User schludrig editiert hat, zeigt die App die Ursache an und fordert mit "Q: quit" dazu auf, die Q-Taste zu drücken um die UI herunterzufahren.

Liegt alles im grünen Bereich, zeigt die UI erst oben im Fenster die aktuelle Karteikarte an, und zwar deren Vorderseite, da cardFront im Modell noch auf true gesetzt ist. Unter der Karte liegen, horizontal aufgereiht, drei Kalender-Blöcke, und darunter wiederum die Statuszeile mit dem aktuellen Spielstand (Abbildung 5). Die unteren Textblöcke sind statisch, nur die oben liegende Karteikarte breitet sich über den verbleibenden Rest des Terminalfensters aus, sowohl vertikal als auch horizontal.

Weniger Komfort als Fyne

Nun verfügt lipgloss über keinen komfortablen Layout-Manager, aber die Funktionen JoinVertical() und JoinHorizontal() helfen dabei, TUI-Inhalte zu arrangieren. Dabei muss der Code explizit vorgeben, wie breit zum Beispiel die Zwischenräume zwischen den Textblöcken ausfallen sollen. Ein GUI-Framework wie Fyne bietet da deutlich mehr Komfort wie dynamische Grid- oder Border-Layouts.

Shell-User kennen das Kalenderformat von der Utility cal. In der Go-Welt erzeugt das Paket go-textcal auf Github die ASCII-Strings mit optional zur Markierung erleuchteten Tagen. Die Funktion calendar() ab Zeile 55 gibt zu einem Kalendarmonat einen Textstring mit dem Kalender zurück, in dem die in der Hash-Map marked gesetzten Tage mit ANSIColor-Strings angestrichen sind.

Abbildung 5: Lipgloss arrangiert die Textblöcke der TUI nach diesem Layout.

Trainieren, trainieren

Der in Go übliche Dreisprung

$ go mod init memory && go mod tidy && go build

erzeugt aus den drei Sourcen dieser Ausgabe im aktuellen Verzeichnis wegen des Modulnamens memory ein Binary mit diesem Namen. Dann noch schnell mit einem Editor eine YAML-Datei mit Fragen und Antworten erzeugt, und dann geht's los, ./memory abfeuern und trainieren, trainieren, trainieren!

Infos

[1]

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

[2]

Brian Christian, Tom Griffiths, "Algorithms to Live By", https://www.amazon.com//dp/B015CKNWJI

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.