Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
116 changes: 116 additions & 0 deletions internal/gtk/gtk_linux.go
Original file line number Diff line number Diff line change
@@ -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)
}
65 changes: 65 additions & 0 deletions internal/gtk/gtk_live_linux_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading