In herumgeschickten Ausschnitten aus Zeitungsartikeln lohnt es sich oft, eine Passage hervorzuheben, damit auch multitaskende Empfänger sofort wissen, worum es sich dreht und ob es sich lohnt, die Nachricht ganz zu lesen. Damals, vor vielen vielen Jahren, im Papierzeitalter, griff der Absender deswegen zu einem grell gelb fluoreszierend leuchtenden sogenannten Textmarker der Marke "Stabilo", zog knackend dessen Kappe ab und strich mit dem nach Isopropyl-Alkohol duftenden Filz einen Textbereich an, um ihn hervorzuheben. Es soll übrigens heutzutage auch noch solche Dinosaurier geben, denn kauft man ein Papierbuch online auf dem Gebrauchtmarkt, stellt sich oft bei dessen Empfang heraus, dass weite Teile des Texts grell herausleuchten (Abbildung 1).
![]() |
Abbildung 1: Ausschnitt aus einem gebraucht gekauften Buch mit Markierungen |
Liegt nun ein Artikel als Screenshot vor, kann ihn ein selbstgeschriebenes Go-Programm mit GUI auf den Schirm bringen. Der User klickt mit der Maus auf einen Startpunkt und zieht dann bei gedrückt gehaltener Maustaste einen Rahmen auf, der sich wie ein Gummiband ausdehnt und den zu markierenden Textbereich umschließt (Abbildung 2). Das Fyne-Framework stellt hierzu die dazu benötigten grafischen Elemente in Go bereit. Sichert das Programm abschließend die modifizierte Datei, lässt sich diese umstandslos posten oder verschicken.
![]() |
Abbildung 2: Gummibox aufziehen und Textstellen markieren |
Dabei kann der User die Maus in alle Richtungen ziehen. Manchmal ist es eben bequemer, den zu zeichnenden Rahmen nicht links oben anzufangen und das Gummiband nach rechts unten aufzuziehen, sondern zum Beispiel exakt seitenverkehrt, also rechts unten anzufangen und in Richtung links oben zu fahren. Oder erst hoch und dann nach rechts. Die vier möglichen Aktionen zeigt Abbildung 3 und das Programm muss später die aufgezogene Box entsprechend richtig einzeichnen.
![]() |
Abbildung 3: Die GUI kann Text in allen Richtungen markieren |
Da Grafik-Frameworks wie das verwendete Fyne Rechtecke anhand der linken oberen Ecke (x,y)
und den beiden Kantenlängen w
und h
zeichnen, muss die GUI später die Bewegungsrichtung der Maus beobachten, damit sie das Rechteck korrekt zeichnen kann. Fährt die Maus zum Beispiel von rechts unten nach links oben, verschiebt sich der Ursprung des zu zeichnenden Rechtecks vom Startpunkt der Bewegung in (x0,x0)
nach (x,y)
wie Abbildung 4 illustriert. Die Kantenlängen w
und h
ergeben sich aus der Differenz der horizontalen beziehungsweise vertikalen Koordinaten, also x0 - x1
und y0 - y1
, jeweils als Absolutwert.
![]() |
Abbildung 4: Umgekehrt aufgezogen entsteht ein Rechteck, dessen Ursprung sich verschiebt. |
01 package main 02 import ( 03 "fyne.io/fyne/v2" 04 "fyne.io/fyne/v2/app" 05 "fyne.io/fyne/v2/canvas" 06 "fyne.io/fyne/v2/container" 07 "fyne.io/fyne/v2/dialog" 08 "fyne.io/fyne/v2/widget" 09 "image" 10 "os" 11 ) 12 const ( 13 Width = 800 14 Height = 600 15 ) 16 func main() { 17 a := app.NewWithID("com.example.imagehighlighter") 18 w := a.NewWindow("Image Highlighter") 19 w.Resize(fyne.NewSize(Width, Height)) 20 ov := NewOverlay() 21 img := &canvas.Image{} 22 var big image.Image 23 var imgPath string 24 if len(os.Args) == 2 { 25 imgPath = os.Args[1] 26 f, err := os.Open(imgPath) 27 if err != nil { 28 panic(err) 29 } 30 big, img = ov.loadImage(f) 31 } 32 stack := container.NewStack(img, ov) 33 w.Canvas().SetOnTypedKey(func(ev *fyne.KeyEvent) { 34 key := string(ev.Name) 35 switch key { 36 case "Q": 37 os.Exit(0) 38 } 39 }) 40 openBtn := widget.NewButton("Open Image", func() { 41 dialog.NewFileOpen(func(reader fyne.URIReadCloser, err error) { 42 if err != nil || reader == nil { 43 return 44 } 45 defer reader.Close() 46 imgPath = reader.URI().Path() 47 big, img = ov.loadImage(reader) 48 stack.Objects[0] = img 49 stack.Refresh() 50 }, w).Show() 51 }) 52 saveBtn := widget.NewButton("Save", func() { 53 ov.SaveBig(big, imgPath) 54 }) 55 quitBtn := widget.NewButton("Quit", func() { 56 os.Exit(0) 57 }) 58 buttons := container.NewHBox(openBtn, saveBtn, quitBtn) 59 w.SetContent(container.NewVBox(buttons, stack)) 60 w.ShowAndRun() 61 }
Und auf geht's! Listing 1 zeigt das Hauptprogramm. Das kompilierte Binary heißt später marker
und nimmt die Screenshot-Datei eines Zeitungsartikels auf der Kommandozeile als Argument entgegen. Als Formate taugen .png
oder auch <.jpg>-Dateien. Alternativ bringt die GUI eine Dialogbox zur Dateiwahl hoch, falls der User auf den Button "Open Image" links oben im Fenster klickt (Abbildung 5). Dieses Dialog-Element bietet Fyne von Haus aus in der Abteilung /dialog
des Projekts. Zeile 41 lässt die Auswahlbox mit NewFileOpen()
hochschnellen, falls der User durch einen Mausklick den Callback des Buttons openBtn
ausgelöst hat.
![]() |
Abbildung 5: Auf "Load Image" geklickt und ein Dialog zum Einlesen eines Screenshots schnellt hoch. |
Die vom Dialogfenster aus aufgerufene Callback-Funktion bekommt einen Reader vom Typ fyne.URIReadCloser
mit, mit dem später die Funktion loadImage()
die Daten der Bilddatei von der Platte holen wird. Vorher hat schon Zeile 32 einen neuen Container-Stapel angelegt, der aus zwei übereinanderliegenden Layern besteht (Abbildung 6). Obenauf liegt die später in Listing 2 definierte Overlay
-Struktur, auf der der User mit der Maus herumklicken darf und die gegebenenfalls das grellgrüne halbdurchsichtige Rechteck malt.
![]() |
Abbildung 6: Der untere Fyne-Layer zeigt den Text, der obere markiert den Ausschnitt. |
In der unteren Schicht liegt die Screendarstellung der geladenen Bilddatei. Beide Layer bilden einen Verbund, und falls der obere Layer nichts Gegenteiliges definiert, scheint der untere Layer nach oben durch. So kommt es, dass der obere Layer unsichtbar bleibt (also vollständig lichtdurchlässig agiert), während in der GUI das untenliegende Bild erscheint. Gleichzeitig bleibt der obere Layer klickbar. Lädt der Callback des "Open Image"-Buttons ein neues Bild von der Platte, greift Zeile 48 mit Objects[0]
in den Elementen-Array des Stack-Containers, fischt sich das erste Element heraus (auf Index 0) und ersetzt es durch das eben geladene Bild. Ein anschließender Refresh()
auf den Container zeigt nun die geänderte Sicht an.
Zeile 58 packt mit NewHBox()
(H für Horizontal) die drei Buttons in eine Zeile: den eben erwähnten zum Laden neuer Dateien, einen weiteren, auf dem "Save" steht, der die Datei mitsamt neuer Markierung abspeichert, und einen mit der Aufschrift "Quit", der das GUI-Programm auf Wunsch zusammenfaltet. Diese Button-Zeile liegt wiederum vertikal oberhalb der Stack-Combo mit dem Bild (Abbildung 1), und NewVBox()
packt beide vertikal gestapelt ins Hauptfenster, sodass die Button-Zeile über dem Bild schwebt. Dann bugsiert Setcontent()
das Container-Konglomerat ins Hauptfenster der Applikation, und ShowAndRun()
in Zeile 60 startet die Eventschleife, die Mausklicks abfängt und auf den einzelnen Widgets vordefinierte Aktionen einleitet, solange, bis das Programm endet.
Listing 2 zeichnet für den oberen Layer der GUI verantwortlich, der die Mausbewegungen des Users beobachtet und entsprechend grün-gelbe Gummiboxen aufzieht. Die Funktion ExtendBaseWidget()
im Konstruktor NewOverlay()
macht aus der Anordnung ein Fyne-Widget, das Mausklicks mitbekommt und sich selbständig rendern kann. Als Malfläche erzeugt Zeile 23 mit NewWithoutLayout()
einen leeren Container, der später die Gummibox aufnehmen wird. Als Renderer zieht CreateRenderer()
einfach den Standard-Renderer des verwendeten Containers heran. Fertig ist das neue Custom-Widget!
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 "github.com/disintegration/imaging" 08 "image" 09 "image/draw" 10 "io" 11 ) 12 type Overlay struct { 13 widget.BaseWidget 14 con *fyne.Container 15 marker *canvas.Rectangle 16 rect *Rect 17 inMotion bool 18 zoom float64 19 } 20 func NewOverlay() *Overlay { 21 over := &Overlay{} 22 over.ExtendBaseWidget(over) 23 over.con = container.NewWithoutLayout() 24 over.rect = NewRect() 25 return over 26 } 27 func (t *Overlay) CreateRenderer() fyne.WidgetRenderer { 28 return widget.NewSimpleRenderer(t.con) 29 } 30 func (t *Overlay) Dragged(e *fyne.DragEvent) { 31 if t.inMotion == false { 32 t.inMotion = true 33 t.rect.From = e.Position 34 return 35 } 36 t.rect.To = e.Position 37 pos, size := t.rect.Dims() 38 t.DrawMarker(pos, size) 39 } 40 func (t *Overlay) DragEnd() { 41 t.inMotion = false 42 } 43 func (t *Overlay) DrawMarker(pos fyne.Position, size fyne.Size) { 44 rect := canvas.NewRectangle(t.rect.Color()) 45 rect.Resize(size) 46 rect.Move(pos) 47 if t.marker == nil { 48 t.marker = rect 49 return 50 } 51 t.con.Remove(t.marker) 52 t.marker = rect 53 t.con.Add(rect) 54 t.con.Refresh() 55 return 56 } 57 func (t *Overlay) SaveBig(big image.Image, path string) error { 58 dimg := imaging.Clone(big) 59 r := t.rect.AsImage(t.zoom) 60 draw.Draw(dimg, r, &image.Uniform{t.rect.Color()}, r.Min, draw.Over) 61 err := imaging.Save(dimg, path) 62 if err != nil { 63 return err 64 } 65 return nil 66 } 67 func (t *Overlay) loadImage(r io.Reader) (image.Image, *canvas.Image) { 68 big, err := imaging.Decode(r, imaging.AutoOrientation(true)) 69 if err != nil { 70 panic(err) 71 } 72 shrunk := imaging.Resize(big, Width, 0, imaging.Lanczos) 73 t.zoom = float64(big.Bounds().Dx()) / float64(Width) 74 img := canvas.NewImageFromImage(shrunk) 75 img.FillMode = canvas.ImageFillOriginal 76 return big, img 77 }
Drückt nun der User die Maustaste und beginnt, bei gedrückt gehaltener Taste, ein Rechteck aufzuziehen, bekommt das Widget den Event Dragged()
mit, den der Callback ab Zeile 30 verarbeitet. Der Event wiederholt sich laufend, und sendet alle paar Millisekunden die aktuellen Koordinaten des Mauszeigers an die Funktion, während dieser über die Oberfläche fährt. Mit dem Loslassen der Maustaste endet der Reigen, und der Event DragEnd()
signalisiert das Ereignis der ab Zeile 40 eingehängten Funktion. Die setzt lediglich das Flag inMotion
auf false
, während der Callback Dragged()
das Flag vorher erst auf true
gesetzt hatte und dann von jeder neu angesteuerten Positon aus die Funktion DrawMarker()
ein neues Gummi-Rechteck zeichnen ließ.
Die Funktion merkt sich in der Instanzvariablen marker
das zuletzt gezeichnete Rechteck und löscht dieses eingangs mit Remove()
aus dem Container, bevor der Container in Zeile 53 mit Add()
ein neues Rechteck mit den aktuellen Koordinaten zugespielt bekommt. Die scheinbar flüssige Bewegung einer mit der Maus aufgezogenen Gummibox beruht also auf einer Illusion aus schnell abgespielten Einzelbildern, ähnlich wie im Kino.
01 package main 02 import ( 03 "fyne.io/fyne/v2" 04 "image" 05 "image/color" 06 ) 07 type Rect struct { 08 From fyne.Position 09 To fyne.Position 10 Zoom float64 11 } 12 func NewRect() *Rect { 13 return &Rect{} 14 } 15 func (r *Rect) Dims() (fyne.Position, fyne.Size) { 16 x := r.From.X 17 y := r.From.Y 18 w := r.To.X - r.From.X 19 h := r.To.Y - r.From.Y 20 if r.To.X < r.From.X { 21 x = r.To.X 22 w = -w 23 } 24 if r.To.Y < r.From.Y { 25 y = r.To.Y 26 h = -h 27 } 28 return fyne.NewPos(x, y), fyne.NewSize(w, h) 29 } 30 func (r *Rect) AsImage(zoom float64) image.Rectangle { 31 pos, size := r.Dims() 32 x := pos.X * float32(zoom) 33 y := pos.Y * float32(zoom) 34 w := size.Width * float32(zoom) 35 h := size.Height * float32(zoom) 36 rect := image.Rectangle{ 37 Min: image.Point{X: int(x), Y: int(y)}, 38 Max: image.Point{X: int(x) + int(w), Y: int(y) + int(h)}, 39 } 40 return rect 41 } 42 func (r *Rect) Color() color.NRGBA { 43 return color.NRGBA{R: 204, G: 255, B: 0, A: 50} 44 }
Wie sich das Rechteck der Mausbewegung folgend ausbreitet, bestimmt die Rect
-Struktur aus Listing 3 mit ihren Funktionen. Ausgehend von den Startkoordinaten From.X
und From.Y
und den Zielwerten To.X
und To.Y
gilt es, nach Abbildung 4 die Startkoordinaten des Rechtecks links oben (X,Y)
zu ermitteln, sowie dessen Breite w
und Höhe h
, damit das Fyne-Framework das Rechteck mit Resize()
aufziehen und mit Pos()
an die richtige Stelle im Container schubsen kann. Ja nach Fahrtrichtung der Maus kommen dabei negative Werte für die Rechteckdimensionen heraus, die die if
-Logik in den Zeilen 22 und 26 gegebenenfalls auf positive Werte korrigiert.
Das Layer-Design eignet sich hervorragend, um auch kompliziertere Markierungen zuzulassen, zum Beispiel additive Selektionen, um gleich mehrere Blöcke anzustreichen. Leider ist der Code aber nun des Originals verlustig gegangen, denn eingangs wurde das Bild für die kompakte Darstellung auf dem Desktop geschrumpft. Außerdem steckt die Markierung in einem übergeordneten Layer, wie flacht der Code das Bild später aus? Das Ziel ist ja, die modifizierte Bilddatei mitsamt dem Markierungen wieder auf der Platte abzulegen.
![]() |
Abbildung 7: Die Streckung passt sowohl die Position als auch die Dimensionen des Rechtecks an |
Die Originaldatei existiert noch im Speicher des Hauptprogramms, und die Markierung findet sich zwar in einem verkleinerte Maßstab wieder, doch das ist nichts, was eine zentrische Streckung nicht hinbiegen könnte. Abbildung 7 illustriert, dass wenn wir das verkleinerte Bild wieder vergrößern, auch die angebrachte Markierung entsprechend strecken müssen. Und das bezieht sich nicht nur auf den linken oberen Startpunkt des Rechtecks, sondern auch auf dessen Länge und Breite.
Anfangs wurde das Bild in der Funktion loadImage()
in Listing 2 auf die Breite von 800 Pixeln getrimmt, die Höhe ergab sich aus dem gleichbleibenden Seitenverhältnis. War die Originalbreite des Bildes X Pixel, ergibt sich daraus ein schrumpfenden Zoom-Faktor von X/800. Um die Markierung im dargestellten Layer also ins Orignalbild zu übertragen, muss der Code die X/Y-Position und die Breite/Höhe der Markierung also wieder mit dem Zoom-Faktor multiplizieren.
Die Funktion AsImage()
aus Listing 3 führt mit dem Zoom-Faktor als Argument die Streckung durch und spannt ab Zeile 36 ein Rechteck mit den neuen Dimensionen auf. Dabei ist zu beachten, dass Gos image
-Paket die Koordinaten eines Rechtecks in einem anderen Format als das Fyne-Framework erwartet. Während Fyne die linke obere Ecke als Startpunkt und die Länge und Breite des Rechtecks möchte, verwendet image
den Startpunkt als Min
im Format image.Point
sowie die Koordinaten der rechten unteren Ecke in Max
. Beides sind legitime Formate, aber Listing 2 muss eben zwischen beiden Welten übersetzen. Die grellgelbe Textmarker-Farbe definiert Zeile 43 in Listing 3 mit den RGB-Werten (204, 255, 0) sowie einem Alpha-Wert von 50 (von 255 maximal), damit darunter liegender Text auch noch lesbar durchscheint.
Um einen Screenshot mit Markierung wieder im Originalformat auf der Platte abzulegen, klont die Funktion SaveBig()
in Listing 2 das Original-Bild in big
, denn von Haus aus lässt Gos Bildstruktur image.Image
keine Modifizierungen zu. Der Clone()
-Befehl in Zeile 58 gibt eine Struktur zurück, die demselben Interface gehorcht, aber eben wie in Zeile 60 mit Draw()
das Herummalen erlaubt. Der Parameter draw.Over
stellt sicher, dass das gepinselte Rechteck wie in der GUI halbdurchsichtig über der Bilddatei erscheint.
1 $ go mod init marker 2 $ go mod tidy 3 $ go build marker.go overlay.go rect.go
Der übliche Dreisprung aus Listing 4 lötet alle Sourcen sowie die verwendeten Libraries von Github zu einem Binary zusammen. Dann noch das fertige GUI-Programm in einen Pfad der Unix-Shell verfrachtet, wo es ein Aufruf von der Kommandozeile findet, und der Markierer kann loslegen. Wer möchte, kann noch zusätzliche Features einbauen. Weitere Abschnitte anstreichen bei gedrückter Super-Taste? Abspeichern unter einem anderen Pfad? Alles easy, wer die Sourcen hat, dem steht die Welt offen.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2025/08/snapshot/
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc