Compare commits

..

1 Commits

Author SHA1 Message Date
Joe Julian 9bfa6bc1c2 app: relock contexts after refresh
This fixes an Android regression introduced by

  3e601e73c4
  app: optimize window context locking

On Android, that change can leave the app window visible but unrendered:
the screen stays black instead of drawing the UI.

The regression is in app/window.go: Refresh can update the underlying
surface state without guaranteeing a subsequent Lock before rendering.

That is a problem for Android, where Refresh and Lock have distinct
roles:

- androidContext.Refresh stages the native window for the EGL surface
- androidContext.Lock performs CreateSurface, when needed, and MakeCurrent

In other words, after Refresh, Android may require another Lock before
GPU work can proceed correctly.

This patch keeps explicit locking, but avoids unconditional per-frame
Lock/Unlock calls. It adds a small amount of state to Window:

- ctxNeedsLock is set when creating a context
- ctxNeedsLock is set after Refresh
- ctxNeedsLock is set after explicit unlock/release paths
- Lock is called before GPU work only when ctxNeedsLock is true

That keeps the optimized steady-state path while restoring the required
Refresh -> Lock sequencing.

I also added a regression test for validateAndProcess that checks:

- Refresh forces a re-lock before drawing/present
- steady-state frames do not re-lock redundantly

Verification:
- go test ./app

Signed-off-by: Joe Julian <me@joejulian.name>
2026-04-16 15:00:49 -07:00
20 changed files with 255 additions and 319 deletions
+2 -4
View File
@@ -207,9 +207,7 @@ const (
CFS_POINT = 0x0002 CFS_POINT = 0x0002
CFS_CANDIDATEPOS = 0x0040 CFS_CANDIDATEPOS = 0x0040
HWND_TOP = syscall.Handle(0) HWND_TOPMOST = ^(uint32(1) - 1) // -1
HWND_TOPMOST = ^(syscall.Handle(1) - 1) // -1
HWND_NOTOPMOST = ^(syscall.Handle(2) - 1) // -2
HTCAPTION = 2 HTCAPTION = 2
HTCLIENT = 1 HTCLIENT = 1
@@ -784,7 +782,7 @@ func SetWindowPlacement(hwnd syscall.Handle, wp *WindowPlacement) {
_SetWindowPlacement.Call(uintptr(hwnd), uintptr(unsafe.Pointer(wp))) _SetWindowPlacement.Call(uintptr(hwnd), uintptr(unsafe.Pointer(wp)))
} }
func SetWindowPos(hwnd, hwndInsertAfter syscall.Handle, x, y, dx, dy int32, style uintptr) { func SetWindowPos(hwnd syscall.Handle, hwndInsertAfter uint32, x, y, dx, dy int32, style uintptr) {
_SetWindowPos.Call(uintptr(hwnd), uintptr(hwndInsertAfter), _SetWindowPos.Call(uintptr(hwnd), uintptr(hwndInsertAfter),
uintptr(x), uintptr(y), uintptr(x), uintptr(y),
uintptr(dx), uintptr(dy), uintptr(dx), uintptr(dy),
+17 -30
View File
@@ -369,7 +369,8 @@ func windowProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uintptr
w.update() w.update()
case windows.WM_WINDOWPOSCHANGED: case windows.WM_WINDOWPOSCHANGED:
w.update() w.update()
return 0 case windows.WM_SIZE:
w.update()
case windows.WM_GETMINMAXINFO: case windows.WM_GETMINMAXINFO:
mm := (*windows.MinMaxInfo)(unsafe.Pointer(lParam)) mm := (*windows.MinMaxInfo)(unsafe.Pointer(lParam))
@@ -412,7 +413,6 @@ func windowProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uintptr
icaret := image.Pt(int(caret.X+.5), int(caret.Y+.5)) icaret := image.Pt(int(caret.X+.5), int(caret.Y+.5))
windows.ImmSetCompositionWindow(imc, icaret.X, icaret.Y) windows.ImmSetCompositionWindow(imc, icaret.X, icaret.Y)
windows.ImmSetCandidateWindow(imc, icaret.X, icaret.Y) windows.ImmSetCandidateWindow(imc, icaret.X, icaret.Y)
return windows.TRUE
case windows.WM_IME_COMPOSITION: case windows.WM_IME_COMPOSITION:
imc := windows.ImmGetContext(w.hwnd) imc := windows.ImmGetContext(w.hwnd)
if imc == 0 { if imc == 0 {
@@ -495,7 +495,10 @@ func getModifiers() key.Modifiers {
// hitTest returns the non-client area hit by the point, needed to // hitTest returns the non-client area hit by the point, needed to
// process WM_NCHITTEST. // process WM_NCHITTEST.
func (w *window) hitTest(x, y int) uintptr { func (w *window) hitTest(x, y int) uintptr {
if w.config.Mode == Windowed { if w.config.Mode != Windowed {
// Only windowed mode should allow resizing.
return windows.HTCLIENT
}
// Check for resize handle before system actions; otherwise it can be impossible to // Check for resize handle before system actions; otherwise it can be impossible to
// resize a custom-decorations window when the system move area is flush with the // resize a custom-decorations window when the system move area is flush with the
// edge of the window. // edge of the window.
@@ -521,7 +524,6 @@ func (w *window) hitTest(x, y int) uintptr {
case right: case right:
return windows.HTRIGHT return windows.HTRIGHT
} }
}
p := f32.Pt(float32(x), float32(y)) p := f32.Pt(float32(x), float32(y))
if a, ok := w.w.ActionAt(p); ok && a == system.ActionMove { if a, ok := w.w.ActionAt(p); ok && a == system.ActionMove {
return windows.HTCAPTION return windows.HTCAPTION
@@ -699,13 +701,7 @@ func (w *window) ReadClipboard() {
w.readClipboard() w.readClipboard()
} }
func (w *window) readClipboard() (cerr error) { func (w *window) readClipboard() error {
defer func() {
if cerr != nil {
w.processDataEvent("")
}
}()
if err := windows.OpenClipboard(w.hwnd); err != nil { if err := windows.OpenClipboard(w.hwnd); err != nil {
return err return err
} }
@@ -720,17 +716,13 @@ func (w *window) readClipboard() (cerr error) {
} }
defer windows.GlobalUnlock(mem) defer windows.GlobalUnlock(mem)
content := gowindows.UTF16PtrToString((*uint16)(unsafe.Pointer(ptr))) content := gowindows.UTF16PtrToString((*uint16)(unsafe.Pointer(ptr)))
w.processDataEvent(content)
return nil
}
func (w *window) processDataEvent(content string) {
w.ProcessEvent(transfer.DataEvent{ w.ProcessEvent(transfer.DataEvent{
Type: "application/text", Type: "application/text",
Open: func() io.ReadCloser { Open: func() io.ReadCloser {
return io.NopCloser(strings.NewReader(content)) return io.NopCloser(strings.NewReader(content))
}, },
}) })
return nil
} }
func (w *window) Configure(options []Option) { func (w *window) Configure(options []Option) {
@@ -747,16 +739,7 @@ func (w *window) Configure(options []Option) {
style := windows.GetWindowLong(w.hwnd, windows.GWL_STYLE) style := windows.GetWindowLong(w.hwnd, windows.GWL_STYLE)
var showMode int32 var showMode int32
var x, y, width, height int32 var x, y, width, height int32
swpStyle := uintptr(windows.SWP_FRAMECHANGED) swpStyle := uintptr(windows.SWP_NOZORDER | windows.SWP_FRAMECHANGED)
if cnf.TopMost == w.config.TopMost {
// Don't change the z-order if TopMost didn't change.
swpStyle |= windows.SWP_NOZORDER
}
hwndAfter := windows.HWND_NOTOPMOST
if cnf.TopMost {
hwndAfter = windows.HWND_TOPMOST
}
w.config.TopMost = cnf.TopMost
winStyle := uintptr(windows.WS_OVERLAPPEDWINDOW) winStyle := uintptr(windows.WS_OVERLAPPEDWINDOW)
style &^= winStyle style &^= winStyle
switch cnf.Mode { switch cnf.Mode {
@@ -808,7 +791,7 @@ func (w *window) Configure(options []Option) {
// Note: these invocation all trigger the windows callback method which may process a pending system.ActionCenter // Note: these invocation all trigger the windows callback method which may process a pending system.ActionCenter
// action, so SetWindowPos should come first so as to not "overwrite" system.ActionCenter. // action, so SetWindowPos should come first so as to not "overwrite" system.ActionCenter.
windows.SetWindowPos(w.hwnd, hwndAfter, x, y, width, height, swpStyle) windows.SetWindowPos(w.hwnd, 0, x, y, width, height, swpStyle)
windows.SetWindowLong(w.hwnd, windows.GWL_STYLE, style) windows.SetWindowLong(w.hwnd, windows.GWL_STYLE, style)
windows.ShowWindow(w.hwnd, showMode) windows.ShowWindow(w.hwnd, showMode)
} }
@@ -929,15 +912,19 @@ func (w *window) Perform(acts system.Action) {
y := (mi.Bottom - mi.Top - dy) / 2 y := (mi.Bottom - mi.Top - dy) / 2
windows.SetWindowPos(w.hwnd, 0, x, y, dx, dy, windows.SWP_NOZORDER|windows.SWP_FRAMECHANGED) windows.SetWindowPos(w.hwnd, 0, x, y, dx, dy, windows.SWP_NOZORDER|windows.SWP_FRAMECHANGED)
case system.ActionRaise: case system.ActionRaise:
windows.SetForegroundWindow(w.hwnd) w.raise()
windows.SetWindowPos(w.hwnd, windows.HWND_TOP, 0, 0, 0, 0,
windows.SWP_NOMOVE|windows.SWP_NOSIZE|windows.SWP_SHOWWINDOW)
case system.ActionClose: case system.ActionClose:
windows.PostMessage(w.hwnd, windows.WM_CLOSE, 0, 0) windows.PostMessage(w.hwnd, windows.WM_CLOSE, 0, 0)
} }
}) })
} }
func (w *window) raise() {
windows.SetForegroundWindow(w.hwnd)
windows.SetWindowPos(w.hwnd, windows.HWND_TOPMOST, 0, 0, 0, 0,
windows.SWP_NOMOVE|windows.SWP_NOSIZE|windows.SWP_SHOWWINDOW)
}
func convertKeyCode(code uintptr) (key.Name, bool) { func convertKeyCode(code uintptr) (key.Name, bool) {
if '0' <= code && code <= '9' || 'A' <= code && code <= 'Z' { if '0' <= code && code <= '9' || 'A' <= code && code <= 'Z' {
return key.Name(rune(code)), true return key.Name(rune(code)), true
+26 -9
View File
@@ -46,6 +46,10 @@ type Window struct {
ctx context ctx context
gpu gpu.GPU gpu gpu.GPU
// ctxNeedsLock tracks whether the rendering context must be made
// current again before the next GPU operation. Refresh paths, surface
// loss, and explicit unlocks all invalidate the current binding.
ctxNeedsLock bool
// timer tracks the delayed invalidate goroutine. // timer tracks the delayed invalidate goroutine.
timer struct { timer struct {
// quit is shuts down the goroutine. // quit is shuts down the goroutine.
@@ -146,6 +150,7 @@ func (w *Window) validateAndProcess(size image.Point, sync bool, frame *op.Ops,
if err != nil { if err != nil {
return err return err
} }
w.ctxNeedsLock = true
sync = true sync = true
} }
} }
@@ -162,17 +167,19 @@ func (w *Window) validateAndProcess(size image.Point, sync bool, frame *op.Ops,
} }
return err return err
} }
w.ctxNeedsLock = true
} }
if w.ctx != nil { if w.ctx != nil && w.ctxNeedsLock {
if err := w.ctx.Lock(); err != nil { if err := w.ctx.Lock(); err != nil {
w.destroyGPU() w.destroyGPU()
return err return err
} }
w.ctxNeedsLock = false
} }
if w.gpu == nil && !w.nocontext { if w.gpu == nil && !w.nocontext {
gpu, err := gpu.New(w.ctx.API()) gpu, err := gpu.New(w.ctx.API())
if err != nil { if err != nil {
w.ctx.Unlock() w.unlockContext()
w.destroyGPU() w.destroyGPU()
return err return err
} }
@@ -180,7 +187,7 @@ func (w *Window) validateAndProcess(size image.Point, sync bool, frame *op.Ops,
} }
if w.gpu != nil { if w.gpu != nil {
if err := w.frame(frame, size); err != nil { if err := w.frame(frame, size); err != nil {
w.ctx.Unlock() w.unlockContext()
if errors.Is(err, errOutOfDate) { if errors.Is(err, errOutOfDate) {
// GPU surface needs refreshing. // GPU surface needs refreshing.
sync = true sync = true
@@ -200,7 +207,6 @@ func (w *Window) validateAndProcess(size image.Point, sync bool, frame *op.Ops,
var err error var err error
if w.gpu != nil { if w.gpu != nil {
err = w.ctx.Present() err = w.ctx.Present()
w.ctx.Unlock()
} }
return err return err
} }
@@ -439,7 +445,6 @@ func (c *callbacks) EditorState() editorState {
func (c *callbacks) SetComposingRegion(r key.Range) { func (c *callbacks) SetComposingRegion(r key.Range) {
c.w.imeState.compose = r c.w.imeState.compose = r
c.w.driver.ProcessEvent(key.CompositionEvent(r))
} }
func (c *callbacks) EditorInsert(text string) { func (c *callbacks) EditorInsert(text string) {
@@ -504,16 +509,26 @@ func (c *callbacks) ActionAt(p f32.Point) (system.Action, bool) {
return c.w.queue.ActionAt(p) return c.w.queue.ActionAt(p)
} }
func (w *Window) unlockContext() {
if w.ctx == nil || w.ctxNeedsLock {
return
}
w.ctx.Unlock()
w.ctxNeedsLock = true
}
func (w *Window) destroyGPU() { func (w *Window) destroyGPU() {
if w.gpu != nil { if w.gpu != nil {
w.ctx.Lock() if err := w.ctx.Lock(); err == nil {
w.gpu.Release() w.gpu.Release()
w.ctx.Unlock() w.ctx.Unlock()
}
w.gpu = nil w.gpu = nil
} }
if w.ctx != nil { if w.ctx != nil {
w.ctx.Release() w.ctx.Release()
w.ctx = nil w.ctx = nil
w.ctxNeedsLock = false
} }
} }
@@ -656,11 +671,13 @@ func (w *Window) processEvent(e event.Event) bool {
w.coalesced.destroy = &e2 w.coalesced.destroy = &e2
case ViewEvent: case ViewEvent:
if !e2.Valid() && w.gpu != nil { if !e2.Valid() && w.gpu != nil {
w.ctx.Lock() if err := w.ctx.Lock(); err == nil {
w.gpu.Release() w.gpu.Release()
w.gpu = nil
w.ctx.Unlock() w.ctx.Unlock()
} }
w.gpu = nil
w.ctxNeedsLock = true
}
w.coalesced.view = &e2 w.coalesced.view = &e2
case ConfigEvent: case ConfigEvent:
w.decorations.Decorations.Maximized = e2.Config.Mode == Maximized w.decorations.Decorations.Maximized = e2.Config.Mode == Maximized
@@ -972,7 +989,7 @@ func Decorated(enabled bool) Option {
// TopMost windows will be rendered above all other non-top-most windows. // TopMost windows will be rendered above all other non-top-most windows.
// //
// TopMost windows are supported on macOS, Windows. // TopMost windows are only supported on MacOS currently.
func TopMost(enabled bool) Option { func TopMost(enabled bool) Option {
return func(_ unit.Metric, cnf *Config) { return func(_ unit.Metric, cnf *Config) {
cnf.TopMost = enabled cnf.TopMost = enabled
+107
View File
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: Unlicense OR MIT
package app
import (
"image"
"image/color"
"testing"
"gioui.org/gpu"
"gioui.org/op"
)
func TestValidateAndProcessRelocksAfterRefresh(t *testing.T) {
ctx := &testContext{}
w := &Window{
ctx: ctx,
gpu: &testGPU{},
ctxNeedsLock: false,
}
if err := w.validateAndProcess(image.Pt(320, 240), true, new(op.Ops), nil); err != nil {
t.Fatalf("validateAndProcess returned error: %v", err)
}
want := []string{"refresh", "lock", "render-target", "present"}
if got := ctx.ops; !equalStringSlices(got, want) {
t.Fatalf("unexpected call order:\n got %v\n want %v", got, want)
}
if w.ctxNeedsLock {
t.Fatalf("context should remain current after a successful frame")
}
}
func TestValidateAndProcessSkipsRedundantRelock(t *testing.T) {
ctx := &testContext{}
w := &Window{
ctx: ctx,
gpu: &testGPU{},
ctxNeedsLock: false,
}
if err := w.validateAndProcess(image.Pt(320, 240), false, new(op.Ops), nil); err != nil {
t.Fatalf("validateAndProcess returned error: %v", err)
}
want := []string{"render-target", "present"}
if got := ctx.ops; !equalStringSlices(got, want) {
t.Fatalf("unexpected call order:\n got %v\n want %v", got, want)
}
}
type testContext struct {
ops []string
}
func (c *testContext) API() gpu.API {
return nil
}
func (c *testContext) RenderTarget() (gpu.RenderTarget, error) {
c.ops = append(c.ops, "render-target")
return gpu.OpenGLRenderTarget{}, nil
}
func (c *testContext) Present() error {
c.ops = append(c.ops, "present")
return nil
}
func (c *testContext) Refresh() error {
c.ops = append(c.ops, "refresh")
return nil
}
func (c *testContext) Release() {}
func (c *testContext) Lock() error {
c.ops = append(c.ops, "lock")
return nil
}
func (c *testContext) Unlock() {
c.ops = append(c.ops, "unlock")
}
type testGPU struct{}
func (g *testGPU) Release() {}
func (g *testGPU) Clear(color.NRGBA) {}
func (g *testGPU) Frame(*op.Ops, gpu.RenderTarget, image.Point) error {
return nil
}
func equalStringSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+1 -1
View File
@@ -106,7 +106,7 @@ func parseLoader(ld *opentype.Loader) (*fontapi.Font, giofont.Font, error) {
// Face many be invoked any number of times and is safe so long as each return value is // Face many be invoked any number of times and is safe so long as each return value is
// only used by one goroutine. // only used by one goroutine.
func (f Face) Face() *fontapi.Face { func (f Face) Face() *fontapi.Face {
return fontapi.NewFace(f.face) return &fontapi.Face{Font: f.face}
} }
// FontFace returns a text.Font with populated font metadata for the // FontFace returns a text.Font with populated font metadata for the
+9
View File
@@ -61,10 +61,14 @@ func (h *Hover) Update(q input.Source) bool {
h.entered = false h.entered = false
} }
case pointer.Enter: case pointer.Enter:
if !h.entered {
h.pid = e.PointerID h.pid = e.PointerID
}
if h.pid == e.PointerID {
h.entered = true h.entered = true
} }
} }
}
return h.entered return h.entered
} }
@@ -218,7 +222,12 @@ func (c *Click) Update(q input.Source) (ClickEvent, bool) {
if e.Source == pointer.Mouse && e.Buttons != pointer.ButtonPrimary { if e.Source == pointer.Mouse && e.Buttons != pointer.ButtonPrimary {
break break
} }
if !c.hovered {
c.pid = e.PointerID c.pid = e.PointerID
}
if c.pid != e.PointerID {
break
}
c.pressed = true c.pressed = true
if e.Time-c.clickedAt < doubleClickDuration { if e.Time-c.clickedAt < doubleClickDuration {
c.clicks++ c.clicks++
-72
View File
@@ -100,78 +100,6 @@ func TestMouseClicks(t *testing.T) {
} }
} }
func TestClickPointerIDReassignment(t *testing.T) {
// A Click must accept a Press from a PointerID that differs from the
// one its hovered state was previously associated with. Some backends
// reassign a single physical pointer's ID over its lifetime — e.g. the
// Windows pointer API across focus changes — and locking the gesture
// to the first observed ID would silently drop every subsequent press.
//
// The sequence below puts the gesture into the buggy state through
// public events alone: a press under PointerID 1 starts an active
// press cycle, a Move under PointerID 2 arrives mid-press (which the
// router routes as an Enter for PID 2 but the gesture's Enter handler
// is a no-op for pid while pressed), then PID 1 releases. After this,
// the router has the gesture entered for PID 2 (so the next event
// under PID 2 won't trigger another Enter) but the gesture itself
// still has pid=1.
var click Click
var ops op.Ops
rect := image.Rect(0, 0, 100, 100)
stack := clip.Rect(rect).Push(&ops)
click.Add(&ops)
stack.Pop()
var r input.Router
click.Update(r.Source())
r.Frame(&ops)
drain := func() {
for {
if _, ok := click.Update(r.Source()); !ok {
return
}
}
}
// Press under PointerID 1.
r.Queue(
pointer.Event{Kind: pointer.Move, Source: pointer.Mouse, Position: f32.Pt(50, 50), PointerID: 1},
pointer.Event{Kind: pointer.Press, Source: pointer.Mouse, Buttons: pointer.ButtonPrimary, Position: f32.Pt(50, 50), PointerID: 1},
)
drain()
// Move under PointerID 2 while PointerID 1 is still pressed. The
// router records the gesture as entered for PointerID 2 but the
// gesture's Enter handler is a no-op for pid because c.pressed.
r.Queue(pointer.Event{Kind: pointer.Move, Source: pointer.Mouse, Position: f32.Pt(50, 50), PointerID: 2})
drain()
// Release PointerID 1. PointerID 1's press tracking ends; the
// gesture's recorded pid stays at 1.
r.Queue(pointer.Event{Kind: pointer.Release, Source: pointer.Mouse, Position: f32.Pt(50, 50), PointerID: 1})
drain()
// Press under PointerID 2. The router won't refire Enter for PID 2
// (the gesture is already in PID 2's entered set), so the gesture's
// only chance to refresh its pid is the Press handler itself.
r.Queue(pointer.Event{Kind: pointer.Press, Source: pointer.Mouse, Buttons: pointer.ButtonPrimary, Position: f32.Pt(50, 50), PointerID: 2})
var sawPress bool
for {
ev, ok := click.Update(r.Source())
if !ok {
break
}
if ev.Kind == KindPress {
sawPress = true
}
}
if !sawPress {
t.Fatal("expected KindPress for press under reassigned PointerID; gesture dropped the press because of stale recorded pid")
}
}
func mouseClickEvents(times ...time.Duration) []event.Event { func mouseClickEvents(times ...time.Duration) []event.Event {
press := pointer.Event{ press := pointer.Event{
Kind: pointer.Press, Kind: pointer.Press,
+1 -1
View File
@@ -5,7 +5,7 @@ go 1.24.0
require ( require (
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d
gioui.org/shader v1.0.8 gioui.org/shader v1.0.8
github.com/go-text/typesetting v0.3.4 github.com/go-text/typesetting v0.3.0
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0
golang.org/x/image v0.26.0 golang.org/x/image v0.26.0
+4 -4
View File
@@ -3,10 +3,10 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8v
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU= github.com/go-text/typesetting v0.3.0 h1:OWCgYpp8njoxSRpwrdd1bQOxdjOXDj9Rqart9ML4iF4=
github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY= github.com/go-text/typesetting v0.3.0/go.mod h1:qjZLkhRgOEYMhU9eHBr3AR4sfnGJvOXNLt8yRAySFuY=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos= github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80= golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80=
+6 -2
View File
@@ -41,10 +41,14 @@ var (
_eglWaitClient *syscall.Proc _eglWaitClient *syscall.Proc
) )
var loadOnce = sync.OnceValue(loadDLLs) var loadOnce sync.Once
func loadEGL() error { func loadEGL() error {
return loadOnce() var err error
loadOnce.Do(func() {
err = loadDLLs()
})
return err
} }
func loadDLLs() error { func loadDLLs() error {
+4 -19
View File
@@ -739,10 +739,6 @@ func (q *pointerQueue) Push(handlers map[event.Tag]*handler, state pointerState,
state.pointers = nil state.pointers = nil
return state, evts return state, evts
} }
if e.Kind == pointer.Scroll {
// Scroll events are not bound to a pointer; see pointer.Event.PointerID.
return state, q.deliverScrollEvent(handlers, evts, e)
}
state, pidx := state.pointerOf(e) state, pidx := state.pointerOf(e)
p := state.pointers[pidx] p := state.pointers[pidx]
@@ -760,13 +756,14 @@ func (q *pointerQueue) Push(handlers map[event.Tag]*handler, state pointerState,
if p.pressed { if p.pressed {
p, evts = q.deliverDragEvent(handlers, p, evts) p, evts = q.deliverDragEvent(handlers, p, evts)
} }
case pointer.Leave:
p, evts, state.cursor, _ = q.deliverEnterLeaveEvents(handlers, state.cursor, p, evts, e)
case pointer.Release: case pointer.Release:
evts = q.deliverEvent(handlers, p, evts, e) evts = q.deliverEvent(handlers, p, evts, e)
p.pressed = false p.pressed = false
p, evts, state.cursor, _ = q.deliverEnterLeaveEvents(handlers, state.cursor, p, evts, e) p, evts, state.cursor, _ = q.deliverEnterLeaveEvents(handlers, state.cursor, p, evts, e)
p, evts = q.deliverDropEvent(handlers, p, evts) p, evts = q.deliverDropEvent(handlers, p, evts)
case pointer.Scroll:
p, evts, state.cursor, _ = q.deliverEnterLeaveEvents(handlers, state.cursor, p, evts, e)
evts = q.deliverEvent(handlers, p, evts, e)
default: default:
panic("unsupported pointer event type") panic("unsupported pointer event type")
} }
@@ -783,18 +780,6 @@ func (q *pointerQueue) Push(handlers map[event.Tag]*handler, state pointerState,
return state, evts return state, evts
} }
// deliverScrollEvent delivers scroll events to the handlers hit by the event coordinate.
func (q *pointerQueue) deliverScrollEvent(handlers map[event.Tag]*handler, evts []taggedEvent, e pointer.Event) []taggedEvent {
var hits []event.Tag
q.hitTest(e.Position, func(n *hitNode) bool {
if _, ok := handlers[n.tag]; ok {
hits = addHandler(hits, n.tag)
}
return true
})
return q.deliverEvent(handlers, pointerInfo{handlers: hits}, evts, e)
}
func (q *pointerQueue) deliverEvent(handlers map[event.Tag]*handler, p pointerInfo, evts []taggedEvent, e pointer.Event) []taggedEvent { func (q *pointerQueue) deliverEvent(handlers map[event.Tag]*handler, p pointerInfo, evts []taggedEvent, e pointer.Event) []taggedEvent {
if p.pressed && len(p.handlers) == 1 { if p.pressed && len(p.handlers) == 1 {
e.Priority = pointer.Grabbed e.Priority = pointer.Grabbed
@@ -825,7 +810,7 @@ func (q *pointerQueue) deliverEvent(handlers map[event.Tag]*handler, p pointerIn
func (q *pointerQueue) deliverEnterLeaveEvents(handlers map[event.Tag]*handler, cursor pointer.Cursor, p pointerInfo, evts []taggedEvent, e pointer.Event) (pointerInfo, []taggedEvent, pointer.Cursor, bool) { func (q *pointerQueue) deliverEnterLeaveEvents(handlers map[event.Tag]*handler, cursor pointer.Cursor, p pointerInfo, evts []taggedEvent, e pointer.Event) (pointerInfo, []taggedEvent, pointer.Cursor, bool) {
changed := false changed := false
var hits []event.Tag var hits []event.Tag
if e.Kind == pointer.Leave || e.Source != pointer.Mouse && !p.pressed && e.Kind != pointer.Press { if e.Source != pointer.Mouse && !p.pressed && e.Kind != pointer.Press {
// Consider non-mouse pointers leaving when they're released. // Consider non-mouse pointers leaving when they're released.
} else { } else {
var transSrc *pointerFilter var transSrc *pointerFilter
-76
View File
@@ -255,45 +255,6 @@ func TestPointerMove(t *testing.T) {
assertEventPointerTypeSequence(t, events(&r, -1, filter(handler2)), pointer.Enter, pointer.Move, pointer.Leave, pointer.Cancel) assertEventPointerTypeSequence(t, events(&r, -1, filter(handler2)), pointer.Enter, pointer.Move, pointer.Leave, pointer.Cancel)
} }
func TestPointerLeave(t *testing.T) {
handler := new(int)
var ops op.Ops
filter := pointer.Filter{
Target: handler,
Kinds: pointer.Move | pointer.Enter | pointer.Leave | pointer.Cancel,
}
defer clip.Rect(image.Rect(0, 0, 100, 100)).Push(&ops).Pop()
event.Op(&ops, handler)
var r Router
events(&r, -1, filter)
r.Frame(&ops)
r.Queue(
pointer.Event{
Kind: pointer.Move,
Source: pointer.Mouse,
PointerID: 1,
Position: f32.Pt(50, 50),
},
pointer.Event{
Kind: pointer.Leave,
Source: pointer.Mouse,
PointerID: 1,
Position: f32.Pt(50, 50),
},
)
assertEventPointerTypeSequence(t, events(&r, -1, filter), pointer.Enter, pointer.Move, pointer.Leave)
r.Queue(pointer.Event{
Kind: pointer.Move,
Source: pointer.Mouse,
PointerID: 1,
Position: f32.Pt(50, 50),
})
assertEventPointerTypeSequence(t, events(&r, -1, filter), pointer.Enter, pointer.Move)
}
func TestPointerTypes(t *testing.T) { func TestPointerTypes(t *testing.T) {
handler := new(int) handler := new(int)
var ops op.Ops var ops op.Ops
@@ -1384,40 +1345,3 @@ func events(r *Router, n int, filters ...event.Filter) []event.Event {
} }
return events return events
} }
// TestPointerScrollDoesNotTrackPointer queues two events over two cursor
// regions. The Move puts the live pointer over the button (CursorPointer);
// the Scroll happens over the cell (CursorText) and must not update the
// cursor.
func TestPointerScrollDoesNotTrackPointer(t *testing.T) {
var ops op.Ops
button := clip.Rect(image.Rect(0, 0, 50, 50)).Push(&ops)
pointer.CursorPointer.Add(&ops)
button.Pop()
cell := clip.Rect(image.Rect(100, 0, 200, 50)).Push(&ops)
pointer.CursorText.Add(&ops)
cell.Pop()
var r Router
r.Frame(&ops)
r.Queue(
pointer.Event{
Kind: pointer.Move,
Source: pointer.Mouse,
Position: f32.Pt(25, 25),
},
pointer.Event{
Kind: pointer.Scroll,
Source: pointer.Mouse,
Position: f32.Pt(150, 25),
Scroll: f32.Pt(0, 1),
},
)
if got, want := r.Cursor(), pointer.CursorPointer; got != want {
t.Errorf("got %q, want %q (scroll position must not update the cursor; "+
"the live pointer's last position is what determines it)", got, want)
}
}
+1 -8
View File
@@ -419,7 +419,7 @@ func (f *filter) Merge(f2 filter) {
func (f *filter) Matches(e event.Event) bool { func (f *filter) Matches(e event.Event) bool {
switch e.(type) { switch e.(type) {
case key.FocusEvent, key.SnippetEvent, key.EditEvent, key.SelectionEvent, key.CompositionEvent: case key.FocusEvent, key.SnippetEvent, key.EditEvent, key.SelectionEvent:
return f.focusable return f.focusable
default: default:
return f.pointer.Matches(e) return f.pointer.Matches(e)
@@ -463,13 +463,6 @@ func (q *Router) processEvent(e event.Event, system bool) {
evts = append(evts, taggedEvent{tag: f, event: e}) evts = append(evts, taggedEvent{tag: f, event: e})
} }
q.changeState(e, state, evts) q.changeState(e, state, evts)
case key.CompositionEvent:
e = key.CompositionEvent(rangeNorm(key.Range(e)))
var evts []taggedEvent
if f := state.focus; f != nil {
evts = append(evts, taggedEvent{tag: f, event: e})
}
q.changeState(e, state, evts)
case key.EditEvent, key.FocusEvent, key.SelectionEvent: case key.EditEvent, key.FocusEvent, key.SelectionEvent:
var evts []taggedEvent var evts []taggedEvent
if f := state.focus; f != nil { if f := state.focus; f != nil {
-4
View File
@@ -77,9 +77,6 @@ type Caret struct {
// SelectionEvent is generated when an input method changes the selection. // SelectionEvent is generated when an input method changes the selection.
type SelectionEvent Range type SelectionEvent Range
// CompositionEvent is generated when an input method changes the composing range.
type CompositionEvent Range
// SnippetEvent is generated when the snippet range is updated by an // SnippetEvent is generated when the snippet range is updated by an
// input method. // input method.
type SnippetEvent Range type SnippetEvent Range
@@ -249,7 +246,6 @@ func (h InputHintOp) Add(o *op.Ops) {
func (EditEvent) ImplementsEvent() {} func (EditEvent) ImplementsEvent() {}
func (Event) ImplementsEvent() {} func (Event) ImplementsEvent() {}
func (FocusEvent) ImplementsEvent() {} func (FocusEvent) ImplementsEvent() {}
func (CompositionEvent) ImplementsEvent() {}
func (SnippetEvent) ImplementsEvent() {} func (SnippetEvent) ImplementsEvent() {}
func (SelectionEvent) ImplementsEvent() {} func (SelectionEvent) ImplementsEvent() {}
+1 -3
View File
@@ -19,9 +19,7 @@ type Event struct {
Source Source Source Source
// PointerID is the id for the pointer and can be used // PointerID is the id for the pointer and can be used
// to track a particular pointer from Press to // to track a particular pointer from Press to
// Release. Populated for Press, Release, Move, Drag, // Release.
// Enter, Leave, and Cancel; Scroll events are not
// bound to a tracked pointer and leave it zero.
PointerID ID PointerID ID
// Priority is the priority of the receiving handler // Priority is the priority of the receiving handler
// for this event. // for this event.
+14 -12
View File
@@ -103,7 +103,8 @@ func (l *line) insertTrailingSyntheticNewline(newLineClusterIdx int) {
clusterIndex: newLineClusterIdx, clusterIndex: newLineClusterIdx,
glyphCount: 0, glyphCount: 0,
runeCount: 1, runeCount: 1,
advance: 0, xAdvance: 0,
yAdvance: 0,
xOffset: 0, xOffset: 0,
yOffset: 0, yOffset: 0,
} }
@@ -159,9 +160,9 @@ type glyph struct {
// runeCount is the quantity of runes in the source text that this glyph // runeCount is the quantity of runes in the source text that this glyph
// corresponds to. // corresponds to.
runeCount int runeCount int
// advance is the distance the dot moves when laying out the glyph along // xAdvance and yAdvance describe the distance the dot moves when
// the run's primary axis. // laying out the glyph on the X or Y axis.
advance fixed.Int26_6 xAdvance, yAdvance fixed.Int26_6
// xOffset and yOffset describe offsets from the dot that should be // xOffset and yOffset describe offsets from the dot that should be
// applied when rendering the glyph. // applied when rendering the glyph.
xOffset, yOffset fixed.Int26_6 xOffset, yOffset fixed.Int26_6
@@ -269,9 +270,8 @@ func newShaperImpl(systemFonts bool, collection []FontFace) *shaperImpl {
// in the order in which they are loaded, with the first face being the default. // in the order in which they are loaded, with the first face being the default.
func (s *shaperImpl) Load(f FontFace) { func (s *shaperImpl) Load(f FontFace) {
desc := opentype.FontToDescription(f.Font) desc := opentype.FontToDescription(f.Font)
face := f.Face.Face() s.fontMap.AddFace(f.Face.Face(), fontscan.Location{File: fmt.Sprint(desc)}, desc)
s.fontMap.AddFace(face, fontscan.Location{File: fmt.Sprint(desc)}, desc) s.addFace(f.Face.Face(), f.Font)
s.addFace(face, f.Font)
} }
func (s *shaperImpl) addFace(f *font.Face, md giofont.Font) { func (s *shaperImpl) addFace(f *font.Face, md giofont.Font) {
@@ -437,7 +437,8 @@ func (s *shaperImpl) shapeText(ppem fixed.Int26_6, lc system.Locale, txt []rune)
Height: input.Size, Height: input.Size,
XBearing: 0, XBearing: 0,
YBearing: 0, YBearing: 0,
Advance: input.Size, XAdvance: input.Size,
YAdvance: input.Size,
XOffset: 0, XOffset: 0,
YOffset: 0, YOffset: 0,
ClusterIndex: input.RunStart, ClusterIndex: input.RunStart,
@@ -853,10 +854,11 @@ func toGioGlyphs(in []shaping.Glyph, ppem fixed.Int26_6, faceIdx int) []glyph {
bounds.Max = bounds.Min.Add(fixed.Point26_6{X: g.Width, Y: -g.Height}) bounds.Max = bounds.Min.Add(fixed.Point26_6{X: g.Width, Y: -g.Height})
out = append(out, glyph{ out = append(out, glyph{
id: newGlyphID(ppem, faceIdx, g.GlyphID), id: newGlyphID(ppem, faceIdx, g.GlyphID),
clusterIndex: g.TextIndex(), clusterIndex: g.ClusterIndex,
runeCount: g.RunesCount(), runeCount: g.RuneCount,
glyphCount: g.GlyphsCount(), glyphCount: g.GlyphCount,
advance: g.Advance, xAdvance: g.XAdvance,
yAdvance: g.YAdvance,
xOffset: g.XOffset, xOffset: g.XOffset,
yOffset: g.YOffset, yOffset: g.YOffset,
bounds: bounds, bounds: bounds,
+7 -3
View File
@@ -259,6 +259,10 @@ func WithCollection(collection []FontFace) ShaperOption {
} }
// NewShaper constructs a shaper with the provided options. // NewShaper constructs a shaper with the provided options.
//
// NewShaper must be called after [app.NewWindow], unless the [NoSystemFonts]
// option is specified. This is an unfortunate restriction caused by some platforms
// such as Android.
func NewShaper(options ...ShaperOption) *Shaper { func NewShaper(options ...ShaperOption) *Shaper {
l := &Shaper{} l := &Shaper{}
for _, opt := range options { for _, opt := range options {
@@ -464,7 +468,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
if rtl { if rtl {
// Modify the advance prior to computing runOffset to ensure that the // Modify the advance prior to computing runOffset to ensure that the
// current glyph's width is subtracted in RTL. // current glyph's width is subtracted in RTL.
l.advance += g.advance l.advance += g.xAdvance
} }
// runOffset computes how far into the run the dot should be positioned. // runOffset computes how far into the run the dot should be positioned.
runOffset := l.advance runOffset := l.advance
@@ -477,7 +481,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
Y: int32(line.yOffset), Y: int32(line.yOffset),
Ascent: line.ascent, Ascent: line.ascent,
Descent: line.descent, Descent: line.descent,
Advance: g.advance, Advance: g.xAdvance,
Runes: uint16(g.runeCount), Runes: uint16(g.runeCount),
Offset: fixed.Point26_6{ Offset: fixed.Point26_6{
X: g.xOffset, X: g.xOffset,
@@ -490,7 +494,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
} }
l.glyph++ l.glyph++
if !rtl { if !rtl {
l.advance += g.advance l.advance += g.xAdvance
} }
endOfRun := l.glyph == len(run.Glyphs) endOfRun := l.glyph == len(run.Glyphs)
+2 -2
View File
@@ -450,8 +450,8 @@ func printLinePositioning(t *testing.T, lines []line, glyphs []Glyph) {
for g := start; ; g += inc { for g := start; ; g += inc {
glyph := run.Glyphs[g] glyph := run.Glyphs[g]
if glyphCursor < len(glyphs) { if glyphCursor < len(glyphs) {
t.Logf("glyph %2d, adv %3d, runes %2d, glyphs %d - glyphs[%2d] flags %s", g, glyph.advance, glyph.runeCount, glyph.glyphCount, glyphCursor, glyphs[glyphCursor].Flags) t.Logf("glyph %2d, adv %3d, runes %2d, glyphs %d - glyphs[%2d] flags %s", g, glyph.xAdvance, glyph.runeCount, glyph.glyphCount, glyphCursor, glyphs[glyphCursor].Flags)
t.Logf("glyph %2d, adv %3d, runes %2d, glyphs %d - n/a", g, glyph.advance, glyph.runeCount, glyph.glyphCount) t.Logf("glyph %2d, adv %3d, runes %2d, glyphs %d - n/a", g, glyph.xAdvance, glyph.runeCount, glyph.glyphCount)
} }
glyphCursor++ glyphCursor++
if g == end { if g == end {
-28
View File
@@ -25,7 +25,6 @@ import (
"gioui.org/layout" "gioui.org/layout"
"gioui.org/op" "gioui.org/op"
"gioui.org/op/clip" "gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text" "gioui.org/text"
"gioui.org/unit" "gioui.org/unit"
) )
@@ -109,7 +108,6 @@ type imeState struct {
caret key.Caret caret key.Caret
} }
snippet key.Snippet snippet key.Snippet
composition key.Range
start, end int start, end int
} }
@@ -400,12 +398,9 @@ func (e *Editor) processKey(gtx layout.Context) (EditorEvent, bool) {
case key.FocusEvent: case key.FocusEvent:
// Reset IME state. // Reset IME state.
e.ime.imeState = imeState{} e.ime.imeState = imeState{}
e.ime.composition = key.Range{Start: -1, End: -1}
if ke.Focus && !e.ReadOnly { if ke.Focus && !e.ReadOnly {
gtx.Execute(key.SoftKeyboardCmd{Show: true}) gtx.Execute(key.SoftKeyboardCmd{Show: true})
} }
case key.CompositionEvent:
e.ime.composition = key.Range(ke)
case key.Event: case key.Event:
if !gtx.Focused(e) || ke.State != key.Press { if !gtx.Focused(e) || ke.State != key.Press {
break break
@@ -740,7 +735,6 @@ func (e *Editor) layout(gtx layout.Context, textMaterial, selectMaterial op.Call
if e.Len() > 0 { if e.Len() > 0 {
e.paintSelection(gtx, selectMaterial) e.paintSelection(gtx, selectMaterial)
e.paintText(gtx, textMaterial) e.paintText(gtx, textMaterial)
e.paintComposition(gtx, textMaterial)
} }
if gtx.Enabled() { if gtx.Enabled() {
e.paintCaret(gtx, textMaterial) e.paintCaret(gtx, textMaterial)
@@ -765,28 +759,6 @@ func (e *Editor) paintText(gtx layout.Context, material op.CallOp) {
e.text.PaintText(gtx, material) e.text.PaintText(gtx, material)
} }
func (e *Editor) paintComposition(gtx layout.Context, material op.CallOp) {
e.initBuffer()
r := e.ime.composition
if r.Start == -1 || r.Start == r.End {
return
}
e.text.regions = e.text.Regions(r.Start, r.End, e.text.regions)
thickness := max(gtx.Dp(unit.Dp(1)), 1)
for _, region := range e.text.regions {
y := region.Bounds.Max.Y - max(region.Baseline/3, thickness)
underline := image.Rect(region.Bounds.Min.X, y, region.Bounds.Max.X, y+thickness)
underline = underline.Intersect(image.Rectangle{Max: e.text.viewSize})
if underline.Empty() {
continue
}
stack := clip.Rect(underline).Push(gtx.Ops)
material.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
stack.Pop()
}
}
// paintCaret paints the text glyphs using the provided material to set the fill material // paintCaret paints the text glyphs using the provided material to set the fill material
// of the caret rectangle. // of the caret rectangle.
func (e *Editor) paintCaret(gtx layout.Context, material op.CallOp) { func (e *Editor) paintCaret(gtx layout.Context, material op.CallOp) {
+14 -2
View File
@@ -5,6 +5,7 @@ import (
"image/color" "image/color"
"gioui.org/f32" "gioui.org/f32"
"gioui.org/io/semantic"
"gioui.org/io/system" "gioui.org/io/system"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/op" "gioui.org/op"
@@ -85,10 +86,21 @@ func (d DecorationsStyle) layoutDecorations(gtx layout.Context) layout.Dimension
continue continue
} }
cl := d.Decorations.Clickable(a) cl := d.Decorations.Clickable(a)
dims := Clickable(gtx, cl, func(gtx layout.Context) layout.Dimensions { dims := cl.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
system.ActionInputOp(a).Add(gtx.Ops) semantic.Button.Add(gtx.Ops)
return layout.Background{}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop()
for _, c := range cl.History() {
drawInk(gtx, c)
}
return layout.Dimensions{Size: gtx.Constraints.Min}
},
func(gtx layout.Context) layout.Dimensions {
paint.ColorOp{Color: d.Foreground}.Add(gtx.Ops) paint.ColorOp{Color: d.Foreground}.Add(gtx.Ops)
return inset.Layout(gtx, w) return inset.Layout(gtx, w)
},
)
}) })
size.X += dims.Size.X size.X += dims.Size.X
if size.Y < dims.Size.Y { if size.Y < dims.Size.Y {