Eine der mächtigsten und vielfältigsten Funktionen der Unix-Kommandozeile ist das Pipe-Konzept. Hier pumpt eine Pipe die Ausgabe einer Utility in die Eingabe der nächsten, die wiederum Teile ausfiltert, um dann durchs nächste Rohrstück in einem weiteren Verarbeitungsschritt zu landen. Das dieses Konzept niemals den Nobelpreis erhalten hat ist schlichtweg ein Skandal.
Die Utilty grep
spielt im Pipe-Mechanismus oft eine tragende Rolle, denn sie filtert zwischen zwei verbundenen Rohrenden unerwünschte Einträge aus. Die Guten ins Töpfchen und die schlechten ins Kröpfchen. Geht es darum, Strings zu filtern, helfen Textersatz oder reguläre Ausdrücke, doch nach welchen Kriterien sollte man Fotos aussortieren? Welche sind gut geworden, welche unscharf, farbstichig oder einfach schlecht komponiert? Solche Entscheidungen trifft (noch) der Mensch am besten und deswegen bringt die in dieser Ausgabe vorgestellte Utility photogrep
die Photos aus der Stdin-Pipe in einem Fenster auf die Screen, lässt den User mit der Maus eines oder mehrere auswählen, und drückt der Mensch abschließend den Submit-Button, druckt photogrep
die Namen der Auserwählten in die Standardausgabe. Grep mit Fotos eben.
Abbildung 1: photogrep lässt den User Bilder auswählen. |
Abbildung 1 zeigt photogrep
in Aktion, mit einer Pipe, deren erstes Kommando alle Fotodateien im Verzeichnis photos
auflistet. Das Tool schnappt sie sich die Pfade über die Standardeingabe und zeigt sie als Thumbnails in einer dreispaltigen Matrix an. Der User kann nun auf die Bildchen klicken, um sie auszuwählen, und bei jedem selektierten Foto erscheint ein blaues Kästchen mit weißem Haken.
Abbildung 2: Rechter Mausklick auf ein Foto bringt einen beweglichen Ausschnitt des vergrößerten Originalbilds |
Um Details eines der Fotos zu inspizieren, klickt der User statt zu blinzeln mit der rechten Maustaste auf das Bildchen, und es erscheint ein neues Inspektor-Fenster mit der Detailschau (Abbildung 2), die sich wie eine Landkarte auf Google Maps mit gedrückter Maustaste herumschubsen lässt. Die Detailsicht verschwindet nach einem Mausklick auf das Schließsymbol des Fensters.
Abbildung 3: Ein Klick auf den Submit-Button schließt das Tool und gibt die selektierten Pfade aus. |
Ist die Auswahl an hochwertigen Fotos getroffen, schließt ein Klick auf den Submit-Button im Hauptfenster unter der Matrix das Tool. Kurz vor Programmende gibt der Foto-Grepper noch die Pfade der selektierten Fotos auf seiner Standardausgabe aus, von wo das nächste Rohrstück der Pipe sie einsaugt. In Abbildung 3 ist dies xargs echo
, aber es könnten beliebige Kommandos folgen, die die Dateien automatisch bearbeiten oder irgendwo hin kopieren.
Eine GUI auf den Desktop zu zaubern, mit Knöpfchen zum Drücken und Fotos zum Bestaunen, das ist ein klarer Fall für das Framework Fyne mit Go. Der gleiche Code lässt sich damit für allerlei Plattformen kompilieren, nicht nur Windows und Linux-Derivate, auch MacOS oder sogar Android und iOS! Listing 1 zeigt das Hauptprogramm main
, das zuerst einmal die Eingabe untersucht. Kommen die Dateien per Pipe zeilenweise über die Standardeingabe herein oder hat der User die Dateipfade dem Binary als Argumente übergeben? Genau wie die Unix-Utility grep
kann photogrep
beide Eingabeformen verarbeiten. Jetzt dürfen sie die Hand heben, falls sie grep
schon einmal ohne Pipe oder Dateien aufgerufen und sich gewundert haben, warum es ewig wartete.
Anschließend öffnet Zeile 24 mit app.New()
eine neue App des Fyne-Frameworks und NewWindow()
danach das zugehörige GUI-Fenster. Die Fotos im oberen Teil sind teil eines "Contact Sheets" (Kontaktabzug) wie dies in der analogen Fotografie heißt, dessen Konstruktor NewCsheet()
in Zeile 26 eine Referenz auf das neue Widget liefert. Der Aufruf der Funktion MakeGrid()
mit den zu selektierenden Dateipfaden als Argument in Zeile 27 zeigt die Fotos in einer dreispaltigen Matrix in einem scrollenden Widget an. Das Fenster muss schließlich auf dem räumlich begrenzten Desktop Platz finden und hat deswegen eine fixe Größe. Falls mehr Fotos hereinkommen als das Kontaktabzug-Widget darstellen kann, fügt der Window-Manager einen vertikalen Scrollbar hinzu, den der User verschieben kann, um auch weiter unten liegende Fotos zu sichten.
01 package main 02 import ( 03 "bufio" 04 "flag" 05 "fmt" 06 "os" 07 "strings" 08 "fyne.io/fyne/v2" 09 "fyne.io/fyne/v2/app" 10 "fyne.io/fyne/v2/container" 11 "fyne.io/fyne/v2/widget" 12 ) 13 func main() { 14 flag.Parse() 15 var fileNames []string 16 if flag.NArg() != 0 { 17 fileNames = flag.Args() 18 } else { 19 scanner := bufio.NewScanner(os.Stdin) 20 for scanner.Scan() { 21 fileNames = append(fileNames, strings.TrimSpace(scanner.Text())) 22 } 23 } 24 app := app.New() 25 w := app.NewWindow("photogrep") 26 cs := NewCsheet(app) 27 grid := cs.MakeGrid(fileNames) 28 grid.SetMinSize(fyne.NewSize(800, 600)) 29 submitButton := widget.NewButton("Submit", func() { 30 for fileName := range cs.selected { 31 fmt.Println(fileName) 32 } 33 w.Close() 34 }) 35 content := container.NewVBox(grid, submitButton) 36 w.SetContent(content) 37 w.ShowAndRun() 38 }
Dass Zeile 28 die Minimalgröße des Kontaktabzug-Widgets auf eine fixe Größe setzt ist essentiell. Andernfalls würde das scrollende Guckloch auf die geringstmögliche Größe schrumpfen, was in Fyne oftmals Verwirrung auslöst, wenn ein Widget, das eigentlich hochkommen sollte, plötzlich die Dimensionen Null mal Null hat und unsichtbar über dem Desktop schwebt.
Das Hauptfenster der Applikation besteht nun in erster Ebene aus zwei Komponenten: Der Bilderliste oben und dem Submit-Button unten. Letzteren erzeugt der Konstruktor NewButton()
in Zeile 29 und weist ihm eine Funktion als Callback zu, die mit cs.selected()
alle selektierten Fotos des Kontakabzugs auflistet, mit einer for
-Schleife über die Pfade iteriert und sie zeilenweise mit Println()
in die Standardausgabe des Tools pumpt.
Der Konstruktor des Kontaktabzug-Widgets, NewCsheet()
, steht ab Zeile 15 in Listing 2. Er gibt nur einen Pointer auf eine Struktur zurück, in der er eine Referenz auf die App ablegt, damit der Code später ein auf höchster Ebene liegendes Inspektor-Fenster aufmachen kann. Weiter liegt im Feld selected
eine Hashmap, die zu jedem Foto, das der User selektiert hat, den Dateipfad einem wahren Bool-Eintrag zuweist. Ein dutzend Smartphone-Fotos mit jeweils 4000x3000 Pixeln einzulesen und als Thumbnails darzustellen dauert selbst auf einem schnellen Rechner ein paar Sekunden, deswegen macht MakeGrid()
in Zeile 24 mit go func
eine parallel laufende Goroutine auf, die die darzustellenden Fotos in aller Ruhe einliest, runterskaliert und in die GUI malt, während der Haupt-Thread in Zeile 31 bereits zum Hauptprogramm zurückkehrt, das die GUI schon mal mit leerem Kontaktabzug auf den Schirm bringt.
Denn wenn es ein ungeschriebenes Gesetz ansprechender GUI-Applikationen gibt, dann das, dass sie niemals tot erscheinen dürfen. Der User muss zu jeder Zeit in der Lage sein, mittels Mausklicks Aktionen auszulösen. Hängt eine App, weil die CPU mit anderen Dingen beschäftigt ist, als Events zu bearbeiten, folgt die 1-Stern Bewertung auf dem Fuß. Da die Goroutine weiter läuft, auch wenn MakeGrid()
bereits beendet ist, fügt sie mit grid.Add()
nach und nach fertig bearbeitete Dateien in das Sichtfenster ein, während der Eventhandler der GUI ganz normal weiter auf Useranfragen reagiert.
01 package main 02 import ( 03 "github.com/disintegration/imaging" 04 "fyne.io/fyne/v2" 05 "fyne.io/fyne/v2/canvas" 06 con "fyne.io/fyne/v2/container" 07 "fyne.io/fyne/v2/widget" 08 ) 09 const ThumbSize = float32(200) 10 const ViewSize = float32(800) 11 type csheet struct { 12 selected map[string]bool 13 app fyne.App 14 } 15 func NewCsheet(app fyne.App) *csheet { 16 return &csheet{ 17 selected: map[string]bool{}, 18 app: app, 19 } 20 } 21 func (cs *csheet) MakeGrid(fileNames []string) *con.Scroll { 22 grid := con.NewGridWithColumns(3) 23 scroll := con.NewScroll(grid) 24 go func() { 25 for _, fileName := range fileNames { 26 pick := cs.newPick(fileName) 27 grid.Add(pick) 28 grid.Refresh() 29 } 30 }() 31 return scroll 32 } 33 func (cs *csheet) newPick(fileName string) *fyne.Container { 34 img, err := imaging.Open(fileName, imaging.AutoOrientation(true)) 35 if err != nil { 36 panic(err) 37 } 38 thumbnail := imaging.Thumbnail(img, int(ThumbSize), int(ThumbSize), imaging.Lanczos) 39 image := canvas.NewImageFromImage(thumbnail) 40 image.FillMode = canvas.ImageFillContain 41 image.SetMinSize(fyne.NewSize(ThumbSize, ThumbSize)) 42 check := widget.NewCheck("", func(checked bool) { 43 if checked { 44 cs.selected[fileName] = true 45 } else { 46 delete(cs.selected, fileName) 47 } 48 }) 49 ci := newClickImage(image, func() { 50 // toggle 51 check.SetChecked(!check.Checked) 52 }, 53 func() { 54 fullView := cs.app.NewWindow("Full Image View") 55 img, err := imaging.Open(fileName, imaging.AutoOrientation(true)) 56 if err != nil { 57 panic(err) 58 } 59 fullImage := canvas.NewImageFromImage(img) 60 fullImage.FillMode = canvas.ImageFillOriginal 61 inspector := NewPan(fullImage) 62 fullView.SetContent(inspector.scroll) 63 fullView.Resize(fyne.NewSize(ViewSize, ViewSize)) 64 inspector.Center() 65 fullView.Show() 66 }) 67 image.Refresh() 68 return con.NewVBox(ci, check) 69 }
Ein Smartphone-Foto hat schnell mal 25 Megabytes, und so ein Riesenbild von der GUI in einem kleinen Guckloch von 200x200 Pixeln darzustellen wäre extrem verschwenderisch. Die CPU müsste das Bild in voller Auflösung in ein Raster verwandeln und an die GPU des Rechners (falls vorhanden) übermitteln, die es dann erst auf das darzustellende Maß herunterskalieren würde. Das zwingt den schnellsten Rechner in die Knie.
Statt dessen zieht Listing 2 das Paket imaging
von Github herein, das mit der Funktion Thumbail()
den performanten Lanczos
-Algorithmus anbietet, um Bilder zu verkleinern. Die Fyne-Funktion NewImageFromImage()
wandelt die Pixel des Thumbnails dann schnell ins GUI-Format um. Mit newClickImage()
in Zeile 49 wird daraus ein klickbares Bild, und die Funktion nimmt zwei Callbacks entgegen, die definieren, was passiert, falls der User die linke oder die rechte Maustaste klickt, während der Zeiger über dem Bild schwebt.
Denkt man an die ersten Desktop-Designs zurück, war deren Bedienung mit der Maus geradezu umständlich, verglichen mit dem intuitiven Tippen und Wischen auf heutigen Tablets oder Smartphones. Wer hat sich nicht schon mal dabei ertappt, auf dem Laptop statt mit der Maus irgendwo hinzufahren und zu klicken einfach mit dem Finger auf den Bildschirm zu tippen?
Die Checkbox unter einem Foto mit der Maus anzusteuern, nur um diese zu klicken, fühlt sich nach 1995 an, deswegen erlaubt es Zeile 51 in Listing 2, einfach das Bild mit einem Linksklick anzuknipsen, um dessen Checkbox zur Selektion zu aktivieren oder deaktivieren.
Der zweite Callback ab Zeile 53 definiert die Reaktion auf einen rechten Mausklick über einem Foto. Dabei öffnet Zeile 54 ein neues Fenster und Open()
aus der Imaging-Library öffnet mit gesetzter Option AutoOrientation
das Bild in korrigierter Orientierung. Viele Smartphones speichern Bilder mit gesetztem EXIF-Header rotiert ab, was viele Applikationen ignorieren und das Bild falsch gedreht darstellen. Die so entstehenden 12 Millionen Pixel der vollen Auflösung schickt Zeile 61 anschließend an das mit NewPan()
initiierte Inspektor-Fenster.
Zum zügigen Scrollen durch ein übergroßes Foto bietet Fyne mit container.Scroll
Bereiche mit seitlichen Rollbalken. Doch diese mit dem Mauszeiger zu suchen, anzuklicken und dann zu verschieben, das geht im Jahr 2024 beim besten Willen nicht mehr. Statt dessen erwarten User heute, dass sie das Foto wie eine Google-Maps-Landkarte mit der Maus im flexiblen Rahmen herumschubsen können, sogenanntes "Panning". Der zugehörige Maus-Event heißt "drag" (ziehen, schleifen). Listing 4 wird später die Implementierung zeigen.
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 clickImage struct { 08 widget.BaseWidget 09 image *canvas.Image 10 cbleft func() 11 cbright func() 12 } 13 func newClickImage(img *canvas.Image, cbleft func(), cbright func()) *clickImage { 14 ci := &clickImage{} 15 ci.ExtendBaseWidget(ci) 16 ci.image = img 17 ci.cbleft = cbleft 18 ci.cbright = cbright 19 return ci 20 } 21 func (t *clickImage) CreateRenderer() fyne.WidgetRenderer { 22 return widget.NewSimpleRenderer(t.image) 23 } 24 func (t *clickImage) Tapped(_ *fyne.PointEvent) { 25 t.cbleft() 26 } 27 func (t *clickImage) TappedSecondary(_ *fyne.PointEvent) { 28 t.cbright() 29 }
Zunächst aber zu den klickbaren Fotos im Kontaktabzug. Fyne definiert eine Reihe von primitiven grafischen Elementen wie canvas.Image
, die aber aus Performance-Gründen keine Mausklicks verarbeiten. Wer aber ein etwas aus dem Rahmen fallendes Widget braucht, wie ein klickbares Foto, kann dies wie in Listing 3 leicht tun, mit einem auf widget.BaseWidget
(Zeile 9) aufbauenden Konstrukt, das einen Pointer auf das darzustellende Foto enthält, sowie callbacks cbleft()
und cbright()
für Aktionen mit der linken und der rechten Maustaste definiert.
Fyne hat sich auf die Fahne geschrieben, nicht nur GUIs auf dem Desktop zu steuern, sondern auch Apps auf mobilen Endgeräten. Deshalb ist es gar nicht so einfach, zu bestimmen, was bei einem Mausklick mit der linken oder der rechten Maustaste passiert. Auf einem Mobiltelefon gibt's bekanntlich keine Maus, sondern der User tippt, hält oder wischt. Fyne kategorisiert die ersten beiden Events als Tapped()
und TappedSecondary()
. Listing 3 definiert für beide einen Callback, die der Aufrufer später der exportierten Funktion newClickImage()
mitgibt.
Für das komplette Custom-Widget fehlt noch die Anweisung, wie denn das Widget darzustellen ist. Zeile 22 lässt dazu die im Interface vorgeschriebene Funktion CreateRenderer()
einfach den NewSimpleRenderer
der Fyne-Library für Images zurückgeben.
Das Inspektor-Fenster, das nach rechten Mausklicks hochschnellt, kann der User wie gesagt mit Maus-Drags wie in Google Maps durch die verschiedenen Bereiche eines hochauflösenden Bildes fahren. Dazu erzeugt Listing 4 ein weiteres Custom-Widget Pan
und zieht dabei alle Register.
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/widget" 07 "image/color" 08 ) 09 type Pan struct { 10 widget.BaseWidget 11 image *canvas.Image 12 scroll *container.Scroll 13 scrollOffset fyne.Position 14 } 15 func NewPan(img *canvas.Image) *Pan { 16 di := &Pan{image: img} 17 di.ExtendBaseWidget(di) 18 scrollContent := container.NewMax(di) 19 scroll := container.NewScroll(scrollContent) 20 di.scroll = scroll 21 return di 22 } 23 func (di *Pan) CreateRenderer() fyne.WidgetRenderer { 24 return &panRenderer{di} 25 } 26 func (di *Pan) Dragged(e *fyne.DragEvent) { 27 di.scroll.Offset = di.scroll.Offset.SubtractXY(e.Dragged.DX, e.Dragged.DY) 28 di.scroll.Refresh() 29 } 30 func (di *Pan) Center() { 31 w, h := di.image.Size().Width, di.image.Size().Height 32 di.scroll.Offset = fyne.NewPos(float32(w)/2.0, float32(h)/2.0) 33 di.scroll.Refresh() 34 } 35 func (di *Pan) DragEnd() { 36 } 37 type panRenderer struct { 38 di *Pan 39 } 40 func (r *panRenderer) Layout(size fyne.Size) { 41 r.di.image.Resize(size) 42 } 43 func (r *panRenderer) MinSize() fyne.Size { 44 return r.di.image.MinSize() 45 } 46 func (r *panRenderer) Refresh() { 47 canvas.Refresh(r.di.image) 48 } 49 func (r *panRenderer) BackgroundColor() color.Color { 50 return color.Transparent 51 } 52 func (r *panRenderer) Objects() []fyne.CanvasObject { 53 return []fyne.CanvasObject{r.di.image} 54 } 55 func (r *panRenderer) Destroy() {}
Wieder baut das Pan
-Widget auf widget.BaseWidget
auf (Zeile 10), und definiert ein Attribut namens image
das auf das darzustellende Foto zeigt. Weiter braucht so ein Google-Maps-Klon einen Scroll-Container (scroll
in Zeile 12) sowie einen Merker für die aktuelle Scroll-Position scrollOffset
. Wie gesagt, ein Scroll-Widget in Fyne kann bereits übergroße Fotos laden und darstellen, aber die Bewegung durch die Bereiche des Bildes laufen über klick- und schiebbare Scrollbars, die rechts und unterhalb des dargestellten Bereichs liegen.
Der Trick an Pan
ist nun, dass es dem User erlaubt, mit gedrückter linker Maustaste und einer schleifenden Bewegung durch die Details des Fotos zu navigieren. Dazu bekommt der Handler Dragged()
ab Zeile 26 mehrmals pro Sekunde die Koordinaten der Maus zugeschickt, falls der User sie bei gedrückter linker Maustaste durchs Bild zieht. Diese Delta-Werte subtrahiert Zeile 27 vom gespeicherten Offset des Scroll-Widgets und veranlasst das Guckloch mit Refresh()
den Bildausschnitt an den neuen Koordinaten anzuzeigen.
Warum subtrahieren und nicht addieren? Die ersten Map-Implementierungen in der Steinzeit des Internets funktionierten tatsächlich so, dass der User mit der schleifenden Maus die Richtung angab, in die die App den Mauszeiger auf der Landkarte vorwärts zu schieben hatte. Mit Google Maps drehte sich die Richtung um, und heute wäre es undenkbar, etwas anderes zu implementieren als mit der gedrückten und geschlenzten Maus die Richtung anzugeben, mit der die darunter gelegene Landkarte (oder das Foto) zu schubsen ist.
Ruft nun der User den Inspektor zu einem Thumbnail auf, ist höchstwahrscheinlich nicht die linke obere Ecke des Fotos sein Begehr, sondern vielleicht eher die Mitte. Die Funktion Center()
ab Zeile 30 positioniert das Guckloch dazu auf X-Y-Koordinaten, die genau die Hälfte der Bildbreite und -höhe ausmachen.
Wegen der komplexeren Funktion nutzt das Pan
-Widget in Listing 4 nicht den simplen Image-Renderer wie Listing 3, sondern definiert seinen eigenen Renderer, den PanRenderer
ab Zeile 37. Der muss dem Fyne-Widget-Verwalter auf Anfrage mitteilen, wie er sich denn vergrößert, falls Platz frei wird (Layout()
) oder was seine Mindestgröße ist (MinSize()
), oder wie die neueste Darstellung auf den Schirm kommt (Refresh()
). Das Custom-Widget fällt hier lediglich auf das dargestellte Foto zurück, dessen canvas.Image
-Implementierung bereits alles notwendige bietet.
Erstaunlich ist, wie performant dieses Panning am Ende tatsächlich funktiniert. Dabei ist über den Scroll-Container Hardware-Unterstützung im Spiel, keine CPU der Welt könnte so schnell einen Ausschnitt aus einem Bild berechnen.
1 $ go mod init photogrep 2 $ go mod tidy 3 $ go build photogrep.go clickimg.go csheet.go pan.go
Die übliche Dreisatz aus Listing 5 compiliert alle Listings dieser Ausgabe zu einem Binary photogrep
, das die auszuwählenden Fotos etweder über die Standardeingabe oder als Kommandozeilenargumente entgegennimmt. Wer noch nie mit Fyne unter Linux gearbeitet hat, sollte vorher golang-go
, build-essential
, libgl1-mesa-dev
und xorg-dev
installieren. Ein neues praktisches Werkzeug, das wie grep
in keiner Toolbox fehlen sollte.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2025/01/snapshot/
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc