From 3a982d87a0604eb09915b430f7a1a9c0dcf463bf Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 31 Aug 2026 17:42:04 +0200 Subject: [PATCH] =?UTF-8?q?gtk:=20GTK4-hosted=20Linux=20backend=20?= =?UTF-8?q?=E2=80=94=20native=20controls=20over=20a=20GtkPicture=20framebu?= =?UTF-8?q?ffer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third native-control host, the sibling of internal/cocoa and internal/win32. GTK owns the window (github.com/go-gtk/gtk4, pure-Go over purego, no cgo); the toolkit's pixel framebuffer is presented in a GtkPicture each frame, and native platform controls (toolkit.NativeControl) are real GtkEntry/GtkButton/GtkCheckButton overlaid above it in a GtkFixed. It runs the same descriptor reconcile (gatherNative + syncNative) the cocoa and win32 backends do, with the same immediate-mode-safe value-diff binding, so a Surface app's secure field, button and checkbox become real GTK widgets with no app changes. Selected by $GO_WIDGETS_GTK (opt-in, so the default stays the dependency-free from-scratch X11/Wayland backends); needs the libgtk-4 runtime. Proven on real GTK4 under Xvfb (TestGTKBackendReconcilesControls + a new live-gtk CI lane): a secure-entry descriptor becomes a GtkEntry whose value round-trips, follows an app-side change, and is reconciled away when the descriptor disappears. Slider and pop-up are not yet hosted (pending trackbar/combo in the binding); input routing to the drawn surface and continuous frame ticking are the next steps. Requires go-gtk/gtk4 v0.2.0. --- .github/workflows/ci.yml | 17 +++ go.mod | 5 +- go.sum | 6 +- internal/gtk/gtk_linux.go | 116 ++++++++++++++++++++ internal/gtk/gtk_live_linux_test.go | 65 +++++++++++ internal/gtk/native_linux.go | 162 ++++++++++++++++++++++++++++ open_linux_only.go | 23 +++- 7 files changed, 388 insertions(+), 6 deletions(-) create mode 100644 internal/gtk/gtk_linux.go create mode 100644 internal/gtk/gtk_live_linux_test.go create mode 100644 internal/gtk/native_linux.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e9c224..579b8b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -470,3 +470,20 @@ jobs: live-hidpi-native.png live-hidpi-logical.png if-no-files-found: warn + + # Live GTK4 proof: the GTK4-hosted backend (internal/gtk, pure-Go via + # github.com/go-gtk/gtk4) opens a real GTK window under a headless X server, + # presents the toolkit framebuffer in a GtkPicture, and reconciles a Surface's + # native controls into real GTK widgets. Needs only the libgtk-4 runtime. + live-gtk: + name: live GTK4 (Xvfb, native controls) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: stable + - name: Install GTK4 runtime + a headless X server + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libgtk-4-1 xvfb xauth + - name: internal/gtk on real GTK4 + run: CGO_ENABLED=0 xvfb-run -a env GDK_BACKEND=x11 go test -count=1 -v ./internal/gtk/ diff --git a/go.mod b/go.mod index 6067ed9..4438e45 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,10 @@ require ( github.com/godbus/dbus/v5 v5.2.2 ) -require github.com/go-macos/virtualdisplay v0.2.1 +require ( + github.com/go-gtk/gtk4 v0.2.0 + github.com/go-macos/virtualdisplay v0.2.1 +) require ( github.com/ajroetker/go-highway v0.0.4 // indirect diff --git a/go.sum b/go.sum index db47756..8f74cf6 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/go-freedesktop/x11 v0.2.0 h1:qHvov5jn7GG4QxeealqRJVj72/MjR5qi2IXqyD7N github.com/go-freedesktop/x11 v0.2.0/go.mod h1:a+Fma9oOlsagw0CJKe+VHYxIl5/PzPSmlCVaYCtUSYA= github.com/go-gfx/gfx v0.19.0 h1:nA2Eg8Aq2kytR5Hrm/eQ6618w4vT0+Ao8BMZiuYh84c= github.com/go-gfx/gfx v0.19.0/go.mod h1:VAK6hgCgkhT3j3ek2K7G8zDfkCPD3RCF45UbcRlCV8Q= +github.com/go-gtk/gtk4 v0.2.0 h1:GKimOv8of33YipJqMoOkcw2qfS4hzRKqU8HWtgeEijA= +github.com/go-gtk/gtk4 v0.2.0/go.mod h1:+bpfPrc8PwsiQkZdNCL3LcOzj1KYgv8j2BvvL6P6Aqo= github.com/go-icons/iconoir v0.2.0 h1:I5AnDEIPgYfawTmwzemDRJjlYCUbFHuS8GJXrjADhXc= github.com/go-icons/iconoir v0.2.0/go.mod h1:Td/M2vy4xdVv4aOudAskV0nXR9Wv0MlhWvyRIzPBz5U= github.com/go-images/images v0.0.0-20260815210351-4a50ef5ab734 h1:nU+MGeyJzidGvCtx+HLZgHKG9GpUUomkQvB5nXLRWoc= @@ -30,12 +32,8 @@ github.com/go-macos/appkit v0.1.0 h1:z7ynfc6fA9DpEzEMhqADgAo2zmHfSpEARqcWh2IUgTM github.com/go-macos/appkit v0.1.0/go.mod h1:WIn5c/9dGRaYE/NP4BPiYFdtq4IAy/TgN8FMzxzE7wU= github.com/go-macos/objc v0.7.0 h1:uu879AhALfe99XtfrjHBNmKz14Y8fxL8oJTOiTr4VPY= github.com/go-macos/objc v0.7.0/go.mod h1:00GJyieLzPcb65rPLVWNPPUCVm79nmmiEXUDsslRpOs= -github.com/go-macos/virtualdisplay v0.1.0 h1:mZll/h6+PVn+mNTDH17D7G5lXV5HPxlmJU9NKXJcR7c= -github.com/go-macos/virtualdisplay v0.1.0/go.mod h1:SdD/x985/SWSqyARACyLX6F3xTD5dyFk/kWAT++RhyY= github.com/go-macos/virtualdisplay v0.2.1 h1:Yg1IPEjsToMCfSHVuiVKGtL3NpOgI68VGgA53euQtOY= github.com/go-macos/virtualdisplay v0.2.1/go.mod h1:X2uyrY28G2kJ8dVDxnCGUa7Q1ScB5rr0Vmd83Smx+uY= -github.com/go-mswin/win32 v0.3.0 h1:woBuRYApSkzs8PzloMI4qhQdAB8lDmbFC2VT/QRsB3E= -github.com/go-mswin/win32 v0.3.0/go.mod h1:ypmW4zosl3mXhy0O39EDbHk+AUjgaXGyFgY5UpX3AxI= github.com/go-mswin/win32 v0.4.0 h1:0JAvm3XNMxOyfYX1anbtAqxNvxvUA0DZ1SyAUinhMa0= github.com/go-mswin/win32 v0.4.0/go.mod h1:ypmW4zosl3mXhy0O39EDbHk+AUjgaXGyFgY5UpX3AxI= github.com/go-opentype/fonts v0.8.0 h1:77i3VPIH90GbstzNb21mk+an4WvEOe2idC6W+J0n0fw= diff --git a/internal/gtk/gtk_linux.go b/internal/gtk/gtk_linux.go new file mode 100644 index 0000000..0f0c4c6 --- /dev/null +++ b/internal/gtk/gtk_linux.go @@ -0,0 +1,116 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux && !android + +// Package gtk is the GTK4-hosted Linux back-end: instead of blitting to a +// from-scratch X11/Wayland surface, GTK owns the window, the toolkit's pixel +// framebuffer goes in a GtkPicture, and native platform controls +// (toolkit.NativeControl) are real GtkEntry/GtkButton/… overlaid above it in a +// GtkFixed. It links no cgo — everything is github.com/go-gtk/gtk4 over purego. +// +// It is the third native-control host, the sibling of internal/cocoa and +// internal/win32, and runs the same NativeControl descriptor reconcile +// (syncNative) they do — but here the widgets are GTK's, so a radio group finally +// groups properly rather than sharing one global action. +package gtk + +import ( + "errors" + + "github.com/go-gtk/gtk4" + "github.com/go-widgets/painter" + "github.com/go-widgets/toolkit" +) + +// Window is the GTK4-hosted back-end window. It satisfies the window.Backend +// contract (Run/Close/Size/String). +type Window struct { + win gtk4.Widget + fixed gtk4.Widget + pic gtk4.Picture + loop gtk4.MainLoop + + root toolkit.Widget + theme *toolkit.Theme + w, h int + scale float64 + buf []byte + + native map[string]*liveControl +} + +// Open creates the GTK window (but does not enter the loop; Run does). width and +// height are logical points; scale is framebuffer pixels per point. +func Open(title string, width, height int, theme *toolkit.Theme, scale float64) (*Window, error) { + if theme == nil { + theme = toolkit.DefaultDark() + } + if scale <= 0 { + scale = 1 + } + ok, err := gtk4.Init() + if err != nil { + return nil, err + } + if !ok { + return nil, errors.New("gtk: no display (gtk_init_check == false)") + } + win := gtk4.WindowNew() + win.SetTitle(title) + win.SetDefaultSize(width, height) + fixed := gtk4.FixedNew() + pic := gtk4.PictureNew() + fixed.Put(pic.Widget(), 0, 0) + win.SetChild(fixed) + + pxW, pxH := int(float64(width)*scale), int(float64(height)*scale) + return &Window{ + win: win, fixed: fixed, pic: pic, + theme: theme, w: pxW, h: pxH, scale: scale, + buf: make([]byte, pxW*pxH*4), + native: map[string]*liveControl{}, + }, nil +} + +// Run binds root, presents the first frame, and drives the GLib main loop until +// the window is closed. +func (w *Window) Run(root toolkit.Widget) error { + w.root = root + w.loop = gtk4.MainLoopNew() + w.win.Connect("close-request", func() { w.loop.Quit() }) + w.win.Present() + w.frame() // initial layout + present + control reconcile + w.loop.Run() + return nil +} + +// Close quits the loop and drops the window. +func (w *Window) Close() error { + if w.loop != 0 { + w.loop.Quit() + } + return nil +} + +// Size returns the current framebuffer size in pixels. +func (w *Window) Size() (int, int) { return w.w, w.h } + +// String identifies the window. +func (w *Window) String() string { return "gtk4-hosted window" } + +// frame lays the root out, presents its pixels in the picture, and reconciles the +// native controls over them. +func (w *Window) frame() { + if w.root == nil { + return + } + p := painter.NewPixelPainter(w.buf, w.w, w.h) + full := toolkit.Rect{X: 0, Y: 0, W: w.w, H: w.h} + p.FillRect(full, w.theme.Background) + w.root.SetBounds(full) + w.root.Draw(p, w.theme) + w.pic.SetRGBA(w.buf, w.w, w.h) + w.syncNative(w.root) +} diff --git a/internal/gtk/gtk_live_linux_test.go b/internal/gtk/gtk_live_linux_test.go new file mode 100644 index 0000000..b4cc72e --- /dev/null +++ b/internal/gtk/gtk_live_linux_test.go @@ -0,0 +1,65 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux && !android + +package gtk + +import ( + "testing" + + "github.com/go-widgets/toolkit" +) + +// TestGTKBackendReconcilesControls is the on-device proof of the GTK4-hosted +// back-end: it opens a real GTK window, presents a frame, and reconciles a +// self-rendering Surface's native controls into the GtkFixed — a real +// GtkEntry (secure) whose value round-trips, follows an app-side change, and is +// reconciled away when the Surface stops publishing it. Needs a display; under a +// headless runner run it with Xvfb (skips otherwise). +func TestGTKBackendReconcilesControls(t *testing.T) { + win, err := Open("gtk back-end test", 320, 200, nil, 1) + if err != nil { + t.Skipf("no GTK display: %v", err) + } + defer win.Close() + + pw := "hunter2" + surf := toolkit.NewSurface(func() ([]byte, int, int) { + return make([]byte, 320*200*4), 320, 200 + }) + surf.Controls = func() []toolkit.NativeControl { + return []toolkit.NativeControl{{ + Kind: toolkit.NativeSecureEntry, Key: "pw", + Rect: toolkit.Rect{X: 10, Y: 10, W: 200, H: 24}, Visible: true, + Text: pw, + OnText: func(s string) { pw = s }, + }} + } + win.root = surf + + // Present + reconcile: the secure entry is created with its value. + win.frame() + lc := win.native["pw"] + if lc == nil { + t.Fatal("no GTK control created for the pw descriptor") + } + if got := lc.widget.Text(); got != "hunter2" { + t.Errorf("entry text = %q, want hunter2", got) + } + + // App changes the value; the next frame pushes it (it differs). + pw = "changed" + win.frame() + if got := lc.widget.Text(); got != "changed" { + t.Errorf("after app change, entry text = %q, want changed", got) + } + + // The Surface stops publishing it: the control is reconciled away. + surf.Controls = func() []toolkit.NativeControl { return nil } + win.frame() + if _, ok := win.native["pw"]; ok { + t.Error("control was not reconciled away after the descriptor disappeared") + } +} diff --git a/internal/gtk/native_linux.go b/internal/gtk/native_linux.go new file mode 100644 index 0000000..28d9e6b --- /dev/null +++ b/internal/gtk/native_linux.go @@ -0,0 +1,162 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux && !android + +package gtk + +import ( + "github.com/go-gtk/gtk4" + "github.com/go-widgets/toolkit" +) + +// liveControl is one embedded GTK widget, the app callbacks for the frame, and +// the last value it reported — the baseline the next descriptor is compared +// against, so a value is pushed into the widget only when the app changed it and +// the person's own edit is never disturbed (the same immediate-mode-safe binding +// as the cocoa and win32 back-ends). +type liveControl struct { + widget gtk4.Widget + kind toolkit.NativeKind + + onText func(string) + onBool func(bool) + onActivate func() + + lastText string + lastBool bool +} + +// nativeControlSource is the optional capability a root exposes to supply native +// controls directly — a self-rendering toolkit.Surface. A root that does not is +// walked as a widget tree. +type nativeControlSource interface { + NativeControls() []toolkit.NativeControl +} + +func gatherNative(root toolkit.Widget) []toolkit.NativeControl { + if p, ok := root.(nativeControlSource); ok { + return p.NativeControls() + } + return toolkit.WalkNative(root) +} + +// syncNative reconciles the window's embedded GTK controls with the descriptors +// for the current frame: create new ones in the GtkFixed, move/update existing +// ones, unparent the ones that went away. +func (w *Window) syncNative(root toolkit.Widget) { + specs := gatherNative(root) + if len(specs) == 0 && len(w.native) == 0 { + return + } + seen := make(map[string]bool, len(specs)) + for _, spec := range specs { + if spec.Key == "" { + continue + } + seen[spec.Key] = true + lc := w.native[spec.Key] + if lc == nil { + lc = w.makeControl(spec) + if lc == nil { + continue + } + w.native[spec.Key] = lc + if spec.OnClaim != nil { + spec.OnClaim(true) + } + } + w.applySpec(lc, spec) + } + for key, lc := range w.native { + if !seen[key] { + lc.widget.Unparent() + delete(w.native, key) + } + } +} + +// pt converts a framebuffer-pixel coordinate to a GTK logical point (GtkFixed and +// widget sizes are in points; the descriptors are in render pixels). +func (w *Window) pt(px int) float64 { return float64(px) / w.scale } + +// applySpec updates a live control from this frame's descriptor: refresh the +// callbacks, push a value only when it changed, and position + size it. +func (w *Window) applySpec(lc *liveControl, spec toolkit.NativeControl) { + lc.onText = spec.OnText + lc.onBool = spec.OnBool + lc.onActivate = spec.OnActivate + + switch spec.Kind { + case toolkit.NativeLabel, toolkit.NativeEntry, toolkit.NativeSecureEntry: + if spec.Text != lc.lastText { + lc.widget.SetText(spec.Text) + lc.lastText = spec.Text + } + case toolkit.NativeCheckbox, toolkit.NativeRadio, toolkit.NativeSwitch: + if spec.On != lc.lastBool { + lc.widget.SetActive(spec.On) + lc.lastBool = spec.On + } + } + + w.fixed.Move(lc.widget, w.pt(spec.Rect.X), w.pt(spec.Rect.Y)) + lc.widget.SetSizeRequest(int(w.pt(spec.Rect.W)), int(w.pt(spec.Rect.H))) + lc.widget.SetVisible(spec.Visible) +} + +// makeControl builds the GTK widget for a descriptor, puts it in the fixed over +// the framebuffer, and wires its signals to dispatch through the liveControl's +// current app callbacks. Returns nil for a kind this back-end does not yet host +// (slider, pop-up — pending trackbar/combo in the binding). +func (w *Window) makeControl(spec toolkit.NativeControl) *liveControl { + lc := &liveControl{kind: spec.Kind, lastText: spec.Text, lastBool: spec.On} + switch spec.Kind { + case toolkit.NativeButton: + lc.widget = gtk4.ButtonNewWithLabel(spec.Text) + lc.widget.Connect("clicked", func() { + if lc.onActivate != nil { + lc.onActivate() + } + }) + case toolkit.NativeLabel: + lc.widget = gtk4.LabelNew(spec.Text) + case toolkit.NativeEntry, toolkit.NativeSecureEntry: + lc.widget = gtk4.EntryNew() + if spec.Kind == toolkit.NativeSecureEntry { + lc.widget.SetVisibility(false) + } + lc.widget.SetText(spec.Text) + lc.widget.Connect("changed", func() { + lc.lastText = lc.widget.Text() + if lc.onText != nil { + lc.onText(lc.lastText) + } + }) + lc.widget.Connect("activate", func() { + if lc.onActivate != nil { + lc.onActivate() + } + }) + case toolkit.NativeCheckbox, toolkit.NativeRadio, toolkit.NativeSwitch: + lc.widget = gtk4.CheckButtonNewWithLabel(spec.Text) + lc.widget.SetActive(spec.On) + lc.widget.Connect("toggled", func() { + lc.lastBool = lc.widget.Active() + if lc.onBool != nil { + lc.onBool(lc.lastBool) + } + if lc.onActivate != nil { + lc.onActivate() + } + }) + default: + return nil // NativeSlider, NativePopUp — not yet bound + } + if lc.widget == 0 { + return nil + } + w.fixed.Put(lc.widget, w.pt(spec.Rect.X), w.pt(spec.Rect.Y)) + return lc +} diff --git a/open_linux_only.go b/open_linux_only.go index 4d1e0a7..a319c03 100644 --- a/open_linux_only.go +++ b/open_linux_only.go @@ -6,10 +6,31 @@ package window +import ( + "os" + + "github.com/go-widgets/window/internal/gtk" +) + +// A GTK4-hosted backend satisfies the same Backend contract as the from-scratch +// X11/Wayland ones. +var _ Backend = (*gtk.Window)(nil) + // Open returns a live window backed by this environment's display server. It // auto-selects: Wayland when $WAYLAND_DISPLAY is set, X11 otherwise. // +// Setting $GO_WIDGETS_GTK opts into the GTK4-hosted backend instead — GTK owns +// the window, the toolkit's framebuffer goes in a GtkPicture and native controls +// (toolkit.NativeControl) are real GTK widgets overlaid above it. It is opt-in so +// the default stays the dependency-free from-scratch backends; the GTK path needs +// the libgtk-4 runtime present. +// // Android is excluded here and answered by open_android.go, because a binary // there may be running inside an APK, where there is no display server to dial // at all. -func Open(cfg Config) (Backend, error) { return openDisplayServer(cfg) } +func Open(cfg Config) (Backend, error) { + if os.Getenv("GO_WIDGETS_GTK") != "" { + return gtk.Open(cfg.Title, cfg.Width, cfg.Height, cfg.Theme, cfg.RenderScale) + } + return openDisplayServer(cfg) +}