Freie Auswahl (Linux-Magazin, November 2025)

Oft tippe ich ein Kommando in der Shell, zum Beispiel "cp ..." und wünsche als Parameter eine Datei, die irgendwo herumliegt, sagen wir als kürzlich geschossener Screenshot im Verzeichnis Desktop. Nun könnte man ~/Desktop/ tippen und mit Shell-Completion versuchen, die neueste Datei dort zu finden, aber mit den in dieser Ausgabe vorgestellten Dateischnappern geht's noch einfacher. Noch während des Tippens schnalzt auf einen Hotkey hin eine Terminal-UI hoch, mit einer Liste, aus der der Shell-Enthusiast ohne das Keyboard zu verlassen einen Namen auswählt, um nahtlos zur nun vervollständigten Kommandozeile zurückzukehren.

Abbildung 1: Auf der Kommandozeile ...

Abbildung 2: ... kommt ein Selektor hoch ..

Abbildung 3: ... und die gewählte Datei wird eingefügt.

Die erste Version des neuen Tools zeigen die Abbildungen 1-3. In Abbildung 1 hat der User "ls -lh" getippt und möchte als Argument eine Datei aus den Ordnern Desktop oder Downloads anfügen. Ein kurzer Tastendruck auf CTRL-R und die Terminal-UI nach Abbildung 2 poppt hoch. Der User kann nun mit den Cursortasten oder den vim-Kommandos (j runter, k hoch) eine der angezeigten Dateien auswählen und auf Enter hin übernimmt die Shell sie wie in Abbildung 3 an der gegenwärtigen Cursorposition in die Kommandozeile. Nun kann der User weitertippen, weitere Dateien suchen, oder das Kommando mit Enter abschicken. Praktisch!

Listing 1: zsh.sh

    01 precent() {
    02   LBUFFER+=`pick-recent`
    03   LBUFFER+=" "
    04   zle redisplay
    05 }
    06 pphoto() {
    07   LBUFFER+=`pick-photo`
    08   LBUFFER+=" "
    09   zle redisplay
    10 }
    11 zle -N precent
    12 zle -N pphoto
    13 bindkey '^R' precent
    14 bindkey '^P' pphoto

Mit Zauberstab und Widget

Damit nun eine laufende Z-Shell zsh per Tastendruck etwas auf der gerade komponierten Kommandozeile einfügt, muss ein sogenanntes "Widget", ein Kommando für den Kommandozeileneditor, dies explizit festlegen. Listing 1 definiert hierzu zwei Shell-Funktionen. Aus diesen machen die Zeilen 9 und 10 mit dem Kommando zle -N (N für "new") neue Widgets des zsh-Editors. Die Widgets precent und pphoto rufen die Go-Programme pick-recent oder respektive pick-photo auf, und hängen deren Ausgabe an die Variable LBUFFER an. Deren Inhalt spiegelt den bis dato eingetippten Inhalt der aktuellen Kommandozeile wieder, und das += hängt auch noch den per Go-Programm gefundenen Dateinamen am Ende an. Das Kommando zle redisplay innerhalb der Widgets zeigt den aufgefrischten Inhalt von LBUFFER wieder in der aktuellen Zeile an. Dann darf der User weitertippen.

Auf welchen Hotkeydruck hin die Hilfsprogramme anspringen, definieren die bindkey-Anweisungen in den Zeilen 11 und 12. Die Auswahlliste als Terminal-UI kommt danach auf CTRL-R hoch ("recent") und die weiter unten vorgestellte zweite Version mit der grafischen GUI zur Fotoauswahl startet mit CTRL-P.

Nur das Neueste

Die Funktion zum Einholen der neuesten Dateien in voreingestellten Verzeichnissen zeigt Listing 2. findRecentFiles() nimmt einen optionalen Filter entgegen, fehlt dieser, weil das erste Argument auf nil steht, gibt sie alle Dateien zurück, deren letzte Änderung weniger als 24 Stunden zurückliegt. Dazu springt die Funktion Walk aus dem Paket filepath in die eingestellten Verzeichnisse und ruft von dort und von allen darunter liegenden Verzeichnissen für jeden dort gefundenen Eintrag die Callback-Funktion auf. Praktischerweise reicht Walk in den Callback auch noch die Stat-Werte des Eintrags mit hinein, sodass Zeile 23 schnell prüfen kann, ob der gefundene Eintrag den Suchkriterien genügt. Hat der Aufrufer in filter auch noch eine Funktion mitgegeben, ruft Zeile 24 sie mit dem Trefferpfad auf. Kommt ein wahrer Wert zurück, wandert der Treffer ans Ende des Arrays results, den die Funktion am Ende an den Aufrufer zurückgibt.

Listing 2: recent.go

    01 package main
    02 import (
    03   "fmt"
    04   "os"
    05   "path/filepath"
    06   "sort"
    07   "time"
    08 )
    09 func findRecentFiles(filter func(string) bool) ([]string, error) {
    10   home, err := os.UserHomeDir()
    11   if err != nil {
    12     return nil, err
    13   }
    14   dirs := []string{
    15     filepath.Join(home, "Desktop"),
    16     filepath.Join(home, "Downloads"),
    17   }
    18   idbDir, err := newestInTree(filepath.Join(home, "idb"), 3)
    19   if err != nil {
    20     return []string{""}, err
    21   }
    22   dirs = append(dirs, idbDir)
    23   threshold := time.Now().Add(-24 * time.Hour)
    24   var results []string
    25   infoCache := map[string]os.FileInfo{}
    26   for _, dir := range dirs {
    27     filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
    28       if err != nil {
    29         return nil // ignore
    30       }
    31       if !info.IsDir() && info.ModTime().After(threshold) {
    32         if filter == nil || filter(path) {
    33           results = append(results, path)
    34           infoCache[path] = info
    35         }
    36       }
    37       return nil
    38     })
    39   }
    40   sort.Slice(results, func(i, j int) bool {
    41     return infoCache[results[i]].ModTime().After(infoCache[results[j]].ModTime())
    42   })
    43   return results, nil
    44 }
    45 func newestInTree(topDir string, depth int) (string, error) {
    46   dir := topDir
    47   for i := 0; i < depth; i++ {
    48     newest, err := lastModifiedEntry(dir)
    49     if err != nil {
    50       return "", err
    51     }
    52     dir = newest
    53   }
    54   return dir, nil
    55 }
    56 func lastModifiedEntry(dir string) (string, error) {
    57   entries, err := os.ReadDir(dir)
    58   if err != nil {
    59     return "", err
    60   }
    61   var latestPath string
    62   var latestMod time.Time
    63   for _, entry := range entries {
    64     info, err := entry.Info()
    65     if err != nil {
    66       return "", err
    67     }
    68     modTime := info.ModTime()
    69     if modTime.After(latestMod) {
    70       latestMod = modTime
    71       latestPath = filepath.Join(dir, entry.Name())
    72     }
    73   }
    74   if latestPath == "" {
    75     return "", fmt.Errorf("no entries found in %s", dir)
    76   }
    77   return latestPath, nil
    78 }

Aufräumen oder Terminal kaputt

Soweit die Suchfunktion, wie kommt nun die Terminal-UI nach Abbildung 2 auf den Schirm? Mit dem Paket termui auf Github geht das ruck-zuck. Zeile 4 in Listing 3 importiert die praktische Bibliothek unter dem Namen ui ins Programm und der Aufruf von ui.Init() in Zeile 9 lässt das Terminal in den Grafik-Modus springen. Die Ausgabe einer ausgewählten Datei erfolgt später allerdings in Zeile 37 im Normalmodus, also muss Zeile 35 das Terminal mit ui.Close() vorher wieder zurücksetzen. Auch in allen anderen Fällen, die zu einem Programmabbruch führen, sollte ein ui.Close() erfolgen, was die defer-Anweisung in Zeile 18 erledigt, denn sonst steht der User mit einem kaputten Terminal da und kann nicht mehr tippen.

