Aus Linux-Magazin 11/2025

Die Z-Shell und zwei Go-Programme helfen beim Tippen

© Dilok Klaisataporn / 123RF.com

Beim Tippen in der Shell wünscht sich Mike Schilli eine Funktion, die eine TUI bereitstellt, ihm neue Dateien zuspielt und sie in seine Kommandos einfließen lässt. Die Z-Shell und zwei Go-Programme helfen.

Oft tippt man ein Kommando in der Shell, zum Beispiel »cp«, und will als Parameter eine Datei verwenden, die irgendwo herumliegt, sagen wir einen kürzlich geschossenen Screenshot aus dem Verzeichnis »Desktop/«. Nun könnte man »~/Desktop/« tippen und versuchen, per Shell Completion dort die neueste Datei zu finden. Aber mit den in dieser Ausgabe vorgestellten Dateischnappern geht es noch einfacher. Noch während des Tippens schnalzt auf einen Hotkey hin eine zeichenorientierte Benutzerschnittstelle (Terminal User Interface, TUI) hoch. Sie enthält eine 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 soll eine Datei angefügt werden.

Abbildung 1: Auf der Kommandozeile soll eine Datei angefügt werden.

Abbildung 2: Ein Druck auf <span class="key-combo">[Strg]+[R]</span> l&auml;sst die Terminal-UI erscheinen.

Abbildung 2: Ein Druck auf [Strg]+[R] lässt die Terminal-UI erscheinen.

Abbildung 3: Die Auswahl der Datei erfolgt via Cursor oder Vim-Kommandos.

Abbildung 3: Die Auswahl der Datei erfolgt via Cursor oder Vim-Kommandos.

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 Druck auf [Strg]+[R] lässt die TUI aus Abbildung 2 hochpoppen. Der User kann nun mit den Cursortasten oder den Vim-Kommandos ([J]+ nach unten, [K]+ nach oben) eine der angezeigten Dateien auswählen. Nach einem Druck auf die Eingabetaste übernimmt die Shell sie wie in Abbildung 3 an der gegenwärtigen Position der Schreibmarke in die Kommandozeile. Nun kann der Benutzer weitertippen, andere Dateien suchen oder das Kommando mit [Eingabe] abschicken.

Mit Zauberstab und Widget

Damit nun eine laufende Z-Shell per Tastendruck etwas auf der gerade komponierten Kommandozeile einfügt, muss ein Widget, also ein Kommando für den Kommandozeileneditor, das explizit festlegen.

Listing 1 definiert dazu zwei Shell-Funktionen. Daraus 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« 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 wider. Das »+=« hängt zudem 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 Tastendruck hin die Hilfsprogramme anspringen, definieren die »bindkey«-Anweisungen in den Zeilen 11 und 12. Die TUI-Auswahlliste kommt per [Strg]+[R] hoch (“recent”), die weiter unten vorgestellte zweite Version mit einer GUI zur Fotoauswahl startet via [Strg]+[P].

Listing 1

zsh.sh

precent() {
  LBUFFER+=`pick-recent`
  zle redisplay
}
pphoto() {
  LBUFFER+=`pick-photo`
  zle redisplay
}
zle -N precent
zle -N pphoto
bindkey '^R' precent
bindkey '^P' pphoto

Nur das Neueste

Die Funktion zum Einholen der neuesten Dateien in voreingestellten Verzeichnissen zeigt Listing 2. Die Funktion »findRecentFiles()« nimmt einen optionalen Filter entgegen. Fehlt er, 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 Ordnern 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. So kann Zeile 23 schnell prüfen, 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«, das die Funktion am Ende an den Aufrufer zurückgibt.

Listing 2

recent.go

package main
import (
  "os"
  "path/filepath"
  "time"
)
func findRecentFiles(filter func(string) bool) ([]string, error) {
  home, err := os.UserHomeDir()
  if err != nil {
    return nil, err
  }
  dirs := []string{
    filepath.Join(home, "Desktop"),
    filepath.Join(home, "Downloads"),
  }
  threshold := time.Now().Add(-24 * time.Hour)
  var results []string
  for _, dir := range dirs {
    filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
      if err != nil {
        return nil // ignore
      }
      if !info.IsDir() && info.ModTime().After(threshold) {
        if filter == nil || filter(path) {
          results = append(results, path)
        }
      }
      return nil
    })
  }
  return results, nil
}

Aufräumen

So weit die Suchfunktion. Wie kommt nun die TUI aus Abbildung 2 auf den Bildschirm? Mit dem Paket termui von Github geht das ruckzuck. Zeile 4 in Listing 3 importiert die praktische Bibliothek unter dem Namen »ui« ins Programm, der Aufruf »ui.Init()« in Zeile 9 lässt das Terminal in den Grafikmodus springen. Die Ausgabe einer ausgewählten Datei erfolgt später allerdings in Zeile 37 im Normalmodus. Daher 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. Das erledigt die »defer«-Anweisung in Zeile 18. Anderenfalls säße der User vor einem kaputten Terminal und könnte nicht mehr tippen. Stünde dort aber nur der Aufruf »ui.Close()«, würde der insgesamt zweimal erfolgen: explizit in Zeile 35 und implizit durch »defer()«. Nun hat das Paket termui die unschöne Angewohnheit, sich in dieser Situation aufzuhängen. Darum wickelt Zeile 13 die Funktion »safeClose()« um den Aufruf. Sie registriert, ob das Terminal schon geschlossen wurde, und unterbindet gegebenenfalls weitere Aufrufe. Die Darstellung und Auswahl der Dateipfade übernimmt das termui-Widget »List«, das die Text-Strings im Array »Rows« erwartet.

Listing 3

pick-recent.go

package main
import (
  "fmt"
  ui "github.com/gizak/termui/v3"
  "github.com/gizak/termui/v3/widgets"
  "github.com/kballard/go-shellquote"
)
func main() {
  if err := ui.Init(); err != nil {
    panic(err)
  }
  isClosed := false
  safeClose := func() {
    if !isClosed {
      ui.Close()
    }
  }
  defer safeClose()
  lb := widgets.NewList()
  items, err := findRecentFiles(nil)
  if err != nil {
    ui.Close()
    return
  }
  if len(items) == 0 {
    panic("No items found")
  }
  lb.Title = "Pick a file"
  w, h := ui.TerminalDimensions()
  lb.SetRect(0, 0, w, h)
  lb.Rows = items
  lb.SelectedRow = 0
  lb.SelectedRowStyle = ui.NewStyle(ui.ColorGreen)
  pick := handleUI(lb)
  ui.Close()
  isClosed = true
  fmt.Printf("%s\n", shellquote.Join(pick))
}
func handleUI(lb *widgets.List) string {
  ui.Render(lb)
  uiEvents := ui.PollEvents()
  for {
    select {
    case e := <-uiEvents:
      switch e.ID {
      case "q", "<C-c>":
        return ""
      case "j", "<Down>":
        lb.ScrollDown()
        ui.Render(lb)
      case "k", "<Up>":
        lb.ScrollUp()
        ui.Render(lb)
      case "<Enter>":
        return lb.Rows[lb.SelectedRow]
      }
    }
  }
}

Steuerung von Hand

Was passiert, wenn sich der Benutzer durch die Einträge der Listbox der TUI hangelt, legt »handleUI()« ab Zeile 39 fest. Nachdem »PollEvents()« in Zeile 41 den Channel der Benutzerschnittstelle für Tastatur-Events angezapft hat, arbeitet die For-Schleife ab Zeile 42 ankommende Ereignisse so lange ab, bis der User [Eingabe]+ oder [Q] drückt.

Bei einem Druck auf [J]+ oder die [Pfeil-unten]-Taste (»<Down>«), muss die Listbox mit »ScrollDown()« den aktuell hervorgehobenen 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 es 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 der in Anführungszeichen eingeschlossen wird, damit das getippte Kommando den Dateinamen als einzelnen Parameter versteht und nicht als durch Leerzeichen getrennte Einzelwörter.

Der Teufel steckt aber im Detail. Es genügt keineswegs, den Namen zwischen Anführungszeichen zu stellen. Obendrein muss man im Namen eventuell bereits enthaltene Anführungszeichen mit Backslashes maskieren (“escapen”). Dasselbe gilt für im Namen bereits enthaltene Rückstriche. Statt das von Hand zu programmieren, delegieren Listing 3 (in Zeile 37) und Listing 4 (in Zeile 18) die Sisyphusarbeit an das Go-Paket shellquote, das auf Github zur Abholung bereitsteht.

Fotos anzeigen

Auch Fotos lassen sich von der Kommandozeile aus aufstöbern. Allerdings weiß wohl kaum jemand, welche Abbildung sich beispielsweise hinter dem Dateinamen »IMG_3792.JPG« verbirgt. Ein Bild sagt da mehr als tausend Worte! Eine TUI kann keine Bilder anzeigen, eine Fyne-GUI in einem zusätzlich hochschnellenden Applikationsfenster aber schon. 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&auml;hlt sogar Fotos aus.

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

Abbildung 4 zeigt das fertige Programm in Aktion, nachdem der User mit [Strg]+[P] es aus der halb editierten Kommandozeile abgefeuert hat. Die aufschnellende Listbox zeigt neben den Dateinamen gefundener Einträge auch noch ein Vorschaubild, das die Auswahl erleichtern soll. Die darf sowohl per Maus als auch mit den Pfeiltasten im Vi-Modus erfolgen, sprich: [J]+ fährt nach unten und [K] nach oben. Ein Druck auf die Eingabetaste wählt den hellblau unterlegten Eintrag aus, das eingehängte Zsh-Widget übernimmt den gewählten Dateipfad auf die Kommandozeile.

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

Listing 4

pick-photo.go

package main
import (
  "fmt"
  "path/filepath"
  "strings"
  "fyne.io/fyne/v2"
  "fyne.io/fyne/v2/app"
  "github.com/kballard/go-shellquote"
)
func main() {
  items, err := findRecentFiles(isPhoto)
  if err != nil {
    panic(err)
  }
  a := app.New()
  w := a.NewWindow("Pick A Photo")
  list := NewPopList(items, func(text string) {
    fmt.Println(shellquote.Join(text))
    w.Close()
  })
  w.SetContent(list)
  w.Resize(fyne.NewSize(600, 400))
  w.Show()
  w.Canvas().Focus(list)
  list.Select(list.selectedID)
  a.Run()
}
func isPhoto(path string) bool {
  ext := strings.ToLower(filepath.Ext(path))
  switch ext {
  case ".jpg", ".jpeg", ".png", ".gif":
    return true
  }
  return false
}

Funktion erster Klasse

Der Filter »isPhoto()« ab Zeile 28 wird in Zeile 11 der Funktion »findRecentFiles()« mitgegeben. Das klappt, weil Funktionen in Go Bürger erster Klasse sind: Man kann also einfach den Namen einer Funktion zum Aufruf einer anderen Funktion dazupacken, und letztere ruft erstere 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 ausfällt (»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 hochspezialisierten Einträge nutzt es wiederum ein Custom-Widget des Typs »PhotoRow« (Listing 6), das statt einfachen Text-Strings bebilderte Auswahlflächen bietet.

Listing 5

poplist.go

package main
import (
  "fyne.io/fyne/v2"
  "fyne.io/fyne/v2/canvas"
  "fyne.io/fyne/v2/widget"
)
type PopList struct {
  widget.List
  items      []string
  selectedID int
  OnConfirm  func(text string)
}
func NewPopList(items []string, onConfirm func(string)) *PopList {
  l := &PopList{
    items:      items,
    selectedID: 0,
    OnConfirm:  onConfirm,
  }
  l.ExtendBaseWidget(l)
  l.Length = func() int { return len(items) }
  l.CreateItem = func() fyne.CanvasObject {
    return NewPhotoRow()
  }
  cache := map[string]*canvas.Image{}
  l.UpdateItem = func(i widget.ListItemID, o fyne.CanvasObject) {
    if _, ok := cache[items[i]]; !ok {
      cache[items[i]] = o.(*PhotoRow).MkImage(items[i])
    }
    o.(*PhotoRow).SetRow(items[i], cache[items[i]])
  }
  return l
}
func (l *PopList) TypedKey(k *fyne.KeyEvent) {
  switch k.Name {
  case "K", fyne.KeyUp:
    if l.selectedID > 0 {
      l.selectedID--
      l.Select(l.selectedID)
      l.ScrollTo(l.selectedID)
    }
  case "J", fyne.KeyDown:
    if l.selectedID < len(l.items)-1 {
      l.selectedID++
      l.Select(l.selectedID)
      l.ScrollTo(l.selectedID)
    }
  case fyne.KeyEnter, fyne.KeyReturn:
    if l.OnConfirm != nil {
      l.OnConfirm(l.items[l.selectedID])
    }
  }
}

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 frischt es gleich alle angezeigten Einträge auf. Das mag bei Text-Strings 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 treiben würde. Einmal eingelesene Bilder ändern sich aber gottlob nicht, und so kann der Cache ab Zeile 24 das zu einem Bildpfad passende Thumbnail zwischenspeichern. Zeile 29 innerhalb von »UpdateItem()« greift dann blitzschnell auf die vorgefertigten Bilddaten zu, die ein vorheriger Aufruf von »MkImage()« in Zeile 27 dort abgelegt hat.

Die Tastensteuerung per Pfeiltasten oder die Vi-Mappings [J]+ und [K] übernimmt die Funktion »TypedKey()« ab Zeile 33. Die Variable »selectedID« führt Buch darüber, wo die Schreibmarke gerade steht, und »Select()« und »ScrollTo()« passen die Darstellung entsprechend an. Drückt der Benutzer die Eingabetaste, ruft das Widget den vordefinierten Callback unter »OnConfirm()« auf. Er gibt im vorliegenden Fall den Pfad zum angewählten Foto auf der Standardausgabe aus.

Fotos lesen und skalieren

Das Custom-Widget »PhotoRow« in Listing 6 implementiert 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.

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 von Github. Das Flag »AutoOrientation« in Zeile 28 schreibt vor, die Fotos nach dem Einlesen entsprechend ihrer EXIF-Header zu rotieren. Fyne würde sie sonst auf der Seite liegend oder auf dem Kopf stehend darstellen, falls das Smartphone die Rotation nur im Header angegeben und das Bild nicht ordnungsgemäß transformiert hat.

Listing 6

photorow.go

package main
import (
  "fyne.io/fyne/v2"
  "fyne.io/fyne/v2/canvas"
  "fyne.io/fyne/v2/container"
  "fyne.io/fyne/v2/layout"
  "fyne.io/fyne/v2/theme"
  "fyne.io/fyne/v2/widget"
  "github.com/disintegration/imaging"
)
const ThumbSize = 100
type PhotoRow struct {
  widget.BaseWidget
  image *canvas.Image
  text  *widget.Label
}
func NewPhotoRow() *PhotoRow {
  pr := &PhotoRow{
    image: canvas.NewImageFromResource(nil),
    text:  widget.NewLabel(""),
  }
  pr.image.FillMode = canvas.ImageFillContain
  pr.ExtendBaseWidget(pr)
  return pr
}
func (pr *PhotoRow) MkImage(path string) *canvas.Image {
  img := canvas.Image{}
  iimg, err := imaging.Open(path, imaging.AutoOrientation(true))
  if err != nil {
    img.Resource = theme.WarningIcon()
  } else {
    thumbnail := imaging.Thumbnail(iimg,
      int(ThumbSize), int(ThumbSize), imaging.Lanczos)
    img.Image = thumbnail
  }
  return &img
}
func (pr *PhotoRow) SetRow(path string, img *canvas.Image) {
  pr.text.SetText(path)
  pr.image.Image = img.Image
}
func (pr *PhotoRow) CreateRenderer() fyne.WidgetRenderer {
  thumbBox := container.NewGridWrap(
    fyne.NewSize(ThumbSize, ThumbSize), pr.image)
  centered := container.NewVBox(
    layout.NewSpacer(),
    pr.text,
    layout.NewSpacer(),
  )
  row := container.NewHBox(container.NewPadded(thumbBox), centered)
  return widget.NewSimpleRenderer(row)
}

Container in Containern

Zur Darstellung ruft das Custom-Widget nach den Fyne-Vorgaben die Funktion »CreateRenderer()« auf. Sie verpackt das Foto in einen Container, der es entsprechend des Listenformats streckt.

Der zugehörige Texteintrag, also der Pfad zum Bild als Zeichenkette, soll schön vertikal mittig zentriert neben dem Foto stehen. Daher packen ihn die Zeilen 46 bis 48 zwischen zwei Spacer-Widgets und stopfen das Trio in einen »VBox«-Container. Die Kombination 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 (Abbildung 5) einen Renderer, auf dem das Fyne-Framework besteht, damit es das Custom-Widget adoptiert.

Abbildung 5: Die Containerschachtelung zur zentrierten Darstellung der Elemente einer einzelnen Listenzeile.

Abbildung 5: Die Containerschachtelung zur zentrierten Darstellung der Elemente einer einzelnen Listenzeile.

Der Dreisprung aus Listing 7 erzeugt aus den Listings die Binaries. Dabei holt der Befehl »go mod tidy« sämtlichen eingebundenen Code von Github ab. Anschließend gilt es, die Binaries so im Suchpfad der Shell zu platzieren, dass ein Aufruf von der Kommandozeile sie findet. Das Kommando »source zsh.sh« (am besten über die Init-Datei ».zshrc« aufgerufen) stattet die aktive Z-Shell mit der zusätzlichen Funktionalität aus. Das klappt sowohl im (traditionell voreingestellten) Emacs-Modus als auch im von mir (per »set -o vi«) eingestellten Vi-Modus der Shell.

Sowohl normale Dateien (mit [Strg]+[R]) als auch Fotodateien (mit [Strg]+[P]) lassen sich nun schnell auswählen und auf der aktuellen Kommandozeile einfügen. Möchten Sie statt kürzlich aktualisierter Files alle finden oder in zusätzlichen Ordnern suchen, modifizieren Sie dazu die Filter in Listing 2 und Listing 4 entsprechend. (uba/jlu)

Listing 7

build.sh

$ go mod init shell
$ go mod tidy
$ go build pick-recent.go recent.go
$ go build pick-photo.go poplist.go recent.go photorow.go

Der Autor

Michael Schilli (mailto:mschilli@perlmeister.com) 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.

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 7 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Nach oben