From d95ddf930193254fc85ba78c3b2944558188d4d8 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 31 Aug 2026 20:14:19 +0200 Subject: [PATCH] gtk: present on demand (Repainter), not every tick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GTK back-end blitted a full frame every vsync — one layout + texture upload + recompose at 60 Hz even on an idle, unchanged window. Implement window.Repainter instead, the policy the Cocoa back-end already uses: Repaint() raises a flag (cheap, callable from any goroutine, no GTK call off the main thread, no callback allocation), and the frame clock — the one persistent main-thread pump — presents only when the flag is set. go-widgets/application's present loop calls Repaint at up to 60 Hz gated by the handler's NeedsPresent, so a still window draws nothing. Measured with the reader under Xvfb: idle presents fall from ~100% of ticks to ~7% (heartbeat + spinner cadence) — a ~15× cut in idle uploads — with the UI unchanged. A compile-time assertion pins the capability; TestRepaintGating pins the present-once-per-Repaint contract without a display. --- internal/gtk/gtk_linux.go | 37 +++++++++++++++++++----------- internal/gtk/repaint_linux_test.go | 33 ++++++++++++++++++++++++++ open_linux_only.go | 8 +++++-- 3 files changed, 63 insertions(+), 15 deletions(-) create mode 100644 internal/gtk/repaint_linux_test.go diff --git a/internal/gtk/gtk_linux.go b/internal/gtk/gtk_linux.go index c0038bf..ab9aa6f 100644 --- a/internal/gtk/gtk_linux.go +++ b/internal/gtk/gtk_linux.go @@ -18,6 +18,7 @@ package gtk import ( "errors" + "sync/atomic" "github.com/go-gtk/gtk4" "github.com/go-widgets/painter" @@ -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 @@ -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 { diff --git a/internal/gtk/repaint_linux_test.go b/internal/gtk/repaint_linux_test.go new file mode 100644 index 0000000..a4baef7 --- /dev/null +++ b/internal/gtk/repaint_linux_test.go @@ -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") + } +} diff --git a/open_linux_only.go b/open_linux_only.go index a319c03..812fa3e 100644 --- a/open_linux_only.go +++ b/open_linux_only.go @@ -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.