Compare commits

..

18 Commits

Author SHA1 Message Date
qiannian 1a5fa17a39 app: update TopMost comment
TopMost is also supported on Windows.

Signed-off-by: qiannian <qianniancn@gmail.com>
2026-06-27 17:50:54 +08:00
qiannian 5191409708 app: [Windows] support TopMost option
Use HWND_TOPMOST and HWND_NOTOPMOST when applying Config.TopMost on Windows.

Leave SWP_NOZORDER set when TopMost hasn't changed, so other Configure calls don't reorder the window.

Signed-off-by: qiannian <qianniancn@gmail.com>
2026-06-27 10:38:28 +02:00
Jeff Williams 3eab806940 app: gracefully handle Windows clipboard read errors
Signed-off-by: Jeff Williams <info@anvil-editor.net>
2026-06-26 14:25:53 +02:00
qiannian 30dc7ff294 app: [Windows] don't make raised windows topmost
Use HWND_TOP instead of HWND_TOPMOST in ActionRaise so raising a
window no longer pins it on top.

Signed-off-by: qiannian <qianniancn@gmail.com>
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2026-06-25 18:53:27 +02:00
Elias Naur 9e18cb93fb ap/internal/windows: change type of HWND_TOPMOST to syscall.Handle
syscall.Handle is equivalent to the C type HWND.

Inspired by a patch by qiannian.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2026-06-25 11:18:57 +02:00
qiannian e4932e163e widget,io,app: draw IME composition underline
Track IME composition range updates as key.CompositionEvent and route them to
the focused editor. Draw a thin underline under the current composition range
using the editor text material.

Keep the composition range in imeState so it is reset with the rest of the
editor input method state, and normalize it in input.Router before forwarding
CompositionEvent.

Signed-off-by: qiannian <qianniancn@gmail.com>
2026-06-17 12:05:40 -04:00
qiannian a8fe27488f app: [Windows] avoid default IME composition window
Mark WM_IME_STARTCOMPOSITION as handled after positioning the IME
composition and candidate windows.

Passing the message on to DefWindowProc can let Windows create its default
composition window, which shows the first composing character below Gio's
caret.

Fixes: https://todo.sr.ht/~eliasnaur/gio/687
Signed-off-by: qiannian <qianniancn@gmail.com>
2026-06-17 09:07:13 +02:00
qiannian 06307313cd io/input: support direct pointer leave events
Allow platform backends to send pointer.Leave directly.
The router delivers it to entered handlers so hover state is cleared normally.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2026-06-17 08:05:23 +02:00
qiannian 15335a2b37 app: [Windows] restore double-click restore for custom title bars
Keep custom move areas mapped to HTCAPTION even when the window is
maximized so custom title bars preserve the standard Windows behavior
where double-click restores the window.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2026-06-16 10:06:47 +02:00
Qian Nian acf5635575 widget/material: add hover state to window decorations
Decorations buttons already use widget.Clickable and draw press ink, but they don't draw a hover/focus background. This makes custom window decorations appear unresponsive when the pointer is over minimize, maximize, or close buttons.

Register each button's system action over its clickable area and draw the same hovered background used by other material buttons. This also lets platforms query the button action from the material decorations hit area.

Signed-off-by: qiannian <qianniancn@gmail.com>
2026-06-14 10:50:56 +02:00
Kevin Yuan caccb608a5 app: [Windows] don't propagate WM_WINDOWPOSCHANGED to DefWindowProc
DefWindowProc handles WM_WINDOWPOSCHANGED by sending WM_SIZE and WM_MOVE
messages, which would lead us to handle resizes twice.

Per MSDN, the WM_SIZE handler is made redundant by handling
WM_WINDOWPOSCHANGED:
https://learn.microsoft.com/en-us/windows/win32/winmsg/wm-windowposchanged

Signed-off-by: Kevin Yuan <farproc@gmail.com>
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2026-05-26 12:50:02 +02:00
Kevin Yuan d52632b475 internal/egl: fix loadEGL caching error on Windows
loadEGL used sync.Once incorrectly: the error returned by
loadDLLs was assigned to a local variable inside loadEGL,
so on the second call Do would not run and the
function would return nil even though the DLLs were never
loaded. This caused a nil pointer dereference when callers
proceeded to use _eglGetDisplay and other uninitialized
function pointers.

Fix by replacing the sync.Once with sync.OnceValue, which
correctly caches the return value of loadDLLs across all
calls.

Signed-off-by: Kevin Yuan <farproc@gmail.com>
2026-05-26 08:07:26 +02:00
Egon Elbre dec57aea1c go.mod: upgrade to github.com/go-text/typesetting@v0.3.4
Signed-off-by: Egon Elbre <egonelbre@gmail.com>
2026-05-18 14:30:25 -04:00
Egon Elbre e2e2c1a046 text: avoid creating two Face instances
This way their cache can be shared.

Signed-off-by: Egon Elbre <egonelbre@gmail.com>
2026-05-18 14:30:17 -04:00
Egon Elbre e8c1e1ba11 font/opentype: fix font.Face creation
typesetting introduced a cache field that needs to be
properly initialized. Use constructor to avoid the issue.

Signed-off-by: Egon Elbre <egonelbre@gmail.com>
2026-05-18 14:30:12 -04:00
Eugene b1cadbdd76 io/input: do not track scroll events as pointers
Scroll events arrived at pointerQueue.Push and went through pointerOf
+ deliverEnterLeaveEvents + deliverEvent like Move/Press/Release. The
side effect: every scroll created or updated a state.pointers entry,
populated p.entered with whatever handlers sat under the wheel
position, and overwrote state.cursor based on hit-test at the scroll
position.

When the platform layer reports scroll with a different PointerID
than mouse-move events for the same physical mouse — which the
Windows backend does (scrollEvent omits PointerID, defaulting to 0,
while pointerUpdate forwards Windows' assigned ID) — the scroll
spawns a phantom state.pointers entry. Subsequent moves go to the
mouse's "real" entry, so the phantom never receives Leave events,
its entered set never empties, and the cleanup at the end of Push
keeps it alive. pointerQueue.Frame then runs hit-test for it every
frame at the user's last scroll position, threading state.cursor
through it after the live pointer's resolution and clobbering it
with whatever's under the scroll position.

The wheel is positional but isn't a pointer. Treating it as one is
the bug. Hit-test inline at the scroll position to find delivery
targets, dispatch via deliverEvent (which already handles filter
matching, scroll axis clamping, and area-local position), and
return without creating or updating a state.pointers entry.

Add a router-level test that fails without the fix: a Move sets the
cursor over a CursorPointer region, a subsequent Scroll over a
CursorText region, and the test asserts the cursor is still
CursorPointer. Pre-fix the scroll's deliverEnterLeaveEvents
overwrites state.cursor with CursorText.

Signed-off-by: Eugene <eugenebosyakov@gmail.com>
2026-05-18 09:05:32 +02:00
Chris Waldon 451b7d3a74 text: drop obsolete comment about NewWindow
Fixes: https://todo.sr.ht/~eliasnaur/gio/681
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2026-05-06 13:29:01 -04:00
Eugene e49c5b02c7 gesture: refresh PointerID on Press and Enter
Click and Hover both stored the first PointerID they observed in
their internal pid field and only updated it when not currently
hovered/entered. Once the gesture became hovered, any later event
under a different PointerID was effectively ignored: Click.Press
fell through 'c.pid != e.PointerID' and was silently dropped, and
Hover could never reset entered when the matching Leave arrived
under a new ID.

The Windows backend enables EnableMouseInPointer
(app/os_windows.go), under which Windows reassigns the same
physical mouse's PointerID across focus changes, window
leave/re-enter, and similar events. Once a widget had been hovered,
every subsequent press on it failed to register, including
widget.Editor's internal clicker that positions the caret on press.
Multi-line editors silently refused to move the caret on click
after the window had received any focus event.

Always take the latest PointerID on Hover.Enter and Click.Press.
The Press/Release handshake still works because Press now records
the press's own PointerID and Release continues to gate on
'c.pid != e.PointerID' so an unrelated pointer's release can't end
the press tracking.

Signed-off-by: Eugene <eugenebosyakov@gmail.com>
2026-04-30 06:19:20 +02:00
22 changed files with 326 additions and 163 deletions
+3
View File
@@ -115,6 +115,9 @@ func (c *context) Unlock() {
}
func (c *context) Refresh() error {
if C.gio_makeCurrent(c.ctx) == 0 {
return errors.New("[EAGLContext setCurrentContext] failed")
}
if !c.init {
c.init = true
c.frameBuffer = c.c.CreateFramebuffer()
+2
View File
@@ -111,6 +111,8 @@ func (c *glContext) Unlock() {
}
func (c *glContext) Refresh() error {
c.Lock()
defer c.Unlock()
C.gio_updateContext(c.ctx)
return nil
}
+4 -2
View File
@@ -207,7 +207,9 @@ const (
CFS_POINT = 0x0002
CFS_CANDIDATEPOS = 0x0040
HWND_TOPMOST = ^(uint32(1) - 1) // -1
HWND_TOP = syscall.Handle(0)
HWND_TOPMOST = ^(syscall.Handle(1) - 1) // -1
HWND_NOTOPMOST = ^(syscall.Handle(2) - 1) // -2
HTCAPTION = 2
HTCLIENT = 1
@@ -782,7 +784,7 @@ func SetWindowPlacement(hwnd syscall.Handle, wp *WindowPlacement) {
_SetWindowPlacement.Call(uintptr(hwnd), uintptr(unsafe.Pointer(wp)))
}
func SetWindowPos(hwnd syscall.Handle, hwndInsertAfter uint32, x, y, dx, dy int32, style uintptr) {
func SetWindowPos(hwnd, hwndInsertAfter syscall.Handle, x, y, dx, dy int32, style uintptr) {
_SetWindowPos.Call(uintptr(hwnd), uintptr(hwndInsertAfter),
uintptr(x), uintptr(y),
uintptr(dx), uintptr(dy),
-2
View File
@@ -165,8 +165,6 @@ type frameEvent struct {
Sync bool
}
// The caller must hold the context lock while using API, Refresh,
// RenderTarget, or Present.
type context interface {
API() gpu.API
RenderTarget() (gpu.RenderTarget, error)
+54 -41
View File
@@ -369,8 +369,7 @@ func windowProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uintptr
w.update()
case windows.WM_WINDOWPOSCHANGED:
w.update()
case windows.WM_SIZE:
w.update()
return 0
case windows.WM_GETMINMAXINFO:
mm := (*windows.MinMaxInfo)(unsafe.Pointer(lParam))
@@ -413,6 +412,7 @@ 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,34 +495,32 @@ 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 {
// 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
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
}
}
p := f32.Pt(float32(x), float32(y))
if a, ok := w.w.ActionAt(p); ok && a == system.ActionMove {
@@ -701,7 +699,13 @@ func (w *window) ReadClipboard() {
w.readClipboard()
}
func (w *window) readClipboard() error {
func (w *window) readClipboard() (cerr error) {
defer func() {
if cerr != nil {
w.processDataEvent("")
}
}()
if err := windows.OpenClipboard(w.hwnd); err != nil {
return err
}
@@ -716,13 +720,17 @@ func (w *window) readClipboard() 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) {
@@ -739,7 +747,16 @@ 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_NOZORDER | windows.SWP_FRAMECHANGED)
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
winStyle := uintptr(windows.WS_OVERLAPPEDWINDOW)
style &^= winStyle
switch cnf.Mode {
@@ -791,7 +808,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, 0, x, y, width, height, swpStyle)
windows.SetWindowPos(w.hwnd, hwndAfter, x, y, width, height, swpStyle)
windows.SetWindowLong(w.hwnd, windows.GWL_STYLE, style)
windows.ShowWindow(w.hwnd, showMode)
}
@@ -912,19 +929,15 @@ 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:
w.raise()
windows.SetForegroundWindow(w.hwnd)
windows.SetWindowPos(w.hwnd, windows.HWND_TOP, 0, 0, 0, 0,
windows.SWP_NOMOVE|windows.SWP_NOSIZE|windows.SWP_SHOWWINDOW)
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
+14 -42
View File
@@ -46,10 +46,6 @@ 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.
@@ -150,14 +146,9 @@ func (w *Window) validateAndProcess(size image.Point, sync bool, frame *op.Ops,
if err != nil {
return err
}
w.ctxNeedsLock = true
sync = true
}
}
if err := w.lockContext(); err != nil {
w.destroyGPU()
return err
}
if sync && w.ctx != nil {
if err := w.ctx.Refresh(); err != nil {
if errors.Is(err, errOutOfDate) {
@@ -171,8 +162,9 @@ func (w *Window) validateAndProcess(size image.Point, sync bool, frame *op.Ops,
}
return err
}
w.unlockContext()
if err := w.lockContext(); err != nil {
}
if w.ctx != nil {
if err := w.ctx.Lock(); err != nil {
w.destroyGPU()
return err
}
@@ -180,7 +172,7 @@ func (w *Window) validateAndProcess(size image.Point, sync bool, frame *op.Ops,
if w.gpu == nil && !w.nocontext {
gpu, err := gpu.New(w.ctx.API())
if err != nil {
w.unlockContext()
w.ctx.Unlock()
w.destroyGPU()
return err
}
@@ -188,7 +180,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.unlockContext()
w.ctx.Unlock()
if errors.Is(err, errOutOfDate) {
// GPU surface needs refreshing.
sync = true
@@ -208,6 +200,7 @@ 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
}
@@ -446,6 +439,7 @@ 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) {
@@ -510,37 +504,16 @@ func (c *callbacks) ActionAt(p f32.Point) (system.Action, bool) {
return c.w.queue.ActionAt(p)
}
func (w *Window) lockContext() error {
if w.ctx == nil || !w.ctxNeedsLock {
return nil
}
if err := w.ctx.Lock(); err != nil {
return err
}
w.ctxNeedsLock = false
return nil
}
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 {
if err := w.lockContext(); err == nil {
w.gpu.Release()
w.unlockContext()
}
w.ctx.Lock()
w.gpu.Release()
w.ctx.Unlock()
w.gpu = nil
}
if w.ctx != nil {
w.ctx.Release()
w.ctx = nil
w.ctxNeedsLock = false
}
}
@@ -683,11 +656,10 @@ func (w *Window) processEvent(e event.Event) bool {
w.coalesced.destroy = &e2
case ViewEvent:
if !e2.Valid() && w.gpu != nil {
if err := w.lockContext(); err == nil {
w.gpu.Release()
w.unlockContext()
}
w.ctx.Lock()
w.gpu.Release()
w.gpu = nil
w.ctx.Unlock()
}
w.coalesced.view = &e2
case ConfigEvent:
@@ -1000,7 +972,7 @@ func Decorated(enabled bool) Option {
// TopMost windows will be rendered above all other non-top-most windows.
//
// TopMost windows are only supported on MacOS currently.
// TopMost windows are supported on macOS, Windows.
func TopMost(enabled bool) Option {
return func(_ unit.Metric, cnf *Config) {
cnf.TopMost = enabled
+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.Face{Font: f.face}
return fontapi.NewFace(f.face)
}
// FontFace returns a text.Font with populated font metadata for the
+3 -12
View File
@@ -61,12 +61,8 @@ func (h *Hover) Update(q input.Source) bool {
h.entered = false
}
case pointer.Enter:
if !h.entered {
h.pid = e.PointerID
}
if h.pid == e.PointerID {
h.entered = true
}
h.pid = e.PointerID
h.entered = true
}
}
return h.entered
@@ -222,12 +218,7 @@ func (c *Click) Update(q input.Source) (ClickEvent, bool) {
if e.Source == pointer.Mouse && e.Buttons != pointer.ButtonPrimary {
break
}
if !c.hovered {
c.pid = e.PointerID
}
if c.pid != e.PointerID {
break
}
c.pid = e.PointerID
c.pressed = true
if e.Time-c.clickedAt < doubleClickDuration {
c.clicks++
+72
View File
@@ -100,6 +100,78 @@ 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.0
github.com/go-text/typesetting v0.3.4
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.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=
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=
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=
+2 -6
View File
@@ -41,14 +41,10 @@ var (
_eglWaitClient *syscall.Proc
)
var loadOnce sync.Once
var loadOnce = sync.OnceValue(loadDLLs)
func loadEGL() error {
var err error
loadOnce.Do(func() {
err = loadDLLs()
})
return err
return loadOnce()
}
func loadDLLs() error {
+19 -4
View File
@@ -739,6 +739,10 @@ 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]
@@ -756,14 +760,13 @@ 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")
}
@@ -780,6 +783,18 @@ 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
@@ -810,7 +825,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.Source != pointer.Mouse && !p.pressed && e.Kind != pointer.Press {
if e.Kind == pointer.Leave || 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,6 +255,45 @@ 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
@@ -1345,3 +1384,40 @@ 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)
}
}
+8 -1
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:
case key.FocusEvent, key.SnippetEvent, key.EditEvent, key.SelectionEvent, key.CompositionEvent:
return f.focusable
default:
return f.pointer.Matches(e)
@@ -463,6 +463,13 @@ 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 {
+9 -5
View File
@@ -77,6 +77,9 @@ 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
@@ -243,11 +246,12 @@ func (h InputHintOp) Add(o *op.Ops) {
data[1] = byte(h.Hint)
}
func (EditEvent) ImplementsEvent() {}
func (Event) ImplementsEvent() {}
func (FocusEvent) ImplementsEvent() {}
func (SnippetEvent) ImplementsEvent() {}
func (SelectionEvent) ImplementsEvent() {}
func (EditEvent) ImplementsEvent() {}
func (Event) ImplementsEvent() {}
func (FocusEvent) ImplementsEvent() {}
func (CompositionEvent) ImplementsEvent() {}
func (SnippetEvent) ImplementsEvent() {}
func (SelectionEvent) ImplementsEvent() {}
func (FocusCmd) ImplementsCommand() {}
func (SoftKeyboardCmd) ImplementsCommand() {}
+3 -1
View File
@@ -19,7 +19,9 @@ 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.
// Release. Populated for Press, Release, Move, Drag,
// Enter, Leave, and Cancel; Scroll events are not
// bound to a tracked pointer and leave it zero.
PointerID ID
// Priority is the priority of the receiving handler
// for this event.
+12 -14
View File
@@ -103,8 +103,7 @@ func (l *line) insertTrailingSyntheticNewline(newLineClusterIdx int) {
clusterIndex: newLineClusterIdx,
glyphCount: 0,
runeCount: 1,
xAdvance: 0,
yAdvance: 0,
advance: 0,
xOffset: 0,
yOffset: 0,
}
@@ -160,9 +159,9 @@ type glyph struct {
// runeCount is the quantity of runes in the source text that this glyph
// corresponds to.
runeCount int
// 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
// advance is the distance the dot moves when laying out the glyph along
// the run's primary axis.
advance fixed.Int26_6
// xOffset and yOffset describe offsets from the dot that should be
// applied when rendering the glyph.
xOffset, yOffset fixed.Int26_6
@@ -270,8 +269,9 @@ 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)
s.fontMap.AddFace(f.Face.Face(), fontscan.Location{File: fmt.Sprint(desc)}, desc)
s.addFace(f.Face.Face(), f.Font)
face := f.Face.Face()
s.fontMap.AddFace(face, fontscan.Location{File: fmt.Sprint(desc)}, desc)
s.addFace(face, f.Font)
}
func (s *shaperImpl) addFace(f *font.Face, md giofont.Font) {
@@ -437,8 +437,7 @@ func (s *shaperImpl) shapeText(ppem fixed.Int26_6, lc system.Locale, txt []rune)
Height: input.Size,
XBearing: 0,
YBearing: 0,
XAdvance: input.Size,
YAdvance: input.Size,
Advance: input.Size,
XOffset: 0,
YOffset: 0,
ClusterIndex: input.RunStart,
@@ -854,11 +853,10 @@ 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.ClusterIndex,
runeCount: g.RuneCount,
glyphCount: g.GlyphCount,
xAdvance: g.XAdvance,
yAdvance: g.YAdvance,
clusterIndex: g.TextIndex(),
runeCount: g.RunesCount(),
glyphCount: g.GlyphsCount(),
advance: g.Advance,
xOffset: g.XOffset,
yOffset: g.YOffset,
bounds: bounds,
+3 -7
View File
@@ -259,10 +259,6 @@ 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 {
@@ -468,7 +464,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.xAdvance
l.advance += g.advance
}
// runOffset computes how far into the run the dot should be positioned.
runOffset := l.advance
@@ -481,7 +477,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
Y: int32(line.yOffset),
Ascent: line.ascent,
Descent: line.descent,
Advance: g.xAdvance,
Advance: g.advance,
Runes: uint16(g.runeCount),
Offset: fixed.Point26_6{
X: g.xOffset,
@@ -494,7 +490,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
}
l.glyph++
if !rtl {
l.advance += g.xAdvance
l.advance += g.advance
}
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.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)
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)
}
glyphCursor++
if g == end {
+30 -2
View File
@@ -25,6 +25,7 @@ import (
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
)
@@ -107,8 +108,9 @@ type imeState struct {
rng key.Range
caret key.Caret
}
snippet key.Snippet
start, end int
snippet key.Snippet
composition key.Range
start, end int
}
type maskReader struct {
@@ -398,9 +400,12 @@ 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
@@ -735,6 +740,7 @@ 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)
@@ -759,6 +765,28 @@ 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) {
+4 -16
View File
@@ -5,7 +5,6 @@ import (
"image/color"
"gioui.org/f32"
"gioui.org/io/semantic"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
@@ -86,21 +85,10 @@ func (d DecorationsStyle) layoutDecorations(gtx layout.Context) layout.Dimension
continue
}
cl := d.Decorations.Clickable(a)
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)
},
)
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)
})
size.X += dims.Size.X
if size.Y < dims.Size.Y {