Stünde dort aber nur der Aufruf von ui.Close(), würde dieser insgesamt zweimal erfolgen (explizit in Zeile 35 und implizit durch defer() und das Paket termui hat die unschöne Angewohnheit, sich in dieser Situation aufzuhängen. Also wickelt Zeile 13 die Funktion safeClose() um den Aufruf, die mitzählt, ob das Terminal schon geschlossen wurde, und weitere Aufrufe unterbindet. Die Darstellung und Auswahl der Dateipfade übernimmt das termui-Widget List, das die Textstrings im Feld Rows erwartet.

Listing 3: pick-recent.go

    01 package main
    02 import (
    03   "fmt"
    04   ui "github.com/gizak/termui/v3"
    05   "github.com/gizak/termui/v3/widgets"
    06   "github.com/kballard/go-shellquote"
    07 )
    08 func main() {
    09   items, err := findRecentFiles(nil)
    10   if err != nil {
    11     panic(err)
    12   }
    13   if err := ui.Init(); err != nil {
    14     panic(err)
    15   }
    16   isClosed := false
    17   safeClose := func() {
    18     if !isClosed {
    19       ui.Close()
    20     }
    21   }
    22   defer safeClose()
    23   lb := widgets.NewList()
    24   if len(items) == 0 {
    25     panic("No items found")
    26   }
    27   lb.Title = "Pick a file"
    28   w, h := ui.TerminalDimensions()
    29   lb.SetRect(0, 0, w, h)
    30   lb.Rows = items
    31   lb.SelectedRow = 0
    32   lb.SelectedRowStyle = ui.NewStyle(ui.ColorGreen)
    33   pick := handleUI(lb)
    34   ui.Close()
    35   isClosed = true
    36   fmt.Printf("%s\n", shellquote.Join(pick))
    37 }
    38 func handleUI(lb *widgets.List) string {
    39   ui.Render(lb)
    40   uiEvents := ui.PollEvents()
    41   for {
    42     select {
    43     case e := <-uiEvents:
    44       switch e.ID {
    45       case "q", "<C-c>":
    46         return ""
    47       case "j", "<Down>":
    48         lb.ScrollDown()
    49         ui.Render(lb)
    50       case "k", "<Up>":
    51         lb.ScrollUp()
    52         ui.Render(lb)
    53       case "<Enter>":
    54         return lb.Rows[lb.SelectedRow]
    55       }
    56     }
    57   }
    58 }

Steuerung von Hand

Was passiert, wenn sich der User durch die Einträge der Listbox der Terminal-UI hangelt, definiert handleUI() ab Zeile 39. Nachdem PollEvents() in Zeile 41 den Channel der Terminal-UI für Tastatur-Events angezapft hat, arbeitet die for-Schleife ab 42 ankommende Ereignisse ab, solange, bis der User "Enter" oder "q" drückt.

Bei "j" oder der Cursortaste nach unten ("<Down>"), muss die Listbox mit ScrollDown() den aktuell erleuchteten Eintrag um eins nach unten fahren und falls notwendig den angezeigten Ausschnitt nach unten scrollen. Ein nachfolgendes Render() der ui-Komponente mit der Listbox als Argument frischt den Bildschirm mit dem Ergebnis auf.

Böse Leerzeichen

Auf Linux sieht man's zwar selten, aber manche Dateinamen enthalten Leerzeichen. Wer nun "cp ..." tippt und einen Dateinamen mit Leerzeichen als Vorschlag erhält, wird es zu schätzen wissen, wenn dieser in Anführungszeichen eingekastelt wird, damit das getippte Kommando den Dateinamen als einzelnen Parameter versteht und nicht durch an den Leerzeichen umbrochene Einzelwörter. Der Teufel steckt aber im Detail, denn es reicht nicht, den Namen in Anführungszeichen einzukasteln, auch im Namen eventuell bereits enthaltene Anführungszeichen gehören dann mit Backslashes ausmarkiert. Und dann wären freilch auch noch im Namen bereits enthaltene Backslashes, die ebenfalls ausmaskiert gehören, kurzum: Statt das von Hand zu programmieren, delegieren die Listings 3 (Zeile 37) und 4 (Zeile 18) die Sisyphusarbeit an das Go-Paket shellquote, das auf Github zur Abholung bereitsteht.

Fotos anzeigen

Auch Fotos lassen sich so von der Kommandozeile aus aufstöbern, allerdings weiß wohl kaum jemand, welche Abbildung sich nun zum Beispiel hinter dem Kürzel IMG_3792.JPG verbirgt. Ein Bild sagt mehr als tausend Worte! Eine Terminal-UI kann keine Bilder anzeigen, aber eine Fyne-GUI in einem zusätzlich hochschnellenden Applikationsfenster schon. Und den Pfad eines dort ausgewählten Fotos kann die Shell ebenfalls in den aktuell bearbeiteten Kommandozeilenpuffer einfügen, also frisch ans Werk!

Abbildung 4: Eine Fyne-GUI wählt sogar Fotos aus.

Abbildung 4 zeigt das fertige Programm in Aktion, nachdem der User mit CTRL-P es aus der halb editierten Kommandozeile abgefeuert hat. Die aufschnellende Listbox zeigt neben den Dateinamen gefundener Einträge auch noch ein kleines Thumbnail-Foto, das die Auswahl erleichtern soll. Diese darf außer mit der Maus auch noch mit den Cursortasten im Vi-Modus erfolgen, also fährt j nach unten und k nach oben. Ein Druck auf die Enter-Taste selektiert den hellblau unterlegten Eintrag und das eingehängte zsh-Widget übernimmt den gewählten Dateipfad auf der Kommandozeile.

Das Hauptprogramm in Listing 4 ist relativ kompakt, aber der Schein trügt, denn es verwendet nicht etwa die im Fyne-Framework mitgelieferte Auswahlliste widget.List direkt, sondern ein maßgeschneidertes Widget namens PopList, die ohne die sonst obligatorische Maussteuerung funktioniert, schließlich möchte niemand in der Shell tippen und dabei die Hand nach der Maus ausstrecken. Außerdem beherrscht Fynes List-Widget nur Texteinträge, während das neue Custom-Widget PopList bebilderte Einträge anbietet.

Listing 4: pick-photo.go

    01 package main
    02 import (
    03   "fmt"
    04   "path/filepath"
    05   "strings"
    06   "fyne.io/fyne/v2"
    07   "fyne.io/fyne/v2/app"
    08   "github.com/kballard/go-shellquote"
    09 )
    10 func main() {
    11   items, err := findRecentFiles(isPhoto)
    12   if err != nil {
    13     panic(err)
    14   }
    15   a := app.New()
    16   w := a.NewWindow("Pick A Photo")
    17   list := NewPopList(items, func(text string) {
    18     fmt.Println(shellquote.Join(text))
    19     w.Close()
    20   })
    21   w.SetContent(list)
    22   w.Resize(fyne.NewSize(600, 400))
    23   w.Show()
    24   w.Canvas().Focus(list)
    25   list.Select(list.selectedID)
    26   a.Run()
    27 }
    28 func isPhoto(path string) bool {
    29   ext := strings.ToLower(filepath.Ext(path))
    30   switch ext {
    31   case ".jpg", ".jpeg", ".png", ".gif":
    32     return true
    33   }
    34   return false
    35 }

Funktional erster Klasse

Der Filter isPhoto() ab Zeile 28 wird in Zeile 11 der Funktion findRecentFiles() mitgegeben, das geht, weil Funktionen in Go Bürger erster Klasse sind, man kann also einfach den Namen einer Funktion im Aufruf einer anderen Funktion beipacken, und Letztere ruft diese dann intern auf. Funktionale Programmierung spart oft Zeit und Programmierraum.

Listing 5 zeigt das Custom-Widget PopList. Seine Struktur ab Zeile 7 listet als ersten Eintrag widget.List auf, also erbt es die Felder seines Ziehvaters. Der Aufruf von ExtendBaseWidget() in Zeile 19 importiert auch dessen Funktionen, damit ist die Vererbung perfekt.

Nun muss das List-Widget noch wissen, wie lang die Liste der darzustellenden Einträge ist (Length in Zeile 20), wie es neue Einträge anlegt (CreateItem in Zeile 21) und was zu tun ist, um einen Eintrag darzustellen (UpdateItem ab Zeile 25). Für seine hochspeziellen Einträge nutzt es wiederum ein Custom-Widget vom Typ PhotoRow (Listing 6), das statt einfachen Textstrings bebilderte Auswahlflächen bietet.

Listing 5: poplist.go

    01 package main
    02 import (
    03   "fyne.io/fyne/v2"
    04   "fyne.io/fyne/v2/canvas"
    05   "fyne.io/fyne/v2/widget"
    06 )
    07 type PopList struct {
    08   widget.List
    09   items      []string
    10   selectedID int
    11   OnConfirm  func(text string)
    12 }
    13 func NewPopList(items []string, onConfirm func(string)) *PopList {
    14   l := &PopList{
    15     items:      items,
    16     selectedID: 0,
    17     OnConfirm:  onConfirm,
    18   }
    19   l.ExtendBaseWidget(l)
    20   l.Length = func() int { return len(items) }
    21   l.CreateItem = func() fyne.CanvasObject {
    22     return NewPhotoRow()
    23   }
    24   cache := map[string]*canvas.Image{}
    25   l.UpdateItem = func(i widget.ListItemID, o fyne.CanvasObject) {
    26     if _, ok := cache[items[i]]; !ok {
    27       cache[items[i]] = o.(*PhotoRow).MkImage(items[i])
    28     }
    29     o.(*PhotoRow).SetRow(items[i], cache[items[i]])
    30   }
    31   return l
    32 }
    33 func (l *PopList) TypedKey(k *fyne.KeyEvent) {
    34   switch k.Name {
    35   case "K", fyne.KeyUp:
    36     if l.selectedID > 0 {
    37       l.selectedID--
    38       l.Select(l.selectedID)
    39       l.ScrollTo(l.selectedID)
    40     }
    41   case "J", fyne.KeyDown:
    42     if l.selectedID < len(l.items)-1 {
    43       l.selectedID++
    44       l.Select(l.selectedID)
    45       l.ScrollTo(l.selectedID)
    46     }
    47   case fyne.KeyEnter, fyne.KeyReturn:
    48     if l.OnConfirm != nil {
    49       l.OnConfirm(l.items[l.selectedID])
    50     }
    51   }
    52 }

Keine unnötige Arbeit

Nun stellt sich aber heraus, dass das List-Widget in Fyne allzu sorglos mit Aufrufen an UpdateItem() umgeht, bei jedem Mucks des Users werden gleich alle angezeigten Einträge aufgefrischt. Dies mag bei Textstrings angehen, ist aber bei Bildern unbrauchbar, denn die muss der Renderer erst vollständig von der Platte einlesen und anschließend auf Thumbnail-Größe herunterskalieren. Das Ergebnis wäre eine Liste, die die CPU an den Rand des Wahnsinns brächte. Einmal eingelesene Bilder ändern sich aber gottlob nicht und so kann der Cache ab Zeile 24 das zu einem Bild-Pfad passende Thumbnail zwischenspeichern und Zeile 29 innerhalb von UpdateItem() blitzschnell auf die vorgefertigten Bilddaten zugreifen, die ein vorheriger Aufruf von MkImage() in Zeile 27 dort abgelegt hat.

Die Tastensteuerung durch die Cursortasten oder die vi-Mappings "j" und "k" übernimmt die Funktion TypedKey() ab Zeile 33. Die Variable selectedID führt Buch darüber, wo der Cursor gerade steht und Select() und ScrollTo() passen die Darstellung entsprechend an. Tippt der User auf Enter, ruft das Widget den vordefinierten Callback unter OnConfirm() auf, der im vorliegenden Fall den Pfad zum angewählten Foto auf der Standardausgabe druckt.

Fotos lesen und skalieren

Das Custom-Widget PhotoRow in Listing 6 implementiert nun die Einzeleinträge der Liste. Wie schon die spezielle Listbox in Listing 5 definiert es eine vom Basis-Widget abgeleitete Struktur ab Zeile 12 und ruft zur Vererbung der Basisfunktionen im Konstruktur ExtendBaseWidget() auf.

Listing 6: photorow.go

    01 package main
    02 import (
    03   "fyne.io/fyne/v2"
    04   "fyne.io/fyne/v2/canvas"
    05   "fyne.io/fyne/v2/container"
    06   "fyne.io/fyne/v2/layout"
    07   "fyne.io/fyne/v2/theme"
    08   "fyne.io/fyne/v2/widget"
    09   "github.com/disintegration/imaging"
    10 )
    11 const ThumbSize = 100
    12 type PhotoRow struct {
    13   widget.BaseWidget
    14   image *canvas.Image
    15   text  *widget.Label
    16 }
    17 func NewPhotoRow() *PhotoRow {
    18   pr := &PhotoRow{
    19     image: canvas.NewImageFromResource(nil),
    20     text:  widget.NewLabel(""),
    21   }
    22   pr.image.FillMode = canvas.ImageFillContain
    23   pr.ExtendBaseWidget(pr)
    24   return pr
    25 }
    26 func (pr *PhotoRow) MkImage(path string) *canvas.Image {
    27   img := canvas.Image{}
    28   iimg, err := imaging.Open(path, imaging.AutoOrientation(true))
    29   if err != nil {
    30     img.Resource = theme.WarningIcon()
    31   } else {
    32     thumbnail := imaging.Thumbnail(iimg,
    33       int(ThumbSize), int(ThumbSize), imaging.Lanczos)
    34     img.Image = thumbnail
    35   }
    36   return &img
    37 }
    38 func (pr *PhotoRow) SetRow(path string, img *canvas.Image) {
    39   pr.text.SetText(path)
    40   pr.image.Image = img.Image
    41 }
    42 func (pr *PhotoRow) CreateRenderer() fyne.WidgetRenderer {
    43   thumbBox := container.NewGridWrap(
    44     fyne.NewSize(ThumbSize, ThumbSize), pr.image)
    45   centered := container.NewVBox(
    46     layout.NewSpacer(),
    47     pr.text,
    48     layout.NewSpacer(),
    49   )
    50   row := container.NewHBox(container.NewPadded(thumbBox), centered)
    51   return widget.NewSimpleRenderer(row)
    52 }

Die Daumennagelgröße für die Thumbnails legt Zeile 11 auf 100 Pixel im Karree fest. Zur effizienten Skalierung von Fotodateien, die oft mehrere Megabytes groß sind, nutzt Listing 6 das Paket imaging, das auf Github liegt. Das Flag AutoOrientation in Zeile 28 bestimmt, dass Fotos entsprechend ihrer Exif-Header nach dem Einlesen rotiert werden. Fyne würde sie sonst auf der Seite liegend oder auf dem Kopf stehend darstellen, falls das Handy wieder mal die Rotation nur im Header angegeben hat und das Bild nicht ordnungsgemäß transformiert hat.

Abbildung 5: Containerschachtelung zur zentrierten Darstellung einer Listenzeile.

Container in Containern

Zur Darstellung ruft das Custom-Widget nach den Fyne-Vorgaben seine Funktion CreateRenderer() auf, die das Foto erst in einen Container verpackt, der es entsprechend des Listenformats streckt. Der zugehörige Texteintrag, also der Pfad zum Bild as String, sollte schön vertical mittig zentriert neben dem Foto stehen, also packen ihn die Zeilen 46 bis 48 zwischen zwei Spacer-Widgets, und stopfen das Trio in einen VBox-Container. Die Combo aus Foto und String wandert dann mit NewHBox in einen Container, der sie horizontal aufreiht. Die Standardfunktion NewSimpleRenderer() für Fyne-Widgets macht aus dem Gesamtcontainer einen Renderer, auf dem das Fyne-Framework besteht, damit es das Custom-Widget adoptiert.

Listing 7: build.sh

    1 $ go mod init shell
    2 $ go mod tidy
    3 $ go build pick-recent.go recent.go
    4 $ go build pick-photo.go poplist.go recent.go photorow.go

Die Binaries aus den Listings erzeugt wie immer der Dreisprung aus Listing 7. Dabei holt go mod tidy allen eingebundenen Code von Github ab. Anschließend sind die Binaries im Suchpfad der Shell so zu platzieren, dass ein Aufruf von der Kommandozeile sie findet. Dann noch source zsh.sh aus Listing 1 aufgerufen (am besten über die Init-Datei in .zshrc) und die aktive zsh ist mit der zusätzlichen Funktionalität ausgestattet. Dies klappt sowohl im (traditionell voreingestellten) Emacs-Modus als auch in dem von mir (per set -o vi) eingestellen Vi-Modus der Shell. Sowohl normale Dateien (mit CTRL-R) und Fotodateien (mit CTRL-P) lassen sich so schnell auswählen und auf der aktuellen Kommandozeile einfügen. Wer statt kürzlich aktualisierter Files alle finden oder in zusätzlichen Ordnern suchen will, modifiziert die Filter in den Listings 2 und 5 entsprechend.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2025/11/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 22:

Unknown directive: =desc