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!
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
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
.
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.
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 }
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.
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 }
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.
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.
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.
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 }
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.
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 }
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.
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.
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. |
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.
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.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2025/11/snapshot/
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc