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
37 changes: 24 additions & 13 deletions internal/gtk/gtk_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package gtk

import (
"errors"
"sync/atomic"

"github.com/go-gtk/gtk4"
"github.com/go-widgets/painter"
Expand All @@ -39,6 +40,7 @@ type Window struct {
buf []byte

native map[string]*liveControl
dirty atomic.Bool
}

// Open creates the GTK window (but does not enter the loop; Run does). width and
Expand Down Expand Up @@ -79,31 +81,40 @@ func Open(title string, width, height int, theme *toolkit.Theme, scale float64)
}, nil
}

// Run binds root and drives the GLib main loop until the window is closed,
// presenting a fresh frame every vsync from the window's frame clock.
// Run binds root and drives the GLib main loop until the window is closed. It
// presents a frame only when one was asked for — see [Window.Repaint].
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()
// Present each frame on the frame clock, not once. The root is an
// application's own Surface — an immediate-mode scene whose pixels change as
// data loads, a caret blinks, a list scrolls — so a single present would
// freeze whatever existed before the first layout (and before the window was
// even mapped, the frame clock has not started). AddTickCallback fires on the
// window's GdkFrameClock while it is mapped and is quiescent when it is not,
// so an idle or hidden window costs nothing; syncNative inside frame()
// reconciles the overlaid controls by key, so ticking never rebuilds them or
// fights a caret. It stops when the window closes (loop quits, widget
// unmaps).
w.dirty.Store(true) // draw the first frame once the clock starts (post-map)
// The frame clock is the main-thread pump that turns a Repaint request into a
// present. It ticks while the window is mapped (and only then), and each tick
// draws ONLY if a repaint was asked for since the last one — so an idle window
// costs a flag read per frame, not a full layout + texture upload + recompose.
// This is the same policy the Cocoa back-end runs (present on request, not on a
// timer): go-widgets/application's loop calls Repaint at up to 60 Hz gated by
// the handler's NeedsPresent, so nothing is drawn while nothing changes. A
// single persistent callback avoids exhausting purego's callback table.
w.win.AddTickCallback(func() bool {
w.frame()
if w.dirty.Swap(false) {
w.frame()
}
return true
})
w.loop.Run()
return nil
}

// Repaint asks for a frame from any goroutine. It implements
// [github.com/go-widgets/window.Repainter]: the application present loop calls it
// (gated by the handler's NeedsPresent), and a background producer that has queued
// a scene change may call it directly. It only raises a flag the frame clock reads
// on the main thread, so it makes no GTK call off that thread and never allocates
// a callback — cheap enough to call every tick.
func (w *Window) Repaint() { w.dirty.Store(true) }

// Close quits the loop and drops the window.
func (w *Window) Close() error {
if w.loop != 0 {
Expand Down
33 changes: 33 additions & 0 deletions internal/gtk/repaint_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) the go-widgets/window authors. All rights reserved.
//
// SPDX-License-Identifier: BSD-3-Clause

//go:build linux && !android

package gtk

import "testing"

// TestRepaintGating pins the Repainter contract the application present loop
// relies on: Repaint raises a flag (from any goroutine), and the frame clock
// consumes it exactly once — so an unchanged window presents nothing until the
// next Repaint. It touches only the flag, so it needs no display and runs on the
// plain linux test lane, not just the Xvfb live one.
func TestRepaintGating(t *testing.T) {
var w Window
if w.dirty.Load() {
t.Fatal("a fresh window should not be dirty")
}
w.Repaint()
if !w.dirty.Load() {
t.Fatal("Repaint should raise the dirty flag")
}
// The frame clock's tick consumes the request once...
if !w.dirty.Swap(false) {
t.Fatal("the first tick after Repaint should see dirty and present")
}
// ...and finds nothing to do on the next tick, until Repaint is called again.
if w.dirty.Swap(false) {
t.Fatal("a tick with no intervening Repaint should present nothing")
}
}
8 changes: 6 additions & 2 deletions open_linux_only.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@ import (
)

// A GTK4-hosted backend satisfies the same Backend contract as the from-scratch
// X11/Wayland ones.
var _ Backend = (*gtk.Window)(nil)
// X11/Wayland ones, and the Repainter capability that lets the application present
// loop drive it on demand instead of blitting every tick.
var (
_ Backend = (*gtk.Window)(nil)
_ Repainter = (*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.
Expand Down
Loading