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_CANDIDATEPOS = 0x0040
HWND_TOP = syscall.Handle(0)
HWND_TOPMOST = ^(syscall.Handle(1) - 1) // -1
HWND_NOTOPMOST = ^(syscall.Handle(2) - 1) // -2
HWND_TOPMOST = ^(uint32(1) - 1) // -1
HTCAPTION = 2
HTCLIENT = 1
@@ -784,7 +782,7 @@ func SetWindowPlacement(hwnd syscall.Handle, wp *WindowPlacement) {
_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),
uintptr(x), uintptr(y),
uintptr(dx), uintptr(dy),
+41 -54
View File
@@ -369,7 +369,8 @@ func windowProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uintptr
w.update()
case windows.WM_WINDOWPOSCHANGED:
w.update()
return 0
case windows.WM_SIZE:
w.update()
case windows.WM_GETMINMAXINFO:
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))
windows.ImmSetCompositionWindow(imc, icaret.X, icaret.Y)
windows.ImmSetCandidateWindow(imc, icaret.X, icaret.Y)
return windows.TRUE
case windows.WM_IME_COMPOSITION:
imc := windows.ImmGetContext(w.hwnd)
if imc == 0 {
@@ -495,32 +495,34 @@ func getModifiers() key.Modifiers {
// hitTest returns the non-client area hit by the point, needed to
// process WM_NCHITTEST.
func (w *window) hitTest(x, y int) uintptr {
if w.config.Mode == Windowed {
// 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
// edge of the window.
top := y <= w.borderSize.Y
bottom := y >= w.config.Size.Y-w.borderSize.Y
left := x <= w.borderSize.X
right := x >= w.config.Size.X-w.borderSize.X
switch {
case top && left:
return windows.HTTOPLEFT
case top && right:
return windows.HTTOPRIGHT
case bottom && left:
return windows.HTBOTTOMLEFT
case bottom && right:
return windows.HTBOTTOMRIGHT
case top:
return windows.HTTOP
case bottom:
return windows.HTBOTTOM
case left:
return windows.HTLEFT
case right:
return windows.HTRIGHT
}
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
// resize a custom-decorations window when the system move area is flush with the
// edge of the window.
top := y <= w.borderSize.Y
bottom := y >= w.config.Size.Y-w.borderSize.Y
left := x <= w.borderSize.X
right := x >= w.config.Size.X-w.borderSize.X
switch {
case top && left:
return windows.HTTOPLEFT
case top && right:
return windows.HTTOPRIGHT
case bottom && left:
return windows.HTBOTTOMLEFT
case bottom && right:
return windows.HTBOTTOMRIGHT
case top:
return windows.HTTOP
case bottom:
return windows.HTBOTTOM
case left:
return windows.HTLEFT
case right:
return windows.HTRIGHT
}
p := f32.Pt(float32(x), float32(y))
if a, ok := w.w.ActionAt(p); ok && a == system.ActionMove {
@@ -699,13 +701,7 @@ func (w *window) ReadClipboard() {
w.readClipboard()
}
func (w *window) readClipboard() (cerr error) {
defer func() {
if cerr != nil {
w.processDataEvent("")
}
}()
func (w *window) readClipboard() error {
if err := windows.OpenClipboard(w.hwnd); err != nil {
return err
}
@@ -720,17 +716,13 @@ func (w *window) readClipboard() (cerr error) {
}
defer windows.GlobalUnlock(mem)
content := gowindows.UTF16PtrToString((*uint16)(unsafe.Pointer(ptr)))
w.processDataEvent(content)
return nil
}
func (w *window) processDataEvent(content string) {
w.ProcessEvent(transfer.DataEvent{
Type: "application/text",
Open: func() io.ReadCloser {
return io.NopCloser(strings.NewReader(content))
},
})
return nil
}
func (w *window) Configure(options []Option) {
@@ -747,16 +739,7 @@ func (w *window) Configure(options []Option) {
style := windows.GetWindowLong(w.hwnd, windows.GWL_STYLE)
var showMode int32
var x, y, width, height int32
swpStyle := uintptr(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
swpStyle := uintptr(windows.SWP_NOZORDER | windows.SWP_FRAMECHANGED)
winStyle := uintptr(windows.WS_OVERLAPPEDWINDOW)
style &^= winStyle
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
// 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.ShowWindow(w.hwnd, showMode)
}
@@ -929,15 +912,19 @@ func (w *window) Perform(acts system.Action) {
y := (mi.Bottom - mi.Top - dy) / 2
windows.SetWindowPos(w.hwnd, 0, x, y, dx, dy, windows.SWP_NOZORDER|windows.SWP_FRAMECHANGED)
case system.ActionRaise:
windows.SetForegroundWindow(w.hwnd)
windows.SetWindowPos(w.hwnd, windows.HWND_TOP, 0, 0, 0, 0,
windows.SWP_NOMOVE|windows.SWP_NOSIZE|windows.SWP_SHOWWINDOW)
w.raise()
case system.ActionClose:
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) {
if '0' <= code && code <= '9' || 'A' <= code && code <= 'Z' {
return key.Name(rune(code)), true
+29 -12
View File
@@ -46,6 +46,10 @@ type Window struct {
ctx context
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 struct {
// 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 {
return err
}
w.ctxNeedsLock = true
sync = true
}
}
@@ -162,17 +167,19 @@ func (w *Window) validateAndProcess(size image.Point, sync bool, frame *op.Ops,
}
return err
}
w.ctxNeedsLock = true
}
if w.ctx != nil {
if w.ctx != nil && w.ctxNeedsLock {
if err := w.ctx.Lock(); err != nil {
w.destroyGPU()
return err
}
w.ctxNeedsLock = false
}
if w.gpu == nil && !w.nocontext {
gpu, err := gpu.New(w.ctx.API())
if err != nil {
w.ctx.Unlock()
w.unlockContext()
w.destroyGPU()
return err
}
@@ -180,7 +187,7 @@ func (w *Window) validateAndProcess(size image.Point, sync bool, frame *op.Ops,
}
if w.gpu != nil {
if err := w.frame(frame, size); err != nil {
w.ctx.Unlock()
w.unlockContext()
if errors.Is(err, errOutOfDate) {
// GPU surface needs refreshing.
sync = true
@@ -200,7 +207,6 @@ func (w *Window) validateAndProcess(size image.Point, sync bool, frame *op.Ops,
var err error
if w.gpu != nil {
err = w.ctx.Present()
w.ctx.Unlock()
}
return err
}
@@ -439,7 +445,6 @@ func (c *callbacks) EditorState() editorState {
func (c *callbacks) SetComposingRegion(r key.Range) {
c.w.imeState.compose = r
c.w.driver.ProcessEvent(key.CompositionEvent(r))
}
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)
}
func (w *Window) unlockContext() {
if w.ctx == nil || w.ctxNeedsLock {
return
}
w.ctx.Unlock()
w.ctxNeedsLock = true
}
func (w *Window) destroyGPU() {
if w.gpu != nil {
w.ctx.Lock()
w.gpu.Release()
w.ctx.Unlock()
if err := w.ctx.Lock(); err == nil {
w.gpu.Release()
w.ctx.Unlock()
}
w.gpu = nil
}
if w.ctx != nil {
w.ctx.Release()
w.ctx = nil
w.ctxNeedsLock = false
}
}
@@ -656,10 +671,12 @@ func (w *Window) processEvent(e event.Event) bool {
w.coalesced.destroy = &e2
case ViewEvent:
if !e2.Valid() && w.gpu != nil {
w.ctx.Lock()
w.gpu.Release()
if err := w.ctx.Lock(); err == nil {
w.gpu.Release()
w.ctx.Unlock()
}
w.gpu = nil
w.ctx.Unlock()
w.ctxNeedsLock = true
}
w.coalesced.view = &e2
case ConfigEvent:
@@ -972,7 +989,7 @@ func Decorated(enabled bool) Option {
// 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 {
return func(_ unit.Metric, cnf *Config) {
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
// only used by one goroutine.
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
+12 -3
View File
@@ -61,8 +61,12 @@ func (h *Hover) Update(q input.Source) bool {
h.entered = false
}
case pointer.Enter:
h.pid = e.PointerID
h.entered = true
if !h.entered {
h.pid = e.PointerID
}
if h.pid == e.PointerID {
h.entered = true
}
}
}
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 {
break
}
c.pid = e.PointerID
if !c.hovered {
c.pid = e.PointerID
}
if c.pid != e.PointerID {
break
}
c.pressed = true
if e.Time-c.clickedAt < doubleClickDuration {
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 {
press := pointer.Event{
Kind: pointer.Press,
+1 -1
View File
@@ -5,7 +5,7 @@ go 1.24.0
require (
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d
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/shiny v0.0.0-20250408133849-7e4ce0ab07d0
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/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
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.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/go-text/typesetting v0.3.0 h1:OWCgYpp8njoxSRpwrdd1bQOxdjOXDj9Rqart9ML4iF4=
github.com/go-text/typesetting v0.3.0/go.mod h1:qjZLkhRgOEYMhU9eHBr3AR4sfnGJvOXNLt8yRAySFuY=
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
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/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
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
)
var loadOnce = sync.OnceValue(loadDLLs)
var loadOnce sync.Once
func loadEGL() error {
return loadOnce()
var err error
loadOnce.Do(func() {
err = loadDLLs()
})
return err
}
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
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)
p := state.pointers[pidx]
@@ -760,13 +756,14 @@ func (q *pointerQueue) Push(handlers map[event.Tag]*handler, state pointerState,
if p.pressed {
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:
evts = q.deliverEvent(handlers, p, evts, e)
p.pressed = false
p, evts, state.cursor, _ = q.deliverEnterLeaveEvents(handlers, state.cursor, p, evts, e)
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:
panic("unsupported pointer event type")
}
@@ -783,18 +780,6 @@ func (q *pointerQueue) Push(handlers map[event.Tag]*handler, state pointerState,
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 {
if p.pressed && len(p.handlers) == 1 {
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) {
changed := false
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.
} else {
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)
}
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) {
handler := new(int)
var ops op.Ops
@@ -1384,40 +1345,3 @@ func events(r *Router, n int, filters ...event.Filter) []event.Event {
}
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 {
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
default:
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})
}
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:
var evts []taggedEvent
if f := state.focus; f != nil {
+5 -9
View File
@@ -77,9 +77,6 @@ type Caret struct {
// SelectionEvent is generated when an input method changes the selection.
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
// input method.
type SnippetEvent Range
@@ -246,12 +243,11 @@ func (h InputHintOp) Add(o *op.Ops) {
data[1] = byte(h.Hint)
}
func (EditEvent) ImplementsEvent() {}
func (Event) ImplementsEvent() {}
func (FocusEvent) ImplementsEvent() {}
func (CompositionEvent) ImplementsEvent() {}
func (SnippetEvent) ImplementsEvent() {}
func (SelectionEvent) ImplementsEvent() {}
func (EditEvent) ImplementsEvent() {}
func (Event) ImplementsEvent() {}
func (FocusEvent) ImplementsEvent() {}
func (SnippetEvent) ImplementsEvent() {}
func (SelectionEvent) ImplementsEvent() {}
func (FocusCmd) ImplementsCommand() {}
func (SoftKeyboardCmd) ImplementsCommand() {}
+1 -3
View File
@@ -19,9 +19,7 @@ type Event struct {
Source Source
// PointerID is the id for the pointer and can be used
// to track a particular pointer from Press to
// Release. Populated for Press, Release, Move, Drag,
// Enter, Leave, and Cancel; Scroll events are not
// bound to a tracked pointer and leave it zero.
// Release.
PointerID ID
// Priority is the priority of the receiving handler
// for this event.
+14 -12
View File
@@ -103,7 +103,8 @@ func (l *line) insertTrailingSyntheticNewline(newLineClusterIdx int) {
clusterIndex: newLineClusterIdx,
glyphCount: 0,
runeCount: 1,
advance: 0,
xAdvance: 0,
yAdvance: 0,
xOffset: 0,
yOffset: 0,
}
@@ -159,9 +160,9 @@ type glyph struct {
// runeCount is the quantity of runes in the source text that this glyph
// corresponds to.
runeCount int
// advance is the distance the dot moves when laying out the glyph along
// the run's primary axis.
advance fixed.Int26_6
// xAdvance and yAdvance describe the distance the dot moves when
// laying out the glyph on the X or Y axis.
xAdvance, yAdvance fixed.Int26_6
// xOffset and yOffset describe offsets from the dot that should be
// applied when rendering the glyph.
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.
func (s *shaperImpl) Load(f FontFace) {
desc := opentype.FontToDescription(f.Font)
face := f.Face.Face()
s.fontMap.AddFace(face, fontscan.Location{File: fmt.Sprint(desc)}, desc)
s.addFace(face, f.Font)
s.fontMap.AddFace(f.Face.Face(), fontscan.Location{File: fmt.Sprint(desc)}, desc)
s.addFace(f.Face.Face(), f.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,
XBearing: 0,
YBearing: 0,
Advance: input.Size,
XAdvance: input.Size,
YAdvance: input.Size,
XOffset: 0,
YOffset: 0,
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})
out = append(out, glyph{
id: newGlyphID(ppem, faceIdx, g.GlyphID),
clusterIndex: g.TextIndex(),
runeCount: g.RunesCount(),
glyphCount: g.GlyphsCount(),
advance: g.Advance,
clusterIndex: g.ClusterIndex,
runeCount: g.RuneCount,
glyphCount: g.GlyphCount,
xAdvance: g.XAdvance,
yAdvance: g.YAdvance,
xOffset: g.XOffset,
yOffset: g.YOffset,
bounds: bounds,
+7 -3
View File
@@ -259,6 +259,10 @@ func WithCollection(collection []FontFace) ShaperOption {
}
// 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 {
l := &Shaper{}
for _, opt := range options {
@@ -464,7 +468,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
if rtl {
// Modify the advance prior to computing runOffset to ensure that the
// 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 := l.advance
@@ -477,7 +481,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
Y: int32(line.yOffset),
Ascent: line.ascent,
Descent: line.descent,
Advance: g.advance,
Advance: g.xAdvance,
Runes: uint16(g.runeCount),
Offset: fixed.Point26_6{
X: g.xOffset,
@@ -490,7 +494,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
}
l.glyph++
if !rtl {
l.advance += g.advance
l.advance += g.xAdvance
}
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 {
glyph := run.Glyphs[g]
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 - n/a", g, glyph.advance, glyph.runeCount, glyph.glyphCount)
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.xAdvance, glyph.runeCount, glyph.glyphCount)
}
glyphCursor++
if g == end {
+2 -30
View File
@@ -25,7 +25,6 @@ import (
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
)
@@ -108,9 +107,8 @@ type imeState struct {
rng key.Range
caret key.Caret
}
snippet key.Snippet
composition key.Range
start, end int
snippet key.Snippet
start, end int
}
type maskReader struct {
@@ -400,12 +398,9 @@ func (e *Editor) processKey(gtx layout.Context) (EditorEvent, bool) {
case key.FocusEvent:
// Reset IME state.
e.ime.imeState = imeState{}
e.ime.composition = key.Range{Start: -1, End: -1}
if ke.Focus && !e.ReadOnly {
gtx.Execute(key.SoftKeyboardCmd{Show: true})
}
case key.CompositionEvent:
e.ime.composition = key.Range(ke)
case key.Event:
if !gtx.Focused(e) || ke.State != key.Press {
break
@@ -740,7 +735,6 @@ func (e *Editor) layout(gtx layout.Context, textMaterial, selectMaterial op.Call
if e.Len() > 0 {
e.paintSelection(gtx, selectMaterial)
e.paintText(gtx, textMaterial)
e.paintComposition(gtx, textMaterial)
}
if gtx.Enabled() {
e.paintCaret(gtx, textMaterial)
@@ -765,28 +759,6 @@ func (e *Editor) paintText(gtx layout.Context, material op.CallOp) {
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
// of the caret rectangle.
func (e *Editor) paintCaret(gtx layout.Context, material op.CallOp) {
+16 -4
View File
@@ -5,6 +5,7 @@ import (
"image/color"
"gioui.org/f32"
"gioui.org/io/semantic"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
@@ -85,10 +86,21 @@ func (d DecorationsStyle) layoutDecorations(gtx layout.Context) layout.Dimension
continue
}
cl := d.Decorations.Clickable(a)
dims := Clickable(gtx, cl, func(gtx layout.Context) layout.Dimensions {
system.ActionInputOp(a).Add(gtx.Ops)
paint.ColorOp{Color: d.Foreground}.Add(gtx.Ops)
return inset.Layout(gtx, w)
dims := cl.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
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)
return inset.Layout(gtx, w)
},
)
})
size.X += dims.Size.X
if size.Y < dims.Size.Y {